diff --git a/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs index a77af88f..9539643d 100644 --- a/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -1,10 +1,10 @@ +using System.Linq.Expressions; using System.Security.Claims; using System.Text.Encodings.Web; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; @@ -17,15 +17,26 @@ namespace Orbit.Infrastructure.Tests.Authentication; public class ApiKeyAuthenticationHandlerTests { - private static readonly Guid TestUserId = Guid.NewGuid(); - - private static async Task RunHandler(string? authorizationHeader) + private sealed record HandlerRun( + AuthenticateResult Result, + IGenericRepository ApiKeyRepo, + IUnitOfWork UnitOfWork); + + private static async Task RunHandler( + string? authorizationHeader, + string path = "/mcp", + IReadOnlyList? candidates = null, + Result? payGateResult = null) { var apiKeyRepo = Substitute.For>(); var payGate = Substitute.For(); var unitOfWork = Substitute.For(); + payGate.CanReadApiKeys(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Success())); + .Returns(Task.FromResult(payGateResult ?? Result.Success())); + apiKeyRepo.FindTrackedAsync( + Arg.Any>>(), Arg.Any()) + .Returns(candidates ?? []); var services = new ServiceCollection(); services.AddSingleton(apiKeyRepo); @@ -36,92 +47,136 @@ private static async Task RunHandler(string? authorizationHe var optionsMonitor = Substitute.For>(); optionsMonitor.Get(Arg.Any()).Returns(new AuthenticationSchemeOptions()); - var loggerFactory = new NullLoggerFactory(); - var encoder = UrlEncoder.Default; - - var handler = new ApiKeyAuthenticationHandler(optionsMonitor, loggerFactory, encoder, serviceProvider); + var handler = new ApiKeyAuthenticationHandler( + optionsMonitor, new NullLoggerFactory(), UrlEncoder.Default, serviceProvider); var scheme = new AuthenticationScheme("ApiKey", "ApiKey", typeof(ApiKeyAuthenticationHandler)); var httpContext = new DefaultHttpContext(); - httpContext.Request.Path = "/mcp"; - + httpContext.Request.Path = path; if (authorizationHeader is not null) httpContext.Request.Headers.Authorization = authorizationHeader; await handler.InitializeAsync(scheme, httpContext); - return await handler.AuthenticateAsync(); + var result = await handler.AuthenticateAsync(); + return new HandlerRun(result, apiKeyRepo, unitOfWork); } + private static Expression> CapturedPredicate(IGenericRepository repo) => + (Expression>)repo.ReceivedCalls() + .Single(call => call.GetMethodInfo().Name == "FindTrackedAsync") + .GetArguments()[0]!; + [Fact] public async Task HandleAuthenticateAsync_MissingHeader_ReturnsFail() { - var result = await RunHandler(null); + var run = await RunHandler(null); - result.Succeeded.Should().BeFalse(); - result.Failure!.Message.Should().Contain("Not an API key"); + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("Not an API key"); } [Fact] public async Task HandleAuthenticateAsync_EmptyHeader_ReturnsFail() { - var result = await RunHandler(""); + var run = await RunHandler(""); - result.Succeeded.Should().BeFalse(); + run.Result.Succeeded.Should().BeFalse(); } [Fact] public async Task HandleAuthenticateAsync_NonApiKeyBearer_ReturnsFail() { - var result = await RunHandler("Bearer eyJhbGciOiJIUzI1NiJ9.test"); + var run = await RunHandler("Bearer eyJhbGciOiJIUzI1NiJ9.test"); - result.Succeeded.Should().BeFalse(); - result.Failure!.Message.Should().Contain("Not an API key"); + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("Not an API key"); + } + + [Fact] + public async Task HandleAuthenticateAsync_ApiKeyOnNonAgentPath_ReturnsFail() + { + var run = await RunHandler($"Bearer orb_{new string('a', 20)}", path: "/api/habits"); + + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("agent endpoints"); } [Fact] public async Task HandleAuthenticateAsync_TooShortApiKey_ReturnsFail() { - var result = await RunHandler("Bearer orb_short"); + var run = await RunHandler("Bearer orb_short"); - result.Succeeded.Should().BeFalse(); - result.Failure!.Message.Should().Contain("Invalid API key format"); + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("Invalid API key format"); } [Fact] public async Task HandleAuthenticateAsync_ValidFormatButNoMatch_ReturnsFail() { - var apiKeyRepo = Substitute.For>(); - var payGate = Substitute.For(); - var unitOfWork = Substitute.For(); - payGate.CanReadApiKeys(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Result.Success())); + var run = await RunHandler("Bearer orb_testkey12345678", candidates: []); - apiKeyRepo.FindTrackedAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List()); + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("Invalid API key"); + } - var services = new ServiceCollection(); - services.AddSingleton(apiKeyRepo); - services.AddSingleton(payGate); - services.AddSingleton(unitOfWork); - var serviceProvider = services.BuildServiceProvider(); + [Fact] + public async Task HandleAuthenticateAsync_ValidKey_SucceedsWithIdentityClaims() + { + var userId = Guid.NewGuid(); + var (apiKey, rawKey) = ApiKey.Create( + userId, "Agent Key", ["habits:read", "goals:read"], isReadOnly: true).Value; + + var run = await RunHandler($"Bearer {rawKey}", candidates: [apiKey]); + + run.Result.Succeeded.Should().BeTrue(); + var principal = run.Result.Principal!; + principal.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be(userId.ToString()); + principal.FindFirst("auth_method")!.Value.Should().Be("api_key"); + principal.FindFirst("api_key_id")!.Value.Should().Be(apiKey.Id.ToString()); + principal.FindFirst("api_key_read_only")!.Value.Should().Be("True"); + principal.FindAll("scope").Select(claim => claim.Value) + .Should().BeEquivalentTo("habits:read", "goals:read"); + apiKey.LastUsedAtUtc.Should().NotBeNull(); + await run.UnitOfWork.Received(1).SaveChangesAsync(); + } - var optionsMonitor = Substitute.For>(); - optionsMonitor.Get(Arg.Any()).Returns(new AuthenticationSchemeOptions()); + [Fact] + public async Task HandleAuthenticateAsync_ExpiredKey_ReturnsFail() + { + var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value; + typeof(ApiKey).GetProperty(nameof(ApiKey.ExpiresAtUtc))! + .SetValue(apiKey, DateTime.UtcNow.AddDays(-1)); - var handler = new ApiKeyAuthenticationHandler( - optionsMonitor, new NullLoggerFactory(), UrlEncoder.Default, serviceProvider); + var run = await RunHandler($"Bearer {rawKey}", candidates: [apiKey]); - var scheme = new AuthenticationScheme("ApiKey", "ApiKey", typeof(ApiKeyAuthenticationHandler)); - var httpContext = new DefaultHttpContext(); - httpContext.Request.Path = "/mcp"; - httpContext.Request.Headers.Authorization = "Bearer orb_testkey12345678"; + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("expired"); + await run.UnitOfWork.DidNotReceive().SaveChangesAsync(); + } - await handler.InitializeAsync(scheme, httpContext); - var result = await handler.AuthenticateAsync(); + [Fact] + public async Task HandleAuthenticateAsync_PayGateDenied_ReturnsFail() + { + var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value; + + var run = await RunHandler( + $"Bearer {rawKey}", candidates: [apiKey], payGateResult: Result.Failure("no plan")); + + run.Result.Succeeded.Should().BeFalse(); + run.Result.Failure!.Message.Should().Contain("not available for this plan"); + } + + [Fact] + public async Task HandleAuthenticateAsync_QueryPredicateExcludesRevokedKeys() + { + var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value; + + var run = await RunHandler($"Bearer {rawKey}", candidates: []); + + var predicate = CapturedPredicate(run.ApiKeyRepo).Compile(); + predicate(apiKey).Should().BeTrue(); - result.Succeeded.Should().BeFalse(); - result.Failure!.Message.Should().Contain("Invalid API key"); + apiKey.Revoke(); + predicate(apiKey).Should().BeFalse(); } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/PlayNotificationCleanupServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PlayNotificationCleanupServiceTests.cs new file mode 100644 index 00000000..5ed890c1 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/PlayNotificationCleanupServiceTests.cs @@ -0,0 +1,123 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +/// +/// Verifies the retention boundaries of : Play RTDN +/// dedup records purge after 30 days and Stripe webhook dedup records after 90 days, while records +/// inside their provider redelivery window survive. Runs against in-memory SQLite so the service's +/// bulk ExecuteDeleteAsync executes as real SQL. +/// +public sealed class PlayNotificationCleanupServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly OrbitDbContext _dbContext; + private readonly IServiceScopeFactory _scopeFactory; + + public PlayNotificationCleanupServiceTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContext = new SqliteCompatOrbitDbContext(options); + _dbContext.Database.EnsureCreated(); + + var services = new ServiceCollection(); + services.AddSingleton(_ => _dbContext); + _scopeFactory = services.BuildServiceProvider().GetRequiredService(); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task RunAsync_PurgesRecordsOlderThanRetentionAndKeepsRecentOnes() + { + AddPlayNotification("play-recent", DaysAgo(29)); + AddPlayNotification("play-old", DaysAgo(31)); + AddStripeEvent("stripe-recent", DaysAgo(89)); + AddStripeEvent("stripe-old", DaysAgo(91)); + await _dbContext.SaveChangesAsync(); + + var service = new PlayNotificationCleanupService(_scopeFactory, NullLogger.Instance); + await service.RunAsync(CancellationToken.None); + + var remainingPlay = await _dbContext.ProcessedPlayNotifications.Select(n => n.MessageId).ToListAsync(); + var remainingStripe = await _dbContext.ProcessedStripeEvents.Select(e => e.EventId).ToListAsync(); + + remainingPlay.Should().ContainSingle().Which.Should().Be("play-recent"); + remainingStripe.Should().ContainSingle().Which.Should().Be("stripe-recent"); + } + + [Fact] + public async Task RunAsync_NothingExpired_DeletesNothing() + { + AddPlayNotification("play-fresh", DaysAgo(1)); + AddStripeEvent("stripe-fresh", DaysAgo(1)); + await _dbContext.SaveChangesAsync(); + + var service = new PlayNotificationCleanupService(_scopeFactory, NullLogger.Instance); + await service.RunAsync(CancellationToken.None); + + (await _dbContext.ProcessedPlayNotifications.CountAsync()).Should().Be(1); + (await _dbContext.ProcessedStripeEvents.CountAsync()).Should().Be(1); + } + + private static DateTime DaysAgo(int days) => DateTime.UtcNow.AddDays(-days); + + private void AddPlayNotification(string messageId, DateTime processedAtUtc) + { + var notification = ProcessedPlayNotification.Create(messageId); + SetProcessedAt(notification, processedAtUtc); + _dbContext.ProcessedPlayNotifications.Add(notification); + } + + private void AddStripeEvent(string eventId, DateTime processedAtUtc) + { + var stripeEvent = ProcessedStripeEvent.Create(eventId); + SetProcessedAt(stripeEvent, processedAtUtc); + _dbContext.ProcessedStripeEvents.Add(stripeEvent); + } + + private static void SetProcessedAt(ProcessedExternalEvent target, DateTime processedAtUtc) => + typeof(ProcessedExternalEvent) + .GetProperty(nameof(ProcessedExternalEvent.ProcessedAtUtc))! + .SetValue(target, processedAtUtc); + + private sealed class SqliteCompatOrbitDbContext(DbContextOptions options) + : OrbitDbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + var defaultSql = property.GetDefaultValueSql(); + if (defaultSql is not null && defaultSql.Contains("::", StringComparison.Ordinal)) + property.SetDefaultValueSql(null); + } + + foreach (var index in entityType.GetIndexes()) + index.SetFilter(null); + } + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/PushNotificationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PushNotificationServiceTests.cs index af923f60..741a4a88 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PushNotificationServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PushNotificationServiceTests.cs @@ -1,239 +1,235 @@ -using System.Text.Json; +using System.Net; +using System.Security.Cryptography; using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Orbit.Domain.Entities; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; namespace Orbit.Infrastructure.Tests.Services; /// -/// Tests the push subscription routing logic used by PushNotificationService. -/// FCM subscriptions are identified by P256dh == "fcm", everything else is web push. -/// The actual send logic requires Firebase/WebPush infrastructure, tested at integration level. +/// Exercises the real against an in-memory +/// SQLite and a stubbed HTTP transport. Web Push delivery is driven end +/// to end (valid VAPID + receiver EC keys are generated in-process so aes128gcm encryption reaches +/// the stubbed push service), which lets the dead-token-prune, transient-failure, and success paths +/// be asserted on real database state. FCM send is not reachable without Firebase credentials, so +/// only its "not initialized" guard is covered. /// -public class PushNotificationServiceTests +public sealed class PushNotificationServiceTests : IDisposable { - private static readonly Guid ValidUserId = Guid.NewGuid(); + private readonly SqliteConnection _connection; + private readonly OrbitDbContext _dbContext; + private readonly VapidSettings _vapidSettings; + private readonly string _receiverP256dh; + private readonly string _receiverAuth; + private readonly Guid _userId = Guid.NewGuid(); - [Fact] - public void PushSubscription_FcmSubscription_IdentifiedByP256dhFcm() + public PushNotificationServiceTests() { - var sub = PushSubscription.Create(ValidUserId, "fcm-token-123", "fcm", "auth-key").Value; + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); - sub.P256dh.Should().Be("fcm"); - sub.Endpoint.Should().Be("fcm-token-123"); - } + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; - [Fact] - public void PushSubscription_WebPushSubscription_HasRealP256dh() - { - var sub = PushSubscription.Create( - ValidUserId, - "https://fcm.googleapis.com/fcm/send/abc123", - "BNcRdreA...real-key", - "auth-secret").Value; + _dbContext = new SqliteCompatOrbitDbContext(options); + _dbContext.Database.EnsureCreated(); - sub.P256dh.Should().NotBe("fcm"); - } + var user = User.Create("Push Tester", "push@useorbit.org").Value; + typeof(User).GetProperty("Id")!.SetValue(user, _userId); + _dbContext.Users.Add(user); + _dbContext.SaveChanges(); - [Fact] - public void PushSubscription_FcmRoutingDecision_CorrectlySplits() - { - var subs = new List + var (publicKey, privateKey) = GenerateVapidKeyPair(); + _vapidSettings = new VapidSettings { - PushSubscription.Create(ValidUserId, "fcm-token-1", "fcm", "auth1").Value, - PushSubscription.Create(ValidUserId, "fcm-token-2", "fcm", "auth2").Value, - PushSubscription.Create(ValidUserId, "https://push.example.com/sub1", "p256dh-key-1", "auth3").Value, - PushSubscription.Create(ValidUserId, "https://push.example.com/sub2", "p256dh-key-2", "auth4").Value, + PublicKey = publicKey, + PrivateKey = privateKey, + Subject = "mailto:push-tests@useorbit.org" }; - var fcmSubs = subs.Where(s => s.P256dh == "fcm").ToList(); - var webPushSubs = subs.Where(s => s.P256dh != "fcm").ToList(); - - fcmSubs.Should().HaveCount(2); - webPushSubs.Should().HaveCount(2); - fcmSubs.Should().AllSatisfy(s => s.P256dh.Should().Be("fcm")); - webPushSubs.Should().AllSatisfy(s => s.P256dh.Should().NotBe("fcm")); + (_receiverP256dh, _receiverAuth) = GenerateReceiverKeys(); } - [Fact] - public void PushSubscription_EmptyList_NoRouting() + public void Dispose() { - var subs = new List(); + _dbContext.Dispose(); + _connection.Dispose(); + GC.SuppressFinalize(this); + } - var fcmSubs = subs.Where(s => s.P256dh == "fcm").ToList(); - var webPushSubs = subs.Where(s => s.P256dh != "fcm").ToList(); + private PushNotificationService CreateService(StubHttpMessageHandler handler) => + new(_dbContext, Options.Create(_vapidSettings), NullLogger.Instance, new HttpClient(handler)); - fcmSubs.Should().BeEmpty(); - webPushSubs.Should().BeEmpty(); + private async Task SeedWebPushSubscription(string endpoint) + { + var sub = PushSubscription.Create(_userId, endpoint, _receiverP256dh, _receiverAuth).Value; + _dbContext.PushSubscriptions.Add(sub); + await _dbContext.SaveChangesAsync(); + return sub; } [Fact] - public void PushSubscription_AllFcm_NoWebPush() + public async Task SendToUserAsync_NoSubscriptions_DoesNothing() { - var subs = new List - { - PushSubscription.Create(ValidUserId, "token-1", "fcm", "auth1").Value, - PushSubscription.Create(ValidUserId, "token-2", "fcm", "auth2").Value, - }; + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Created)); + var service = CreateService(handler); - var fcmSubs = subs.Where(s => s.P256dh == "fcm").ToList(); - var webPushSubs = subs.Where(s => s.P256dh != "fcm").ToList(); + await service.SendToUserAsync(Guid.NewGuid(), "Title", "Body"); - fcmSubs.Should().HaveCount(2); - webPushSubs.Should().BeEmpty(); + handler.CallCount.Should().Be(0); } [Fact] - public void PushSubscription_AllWebPush_NoFcm() + public async Task SendToUserAsync_FcmSubscriptionAndFirebaseNotInitialized_KeepsSubscription() { - var subs = new List - { - PushSubscription.Create(ValidUserId, "https://push.example.com/1", "real-key-1", "auth1").Value, - PushSubscription.Create(ValidUserId, "https://push.example.com/2", "real-key-2", "auth2").Value, - }; + var fcmSub = PushSubscription.Create(_userId, "fcm-device-token", PushSubscription.FcmSentinel, "auth").Value; + _dbContext.PushSubscriptions.Add(fcmSub); + await _dbContext.SaveChangesAsync(); - var fcmSubs = subs.Where(s => s.P256dh == "fcm").ToList(); - var webPushSubs = subs.Where(s => s.P256dh != "fcm").ToList(); + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Created)); + var service = CreateService(handler); - fcmSubs.Should().BeEmpty(); - webPushSubs.Should().HaveCount(2); - } + await service.SendToUserAsync(_userId, "Title", "Body"); - [Fact] - public void Create_EmptyUserId_ReturnsFailure() - { - var result = PushSubscription.Create(Guid.Empty, "endpoint", "key", "auth"); - result.IsFailure.Should().BeTrue(); + handler.CallCount.Should().Be(0); + (await _dbContext.PushSubscriptions.CountAsync()).Should().Be(1); } [Fact] - public void Create_EmptyEndpoint_ReturnsFailure() + public async Task SendToUserAsync_WebPushDelivered_KeepsSubscription() { - var result = PushSubscription.Create(ValidUserId, "", "key", "auth"); - result.IsFailure.Should().BeTrue(); - } + await SeedWebPushSubscription("https://push.example.com/sub/live"); - [Fact] - public void Create_EmptyP256dh_ReturnsFailure() - { - var result = PushSubscription.Create(ValidUserId, "endpoint", "", "auth"); - result.IsFailure.Should().BeTrue(); - } + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Created)); + var service = CreateService(handler); - [Fact] - public void Create_EmptyAuth_ReturnsFailure() - { - var result = PushSubscription.Create(ValidUserId, "endpoint", "key", ""); - result.IsFailure.Should().BeTrue(); - } + await service.SendToUserAsync(_userId, "Title", "Body", "/habits"); - [Fact] - public void Create_ValidInputs_ReturnsSuccess() - { - var result = PushSubscription.Create(ValidUserId, "https://push.example.com", "p256dh-key", "auth-secret"); - result.IsSuccess.Should().BeTrue(); - result.Value.UserId.Should().Be(ValidUserId); + handler.CallCount.Should().Be(1); + (await _dbContext.PushSubscriptions.CountAsync()).Should().Be(1); } - [Fact] - public void TokenPreview_ShortEndpoint_TruncatesCorrectly() + [Theory] + [InlineData(HttpStatusCode.Gone)] + [InlineData(HttpStatusCode.NotFound)] + public async Task SendToUserAsync_WebPushSubscriptionDead_PrunesSubscription(HttpStatusCode deadStatus) { - var endpoint = "short"; - var preview = endpoint[..Math.Min(20, endpoint.Length)] + "..."; + await SeedWebPushSubscription("https://push.example.com/sub/dead"); + + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(deadStatus)); + var service = CreateService(handler); + + await service.SendToUserAsync(_userId, "Title", "Body"); - preview.Should().Be("short..."); + (await _dbContext.PushSubscriptions.CountAsync()).Should().Be(0); } [Fact] - public void TokenPreview_LongEndpoint_TruncatesTo20Chars() + public async Task SendToUserAsync_WebPushTransientFailure_KeepsSubscriptionAndDoesNotThrow() { - var endpoint = "https://push.example.com/sub/abcdefghijklmnopqrst"; - var preview = endpoint[..Math.Min(20, endpoint.Length)] + "..."; + await SeedWebPushSubscription("https://push.example.com/sub/flaky"); - preview.Should().Be("https://push.example..."); - preview.Should().HaveLength(23); } + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + var service = CreateService(handler); - [Fact] - public void TokenPreview_ExactlyTwentyChars_NoExtraTruncation() - { - var endpoint = "12345678901234567890"; - var preview = endpoint[..Math.Min(20, endpoint.Length)] + "..."; + var act = () => service.SendToUserAsync(_userId, "Title", "Body"); - preview.Should().Be("12345678901234567890..."); + await act.Should().NotThrowAsync(); + (await _dbContext.PushSubscriptions.CountAsync()).Should().Be(1); } [Fact] - public void WebPushPayload_SerializesCorrectly_WithUrl() + public async Task SendToUserAsync_MixedLiveAndDeadWebPush_PrunesOnlyDeadSubscription() { - var title = "Test Title"; - var body = "Test Body"; - var url = "/habits"; + const string liveEndpoint = "https://push.example.com/sub/keep"; + const string deadEndpoint = "https://push.example.com/sub/drop"; + await SeedWebPushSubscription(liveEndpoint); + await SeedWebPushSubscription(deadEndpoint); + + var handler = new StubHttpMessageHandler(request => + request.RequestUri!.AbsoluteUri == deadEndpoint + ? new HttpResponseMessage(HttpStatusCode.Gone) + : new HttpResponseMessage(HttpStatusCode.Created)); + var service = CreateService(handler); - var payload = JsonSerializer.Serialize(new { title, body, url }); + await service.SendToUserAsync(_userId, "Title", "Body"); - payload.Should().Contain("\"title\":\"Test Title\""); - payload.Should().Contain("\"body\":\"Test Body\""); - payload.Should().Contain("\"url\":\"/habits\""); + var remaining = await _dbContext.PushSubscriptions.Select(s => s.Endpoint).ToListAsync(); + remaining.Should().ContainSingle().Which.Should().Be(liveEndpoint); } - [Fact] - public void WebPushPayload_SerializesCorrectly_WithNullUrl() + private static (string PublicKey, string PrivateKey) GenerateVapidKeyPair() { - var title = "Alert"; - var body = "Something happened"; - string? url = null; - - var payload = JsonSerializer.Serialize(new { title, body, url }); - - payload.Should().Contain("\"url\":null"); + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var parameters = key.ExportParameters(true); + return (Base64UrlEncode(UncompressedPoint(parameters)), Base64UrlEncode(LeftPad(parameters.D!, 32))); } - [Fact] - public void WebPushPayload_SpecialCharacters_EscapedInJson() + private static (string P256dh, string Auth) GenerateReceiverKeys() { - var title = "He said \"hello\""; - var body = "Line1\nLine2"; - var url = "/"; - - var payload = JsonSerializer.Serialize(new { title, body, url }); - var parsed = JsonDocument.Parse(payload); + using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + var p256dh = Base64UrlEncode(UncompressedPoint(key.ExportParameters(false))); + var auth = Base64UrlEncode(RandomNumberGenerator.GetBytes(16)); + return (p256dh, auth); + } - parsed.RootElement.GetProperty("title").GetString().Should().Be("He said \"hello\""); - parsed.RootElement.GetProperty("body").GetString().Should().Be("Line1\nLine2"); + private static byte[] UncompressedPoint(ECParameters parameters) + { + var buffer = new byte[65]; + buffer[0] = 0x04; + LeftPad(parameters.Q.X!, 32).CopyTo(buffer, 1); + LeftPad(parameters.Q.Y!, 32).CopyTo(buffer, 33); + return buffer; } - [Fact] - public void MultiUserRouting_GroupsByUserId() + private static byte[] LeftPad(byte[] bytes, int size) { - var user1 = Guid.NewGuid(); - var user2 = Guid.NewGuid(); + if (bytes.Length == size) return bytes; + var padded = new byte[size]; + bytes.CopyTo(padded, size - bytes.Length); + return padded; + } - var subs = new List - { - PushSubscription.Create(user1, "token-1", "fcm", "auth1").Value, - PushSubscription.Create(user1, "https://push.com/1", "key1", "auth2").Value, - PushSubscription.Create(user2, "token-2", "fcm", "auth3").Value, - }; + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - var user1Subs = subs.Where(s => s.UserId == user1).ToList(); - var user2Subs = subs.Where(s => s.UserId == user2).ToList(); + private sealed class StubHttpMessageHandler(Func responder) : HttpMessageHandler + { + public int CallCount { get; private set; } - user1Subs.Should().HaveCount(2); - user2Subs.Should().HaveCount(1); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(responder(request)); + } } - [Fact] - public void StaleSubscriptionTracking_AccumulatesAcrossTypes() + private sealed class SqliteCompatOrbitDbContext(DbContextOptions options) + : OrbitDbContext(options) { - var staleSubscriptions = new List(); - - var fcmSub = PushSubscription.Create(ValidUserId, "stale-token", "fcm", "auth1").Value; - var webSub = PushSubscription.Create(ValidUserId, "https://gone.com/sub", "key1", "auth2").Value; - - staleSubscriptions.Add(fcmSub); - staleSubscriptions.Add(webSub); - - staleSubscriptions.Should().HaveCount(2); - staleSubscriptions.Should().Contain(fcmSub); - staleSubscriptions.Should().Contain(webSub); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + var defaultSql = property.GetDefaultValueSql(); + if (defaultSql is not null && defaultSql.Contains("::", StringComparison.Ordinal)) + property.SetDefaultValueSql(null); + } + + foreach (var index in entityType.GetIndexes()) + index.SetFilter(null); + } + } } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs index bf46241b..9dd4c8d3 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs @@ -23,16 +23,23 @@ public UserDateServiceTests() } [Fact] - public async Task GetUserTodayAsync_UserWithTimezone_ReturnsCorrectDate() + public async Task GetUserTodayAsync_AppliesUserTimezoneToLocalizedDayBoundary() { - var user = User.Create("Test", "test@test.com").Value; - user.SetTimeZone("America/New_York"); - _userRepo.GetByIdAsync(UserId, Arg.Any()) - .Returns(user); + var eastUserId = Guid.NewGuid(); + var westUserId = Guid.NewGuid(); - var result = await _sut.GetUserTodayAsync(UserId); + var eastUser = User.Create("East", "east@test.com").Value; + eastUser.SetTimeZone("Pacific/Kiritimati"); + var westUser = User.Create("West", "west@test.com").Value; + westUser.SetTimeZone("Pacific/Honolulu"); + + _userRepo.GetByIdAsync(eastUserId, Arg.Any()).Returns(eastUser); + _userRepo.GetByIdAsync(westUserId, Arg.Any()).Returns(westUser); + + var eastToday = await _sut.GetUserTodayAsync(eastUserId); + var westToday = await _sut.GetUserTodayAsync(westUserId); - result.Should().NotBe(default); + (eastToday.DayNumber - westToday.DayNumber).Should().BeGreaterThanOrEqualTo(1); } [Fact]