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:
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user