using System.Security.Cryptography; using System.Text.Json; using HrynCo.NotificationService.Contracts.Messages; using HrynCo.NotificationService.DAL.Abstract.Push; using HrynCo.NotificationService.DAL.Abstract.PushChannels; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Worker.Services.Push; using NSubstitute; namespace HrynCo.NotificationService.Services.Tests.Push; public sealed class FcmPushSenderTests { [Fact] public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials() { var services = Substitute.For(); var channels = Substitute.For(); var client = Substitute.For(); var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" }; var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true }; services.GetByNameAsync(service.Name, Arg.Any()).Returns(service); channels.GetActiveForServiceAsync(service.Id, Arg.Any()).Returns(channel); using var rsa = RSA.Create(2048); var json = JsonSerializer.Serialize(new { type = "service_account", project_id = "test-project", client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token" }); channels.GetCredentialsAsync(channel.Id, Arg.Any()).Returns(json); client.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PushDeliveryOutcome(null)); var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None); Assert.Null(result.Error); await client.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Is(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any()); } [Theory] [InlineData(false, false)] [InlineData(true, false)] [InlineData(true, true)] public async Task MissingServiceInactiveChannelOrInvalidCredentialsDoNotContactFirebase(bool exists, bool active) { var services = Substitute.For(); var channels = Substitute.For(); var client = Substitute.For(); var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" }; if (exists) services.GetByNameAsync(service.Name, Arg.Any()).Returns(service); if (active) { var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true }; channels.GetActiveForServiceAsync(service.Id, Arg.Any()).Returns(channel); channels.GetCredentialsAsync(channel.Id, Arg.Any()).Returns("secret-must-not-leak"); } var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None); Assert.NotNull(result.Error); Assert.DoesNotContain("secret-must-not-leak", result.Error); await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default); } [Fact] public void PublicPayloadHasNoFirebaseAuthorizationFields() { var json = JsonSerializer.Serialize(Data("Invemory")); Assert.DoesNotContain("Fcm", json); Assert.DoesNotContain("Credentials", json); Assert.DoesNotContain("AccessToken", json); } private static SendPushMessageData Data(string serviceName) => new() { RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token", TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10) }; }