Files
git.stella-ops.org/src/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/BootstrapInviteCleanupServiceTests.cs
master 79823d3319 up
2025-10-15 10:03:56 +03:00

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;
}
}
}