feat(push): add service-owned firebase channels and delivery

Add push contracts, provider delivery and channel diagnostics. Link email channels to services and document migration and validation.

Ref: IT-1074
This commit is contained in:
2026-10-24 12:31:35 +03:00
parent b30dba93e8
commit 1631786ce2
105 changed files with 3838 additions and 147 deletions
@@ -0,0 +1,78 @@
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<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
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<CancellationToken>()).Returns(service);
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(json);
client.SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.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<Guid>(), Arg.Any<SendPushMessageData>(),
Arg.Is<FirebaseServiceAccountCredentials>(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any<CancellationToken>());
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task MissingServiceInactiveChannelOrInvalidCredentialsDoNotContactFirebase(bool exists, bool active)
{
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(channel);
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).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)
};
}
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Worker.Services.Push;
using NSubstitute;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushChannelTestServiceTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UsesSavedCredentialsWithoutSavingTokenAndSeparatesAuthorizationFromSend(bool send)
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var id = Guid.NewGuid();
channels.GetByIdAsync(id, Arg.Any<CancellationToken>()).Returns(new PushChannel
{ Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project", IsActive = false });
channels.GetCredentialsAsync(id, Arg.Any<CancellationToken>()).Returns(Credentials());
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
Assert.Null(outcome.Error);
await firebase.Received(send ? 1 : 0).SendTestAsync("test-token", Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
}
[Fact]
public async Task UnsavedCredentialsOverrideSavedConfiguration()
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ ChannelId = Guid.NewGuid(), FirebaseProjectId = "test-project", CredentialsJson = Credentials() }, false, default);
Assert.Null(outcome.Error);
await channels.DidNotReceiveWithAnyArgs().GetCredentialsAsync(default, default);
await firebase.Received(1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
}
[Theory]
[InlineData("changed-project")]
[InlineData("invalid-json")]
[InlineData("missing-token")]
[InlineData("unknown-channel")]
public async Task InvalidInputNeverContactsFirebase(string scenario)
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var id = Guid.NewGuid();
if (scenario != "unknown-channel") channels.GetByIdAsync(id, Arg.Any<CancellationToken>()).Returns(new PushChannel
{ Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project" });
channels.GetCredentialsAsync(id, Arg.Any<CancellationToken>()).Returns(Credentials());
var settings = new PushChannelTestSettings
{
ChannelId = id, FirebaseProjectId = scenario == "changed-project" ? "other-project" : "test-project",
CredentialsJson = scenario == "invalid-json" ? "private-secret-do-not-echo" : null
};
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(settings, scenario == "missing-token", default);
Assert.NotNull(outcome.Error);
Assert.DoesNotContain("private-secret-do-not-echo", outcome.Error);
await firebase.DidNotReceiveWithAnyArgs().CheckAuthorizationAsync(default!, default);
await firebase.DidNotReceiveWithAnyArgs().SendTestAsync(default!, default!, default);
}
[Fact]
public async Task RetryableProviderFailureReturnsSafeUnconfirmedResult()
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
Assert.Contains("acceptance was not confirmed", outcome.Error);
}
private static IFirebasePushClient Client()
{
var firebase = Substitute.For<IFirebasePushClient>();
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
return firebase;
}
private static string Credentials()
{
using var rsa = RSA.Create(2048);
return JsonSerializer.Serialize(new
{ type = "service_account", project_id = "test-project", client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem() });
}
}
@@ -0,0 +1,60 @@
namespace HrynCo.NotificationService.Services.Tests.Push;
using FirebaseAdmin.Messaging;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
using NSubstitute;
public sealed class PushDeliveryTests
{
[Theory]
[InlineData(MessagingErrorCode.Unregistered, true)]
[InlineData(MessagingErrorCode.SenderIdMismatch, false)]
[InlineData(MessagingErrorCode.InvalidArgument, false)]
public void OnlyUnregisteredInvalidatesTheInstallation(MessagingErrorCode code, bool invalid)
{
var result = FirebasePushClient.Classify(code);
Assert.Equal(invalid, result.InvalidRegistration);
Assert.NotNull(result.Error);
}
[Theory]
[InlineData(MessagingErrorCode.Internal)]
[InlineData(MessagingErrorCode.Unavailable)]
[InlineData(MessagingErrorCode.QuotaExceeded)]
public void TransientFailuresPreserveRetryAfter(MessagingErrorCode code)
{
var exception = Assert.Throws<PushRetryException>(() => FirebasePushClient.Classify(code, TimeSpan.FromMinutes(3)));
Assert.Equal(TimeSpan.FromMinutes(3), exception.RetryAfter);
Assert.True(PushDeliveryService.RetryDelay(0, exception.RetryAfter) >= TimeSpan.FromMinutes(3));
Assert.True(PushDeliveryService.RetryDelay(1, TimeSpan.Zero) >= TimeSpan.FromMinutes(2));
}
[Fact]
public async Task ExhaustedReplyRetriesReplaySavedSuccessWithoutSendingAgain()
{
var repository = Substitute.For<IPushDeliveryRepository>();
var sender = Substitute.For<IPushSender>();
var publisher = Substitute.For<IRabbitMqPublisher>();
repository.DeliverOnceAsync(Arg.Any<Guid>(), Arg.Any<string>(),
Arg.Any<Func<CancellationToken, Task<PushDeliveryOutcome>>>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
var service = new PushDeliveryService(repository, sender, publisher);
var id = Guid.NewGuid();
var message = new SendPushMessage
{
CorrelationContext = new CorrelationContext { CorrelationId = id.ToString(), ReplyTo = "results" },
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64)
}
};
await service.CompleteFailureAsync(id, message, default);
await publisher.Received(1).PublishAsync("results",
Arg.Is<NotificationResultMessage>(x => x.Data.IsSuccess && x.Data.PushBindingId == message.Data.BindingId), default);
await sender.DidNotReceive().SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,42 @@
namespace HrynCo.NotificationService.Services.Tests.Push;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
public sealed class SendPushDeliveryValidatorTests
{
[Theory]
[InlineData("Notification.SendPush.v1", true)]
[InlineData("Notification.SendEmail.v1", false)]
[InlineData("Notification.SendPush.v2", false)]
[InlineData("notification.sendpush.v1", false)]
[InlineData("Notification.SendPush.v1 ", false)]
[InlineData("SendPushV1", false)]
[InlineData("1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void AcceptsOnlyTheExistingPushWireContract(string? wireType, bool expected)
{
var id = Guid.NewGuid().ToString("D");
var now = DateTimeOffset.UtcNow;
var message = new SendPushMessage
{
CorrelationContext = new CorrelationContext { CorrelationId = id },
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
}
};
var context = new RabbitMqMessageContext
{
QueueName = "notification.send-push", RoutingKey = "notification.send-push", Exchange = "",
MessageId = id, MessageType = wireType
};
Assert.Equal(expected, SendPushDeliveryValidator.IsValid(message, context));
}
}