using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using StellaOps.Authority.Bootstrap; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Cryptography.Audit; using Xunit; namespace StellaOps.Authority.Tests.Bootstrap; public sealed class BootstrapInviteCleanupServiceTests { [Fact] public async Task SweepExpiredInvitesAsync_ExpiresInvitesAndEmitsAuditRecords() { var now = new DateTimeOffset(2025, 10, 14, 12, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(now); var invites = new List { new() { Token = "token-1", Type = BootstrapInviteTypes.User, ExpiresAt = now.AddMinutes(-5), Provider = "standard", Target = "alice@example.com", Status = AuthorityBootstrapInviteStatuses.Pending }, new() { Token = "token-2", Type = BootstrapInviteTypes.Client, ExpiresAt = now.AddMinutes(-1), Provider = "standard", Target = "client-1", Status = AuthorityBootstrapInviteStatuses.Reserved } }; var store = new FakeInviteStore(invites); var sink = new CapturingAuthEventSink(); var service = new BootstrapInviteCleanupService(store, sink, timeProvider, NullLogger.Instance); await service.SweepExpiredInvitesAsync(CancellationToken.None); Assert.True(store.ExpireCalled); Assert.Equal(2, sink.Events.Count); Assert.All(sink.Events, record => Assert.Equal("authority.bootstrap.invite.expired", record.EventType)); Assert.Contains(sink.Events, record => record.Properties.Any(property => property.Name == "invite.token" && property.Value.Value == "token-1")); Assert.Contains(sink.Events, record => record.Properties.Any(property => property.Name == "invite.token" && property.Value.Value == "token-2")); } private sealed class FakeInviteStore : IAuthorityBootstrapInviteStore { private readonly IReadOnlyList invites; public FakeInviteStore(IReadOnlyList invites) => this.invites = invites; public bool ExpireCalled { get; private set; } public ValueTask CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken) => throw new NotImplementedException(); public ValueTask TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken) => ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null)); public ValueTask ReleaseAsync(string token, CancellationToken cancellationToken) => ValueTask.FromResult(false); public ValueTask MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken) => ValueTask.FromResult(false); public ValueTask> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken) { ExpireCalled = true; return ValueTask.FromResult(invites); } } private sealed class CapturingAuthEventSink : IAuthEventSink { public List Events { get; } = new(); public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) { Events.Add(record); return ValueTask.CompletedTask; } } }