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
@@ -67,7 +67,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
var url = $"/admin/services/{context.ServiceId}/delete";
var blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked);
Assert.Contains("Email channels are retained", blocked);
Assert.Contains("Channels are never deleted automatically", blocked);
var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
@@ -43,7 +43,7 @@ public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : ICla
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
@@ -14,7 +14,7 @@ public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) :
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService",
ServiceId = context.ServiceId,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -15,6 +15,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "";
fields["FromName"] = "Updated sender";
@@ -38,6 +39,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "";
fields[field] = value;
@@ -60,6 +62,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "replacement-test-secret";
fields["Host"] = "other.example.invalid";
@@ -78,7 +81,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/channels/create"));
fields["ServiceName"] = "NewTestService";
fields["ServiceId"] = context.ServiceId.ToString();
fields["Host"] = "smtp.example.invalid";
fields["Username"] = "test-user";
fields["FromEmail"] = "sender@example.invalid";
@@ -114,7 +117,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
@@ -89,7 +89,7 @@ public sealed class AdminSmtpTestControllerTests
recorder.Channel = new EmailChannel
{
Id = ChannelId,
ServiceName = "TestService",
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -97,7 +97,7 @@ public sealed class AdminSmtpTestControllerTests
Username = "test-user", Password = "saved-test-secret", UseSsl = true
}
};
return (new AdminChannelsController(mediator), recorder);
return (new AdminChannelsController(mediator, null!), recorder);
}
private static TestSmtpRequest Request() => new(
@@ -15,7 +15,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var request = Content("smtp.example.invalid", 2525, "test-only-user");
var request = Content(context.ServiceId, "smtp.example.invalid", 2525, "test-only-user");
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
@@ -23,7 +23,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content(context.ServiceId, "updated.example.invalid", 2526, "updated-user"))).StatusCode);
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
@@ -33,7 +33,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
@@ -41,22 +41,16 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
}
[Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
public async Task ForeignKeyRejectsChannelsWithoutAnExistingService()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
await channels.AddAsync(new EmailChannel
{
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
Assert.Equal(2, all.GetArrayLength());
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
await Assert.ThrowsAsync<Microsoft.EntityFrameworkCore.DbUpdateException>(() =>
scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceId = Guid.NewGuid(), EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
}));
}
[Theory]
@@ -64,18 +58,22 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
[InlineData("invalidPort")]
[InlineData("blankHost")]
[InlineData("unsupportedType")]
[InlineData("blankService")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("nameOnly")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
var body = JsonSerializer.SerializeToNode(Content(context.ServiceId, "smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
switch (scenario)
{
case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; break;
case "blankService": body["serviceName"] = " "; break;
case "missingService": body["serviceId"] = Guid.NewGuid(); break;
case "emptyService": body["serviceId"] = Guid.Empty; break;
case "nameOnly": body.Remove("serviceId"); body["serviceName"] = "TestService"; break;
}
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -83,9 +81,9 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
private static object Content(string host, int port, string username) => new
private static object Content(Guid serviceId, string host, int port, string username) => new
{
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
serviceId, priority = 1, channelType = 1,
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
};
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceLinkTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RenameUpdatesAllReadPathsAndResolvesChannelsByCurrentName()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed";
await services.SaveAsync(service, false);
}
var read = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{channel.Id}")).GetProperty("data");
Assert.Equal(context.ServiceId, read.GetProperty("serviceId").GetGuid());
Assert.Equal("Invemory-Renamed", read.GetProperty("serviceName").GetString());
var current = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=Invemory-Renamed");
Assert.Single(current.GetProperty("data").EnumerateArray());
var previous = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Empty(previous.GetProperty("data").EnumerateArray());
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync("/admin/channels"));
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync($"/admin/channels/{channel.Id}"));
}
[Fact]
public async Task EditorAllowsReassignmentWithoutReplacingPasswordOrUsage()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using var scope = context.App.Services.CreateScope();
var destination = new NotificationServiceInfo { Name = "Invemory-Other" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(destination, true);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var html = await client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains("<select", html);
Assert.Contains("name=\"ServiceId\"", html);
Assert.Contains("Invemory-Other", html);
Assert.DoesNotContain("name=\"ServiceName\"", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = destination.Id.ToString();
fields["Password"] = "";
fields["UseSsl"] = "true";
fields["IsActive"] = "true";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var readScope = context.App.Services.CreateScope();
var channels = readScope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(destination.Id, saved.ServiceId);
Assert.Equal("Invemory-Other", saved.ServiceName);
Assert.Equal("test-only-password", Assert.IsType<SmtpChannelSettings>(saved.Settings).Password);
Assert.Equal(25, saved.DailyLimit);
Assert.Equal(250, saved.MonthlyLimit);
Assert.Equal(2, saved.Priority);
var summary = Assert.Single(await channels.GetAllWithUsageSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.Equal(1, summary.DailySent);
Assert.Empty(await channels.GetByServiceAsync(context.ServiceId));
Assert.Single(await channels.GetByServiceAsync(destination.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UpdateRejectsMissingServiceWithoutChangingChannel(bool empty)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
var response = await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{channel.Id}", new
{
serviceId = empty ? Guid.Empty : Guid.NewGuid(), priority = 9,
settings = (SmtpChannelSettings)channel.Settings, dailyLimit = 1, monthlyLimit = 2, warnThresholdPercent = 80, isActive = false
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Select an existing service.", result.GetProperty("error").GetProperty("message").GetString());
using var scope = context.App.Services.CreateScope();
var saved = (await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(channel.Id))!;
Assert.Equal(context.ServiceId, saved.ServiceId);
Assert.Equal(2, saved.Priority);
Assert.Equal(25, saved.DailyLimit);
}
private static async Task<EmailChannel> SeedAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId, Priority = 2, EmailChannelType = EmailChannelType.Smtp,
DailyLimit = 25, MonthlyLimit = 250, IsActive = true,
Settings = new SmtpChannelSettings
{
Host = "smtp.invalid", Port = 587, Username = "test-user", Password = "test-only-password",
UseSsl = true, FromEmail = "sender@example.invalid", FromName = "Invemory Test"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<IEmailChannelUsageRepository>()
.IncrementUsageAsync(channel.Id, DateOnly.FromDateTime(DateTime.UtcNow));
return channel;
}
}
@@ -0,0 +1,91 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
private const string PreviousMigration = "20261023223753_AddPushDeliveries";
[Theory]
[InlineData("StoreMate-Prod", "Invemory-Prod")]
[InlineData("Invemory.Staging.Sandbox", "Invemory.Staging.Sandbox")]
public async Task BackfillPreservesChannelSettingsLimitsAndUsage(string legacyName, string currentName)
{
var connection = await PrepareAsync(legacyName, currentName);
await using (var db = TemplateDatabaseFixture.CreateContext(connection))
await db.Database.MigrateAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var channel = Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
var service = await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync(currentName);
Assert.Equal(service!.Id, channel.ServiceId);
Assert.Equal(currentName, channel.ServiceName);
Assert.Equal(7, channel.Priority);
Assert.Equal(25, channel.DailyLimit);
Assert.Equal(250, channel.MonthlyLimit);
Assert.Equal(80, channel.WarnThresholdPercent);
Assert.False(channel.IsActive);
Assert.Equal("migration-test-value", Assert.IsType<SmtpChannelSettings>(channel.Settings).Password);
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM email_channel_usage u JOIN email_channels c ON c.id = u.provider_id"));
await using var rollback = TemplateDatabaseFixture.CreateContext(connection);
await rollback.GetService<IMigrator>().MigrateAsync(PreviousMigration);
Assert.Equal(currentName, await ScalarAsync(connection, "SELECT service_name FROM email_channels"));
}
[Theory]
[InlineData("UnmappedService", false)]
[InlineData("StoreMate-Prod", true)]
public async Task MissingOrAmbiguousMappingAbortsWithoutChangingChannelData(string legacyName, bool ambiguous)
{
var connection = await PrepareAsync(legacyName, "Invemory-Prod");
if (ambiguous)
await TemplateDatabaseFixture.ExecuteAsync(connection,
"INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), 'StoreMate-Prod', gen_random_uuid(), now())");
var before = await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.Database.MigrateAsync());
Assert.Contains("exactly one existing service", error.MessageText);
Assert.Equal(before, await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c"));
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(0L, await ScalarAsync(connection,
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'email_channels' AND column_name = 'service_id'"));
}
private async Task<string> PrepareAsync(string legacyName, string currentName)
{
var connectionString = await fixture.CreateDatabaseAsync(legacy: true);
await using (var db = TemplateDatabaseFixture.CreateContext(connectionString))
await db.GetService<IMigrator>().MigrateAsync(PreviousMigration);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("""
INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), @current, gen_random_uuid(), now());
INSERT INTO email_channels(id, service_name, priority, provider_type, settings, daily_limit, monthly_limit,
warn_threshold_percent, is_active, created)
VALUES (@channel, @legacy, 7, 1, '{"Host":"smtp.invalid","Password":"migration-test-value"}', 25, 250, 80, false, now());
INSERT INTO email_channel_usage(id, provider_id, date, sent_count, created)
VALUES (gen_random_uuid(), @channel, CURRENT_DATE, 12, now());
""", connection);
command.Parameters.AddWithValue("current", currentName);
command.Parameters.AddWithValue("legacy", legacyName);
command.Parameters.AddWithValue("channel", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
return connectionString;
}
private static async Task<object?> ScalarAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
return await command.ExecuteScalarAsync();
}
}
@@ -22,7 +22,7 @@ public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture f
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"));
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList();
Assert.Equal(4, history.Count);
Assert.Equal(db.Database.GetMigrations().Append(InterimEmailTemplateFixture.MigrationId).Order(), history.Order());
Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushChannelAdminTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task TestsRequireAntiforgeryAndRejectMissingCredentialsWithoutSaving()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient();
var html = await client.GetStringAsync("/admin/push-channels/create");
Assert.Contains("Check Firebase authorization", html);
Assert.Contains("Recipient token", html);
Assert.Contains("test-authorization", html);
var fields = TemplateEditorTestFields.Read(html);
foreach (var action in new[] { "test-authorization", "test-send" })
{
var body = new { firebaseProjectId = "test-project", recipientToken = "test-token" };
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/admin/push-channels/" + action, body)).StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/" + action);
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
request.Content = JsonContent.Create(body);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
Assert.False(result.GetProperty("success").GetBoolean());
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
}
using var scope = context.App.Services.CreateScope();
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
}
[Fact]
public async Task CreateAndEditKeepCredentialsOnServerAndAllowServiceReassignment()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
var response = await SaveNewAsync(client, context.ServiceId, credentials);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var other = new NotificationServiceInfo { Name = "Invemory-Push-Test" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var html = await client.GetStringAsync($"/admin/push-channels/{channel.Id}");
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("test@example.invalid", html);
Assert.Contains("Credentials are saved", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = other.Id.ToString();
fields["CredentialsJson"] = "";
fields["IsActive"] = "true";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields))).StatusCode);
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(other.Id, saved.ServiceId);
Assert.Equal("Invemory-Push-Test", saved.ServiceName);
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
Assert.NotNull(await channels.GetActiveForServiceAsync(other.Id));
}
[Theory]
[InlineData("duplicate")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("invalidJson")]
[InlineData("missingCredentials")]
public async Task InvalidCreateDoesNotExposeSubmittedSecretOrPersistAChannel(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
if (scenario == "duplicate")
Assert.Equal(HttpStatusCode.Redirect, (await SaveNewAsync(client, context.ServiceId, credentials)).StatusCode);
var serviceId = scenario switch { "missingService" => Guid.NewGuid(), "emptyService" => Guid.Empty, _ => context.ServiceId };
var secret = scenario switch { "invalidJson" => "invalid-secret-must-not-render", "missingCredentials" => "", _ => credentials };
var response = await SaveNewAsync(client, serviceId, secret);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("invalid-secret-must-not-render", html);
Assert.Contains("TestService", html);
using var scope = context.App.Services.CreateScope();
var channels = await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync();
Assert.Equal(scenario == "duplicate" ? 1 : 0, channels.Count);
}
[Fact]
public async Task BlankCredentialsCannotChangeProjectAndValidationErrorsDoNotEchoSecrets()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/push-channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["FirebaseProjectId"] = "other-project";
fields["CredentialsJson"] = "";
var response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.Contains("Changing project requires new credentials", await response.Content.ReadAsStringAsync());
Assert.Equal("test-project", (await channels.GetByIdAsync(channel.Id))!.FirebaseProjectId);
fields["FirebaseProjectId"] = "";
fields["CredentialsJson"] = credentials;
response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.DoesNotContain("PRIVATE KEY", await response.Content.ReadAsStringAsync());
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
}
[Fact]
public async Task RenameUpdatesDisplayAndInactiveChannelBlocksServiceDeletion()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
channel.IsActive = false;
await channels.SaveAsync(channel, credentials, false);
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed-Push";
await services.SaveAsync(service, false);
Assert.Contains(service.Name, await client.GetStringAsync("/admin/push-channels"));
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
}
private static async Task<HttpResponseMessage> SaveNewAsync(HttpClient client, Guid serviceId, string credentials)
{
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/push-channels/create"));
fields["ServiceId"] = serviceId.ToString();
fields["FirebaseProjectId"] = "test-project";
fields["CredentialsJson"] = credentials;
fields["IsActive"] = "true";
return await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
}
}
@@ -0,0 +1,17 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace HrynCo.NotificationService.Web.IntegrationTests;
internal static class PushChannelTestCredentials
{
public static string Create(string projectId = "test-project")
{
using var rsa = RSA.Create(2048);
return JsonSerializer.Serialize(new
{
type = "service_account", project_id = projectId,
client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem()
});
}
}
@@ -0,0 +1,34 @@
namespace HrynCo.NotificationService.Web.IntegrationTests;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
public sealed class PushDeliveryRepositoryTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RedeliveryReplaysOutcomeAndFailedProviderCallCanRetry()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
var id = Guid.NewGuid();
using (var first = provider.CreateScope())
{
var repository = first.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.DeliverOnceAsync(id, "Invemory",
_ => throw new InvalidOperationException("transient"), default));
}
using (var retry = provider.CreateScope())
{
var repository = retry.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
var result = await repository.DeliverOnceAsync(id, "Invemory",
_ => Task.FromResult(new PushDeliveryOutcome("unregistered", true)), default);
Assert.True(result.InvalidRegistration);
}
using var redelivery = provider.CreateScope();
var saved = await redelivery.ServiceProvider.GetRequiredService<IPushDeliveryRepository>()
.DeliverOnceAsync(id, "Invemory", _ => throw new Exception("must not send twice"), default);
Assert.Equal("unregistered", saved.Error);
Assert.True(saved.InvalidRegistration);
}
}
@@ -34,7 +34,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
}
[Fact]
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
public async Task ExplicitCleanupRequiresReassigningChannelsBeforeDeletingService()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await SeedChannelAsync(context);
@@ -53,10 +53,21 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
using (var reassignment = context.App.Services.CreateScope())
{
var other = new NotificationServiceInfo { Name = "OtherService" };
await reassignment.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var channels = reassignment.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
channel.ServiceId = other.Id;
await channels.UpdateAsync(channel);
await reassignment.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
}
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
Assert.Empty(all.GetProperty("data").EnumerateArray());
Assert.Single(all.GetProperty("data").EnumerateArray());
using var scope = context.App.Services.CreateScope();
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
@@ -145,7 +156,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();