98 lines
4.0 KiB
C#
98 lines
4.0 KiB
C#
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<AuthorityBootstrapInviteDocument>
|
|
{
|
|
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<BootstrapInviteCleanupService>.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<AuthorityBootstrapInviteDocument> invites;
|
|
|
|
public FakeInviteStore(IReadOnlyList<AuthorityBootstrapInviteDocument> invites)
|
|
=> this.invites = invites;
|
|
|
|
public bool ExpireCalled { get; private set; }
|
|
|
|
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken)
|
|
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
|
|
|
|
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
|
|
=> ValueTask.FromResult(false);
|
|
|
|
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
|
|
=> ValueTask.FromResult(false);
|
|
|
|
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
ExpireCalled = true;
|
|
return ValueTask.FromResult(invites);
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingAuthEventSink : IAuthEventSink
|
|
{
|
|
public List<AuthEventRecord> Events { get; } = new();
|
|
|
|
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
|
{
|
|
Events.Add(record);
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
}
|