Files
agrynco 1631786ce2 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
2026-10-24 12:31:35 +03:00

154 lines
9.2 KiB
C#

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));
}
}