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 { [Theory] [InlineData(1)] [InlineData(2)] public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version) { var services = Substitute.For(); var channels = Substitute.For(); var client = Substitute.For(); var templates = Substitute.For(); templates.GetAsync(Arg.Any(), "Test", Arg.Any()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] }); 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 data = version == 1 ? Data(service.Name) with { Version = 1, Title = "Legacy title", Body = "Legacy body", TemplateKey = null, TemplateData = null } : Data(service.Name); var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, 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()); await templates.Received(version == 1 ? 0 : 1).GetAsync(Arg.Any(), Arg.Any(), Arg.Any()); if (version == 1) await client.Received().SendAsync(Arg.Any(), Arg.Is(m => m.Title == "Legacy title" && m.Body == "Legacy body" && m.Buttons.Count == 0), Arg.Any(), 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 templates = Substitute.For(); templates.GetAsync(Arg.Any(), "Test", Arg.Any()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] }); 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, templates).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, Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary(), Actions = new Dictionary() }, Token = "test-device-token", TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10) }; }