Compare commits

...

5 Commits

Author SHA1 Message Date
agrynco b30dba93e8 Merge pull request 'fix(notification): save smtp sender without reentering password' (#27) from fix/notification-admin-save-password into main 2026-10-18 00:36:12 +03:00
agrynco 1c028313ba fix(notification): save smtp sender without reentering password
Preserve the saved SMTP password when connection settings are unchanged and keep it out of admin forms.

Ref: IT-1601
2026-10-18 00:35:17 +03:00
agrynco c5eee69018 Merge pull request 'fix(notification): enable web smtp test with saved password' (#25) from fix/notification-web-smtp-test into main 2026-10-18 00:20:41 +03:00
agrynco 7ce4087d08 Merge pull request 'fix(kubernetes): support localized template schema' (#24) from feature/it-1601-kubernetes-localizations into main 2026-10-18 00:20:35 +03:00
agrynco d5b7605da5 fix(kubernetes): support localized template schema
Update migration, runtime grants, sandbox seed, and live upgrade guidance for the normalized email template schema.

Ref: IT-1601
2026-10-17 23:26:27 +03:00
8 changed files with 236 additions and 32 deletions
@@ -0,0 +1,136 @@
using System.Net;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task BlankPasswordKeepsSavedValueWhenOnlySenderChanges()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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["UseSsl"] = "true";
fields["Password"] = "";
fields["FromName"] = "Updated sender";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("saved-test-secret", saved.Password);
Assert.Equal("Updated sender", saved.FromName);
}
[Theory]
[InlineData("Host", "other.example.invalid")]
[InlineData("Port", "2525")]
[InlineData("Username", "other-user")]
[InlineData("UseSsl", "false")]
public async Task ChangedConnectionWithBlankPasswordIsRejected(string field, string value)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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["UseSsl"] = "true";
fields["Password"] = "";
fields[field] = value;
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("Enter the password to save changed SMTP connection settings.", html);
Assert.DoesNotContain("saved-test-secret", html);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("saved-test-secret", saved.Password);
Assert.Equal("smtp.example.invalid", saved.Host);
}
[Fact]
public async Task ExplicitPasswordCanReplaceSavedValue()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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["UseSsl"] = "true";
fields["Password"] = "replacement-test-secret";
fields["Host"] = "other.example.invalid";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("replacement-test-secret", saved.Password);
Assert.Equal("other.example.invalid", saved.Host);
}
[Fact]
public async Task NewChannelStillRequiresPassword()
{
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["Host"] = "smtp.example.invalid";
fields["Username"] = "test-user";
fields["FromEmail"] = "sender@example.invalid";
fields["FromName"] = "Sender";
fields["Password"] = "";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("The Password field is required.", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task InvalidFormDoesNotRenderEnteredPassword()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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["Password"] = "replacement-test-secret";
fields["FromEmail"] = "invalid-address";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.DoesNotContain("replacement-test-secret", html);
Assert.Equal("saved-test-secret", (await ReadSettings(context, channel.Id)).Password);
}
private static async Task<EmailChannel> SeedChannel(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
Password = "saved-test-secret", UseSsl = true,
FromEmail = "sender@example.invalid", FromName = "Original sender"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
return channel;
}
private static async Task<SmtpChannelSettings> ReadSettings(DefinitionApiTestContext context, Guid id)
{
using var scope = context.App.Services.CreateScope();
var channel = await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(id);
return Assert.IsType<SmtpChannelSettings>(channel?.Settings);
}
}
@@ -65,7 +65,6 @@ public class AdminChannelsController(IMediator mediator) : Controller
Host = smtp.Host,
Port = smtp.Port,
Username = smtp.Username,
Password = smtp.Password,
UseSsl = smtp.UseSsl,
FromEmail = smtp.FromEmail,
FromName = smtp.FromName
@@ -80,14 +79,21 @@ public class AdminChannelsController(IMediator mediator) : Controller
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return View("Edit", model);
return EditWithErrors(model);
var (password, passwordError) = await ResolvePasswordForSave(model, ct);
if (passwordError is not null)
{
ModelState.AddModelError(nameof(model.Password), passwordError);
return EditWithErrors(model);
}
var smtpSettings = new SmtpChannelSettings
{
Host = model.Host,
Port = model.Port,
Username = model.Username,
Password = model.Password,
Password = password,
UseSsl = model.UseSsl,
FromEmail = model.FromEmail,
FromName = model.FromName
@@ -109,7 +115,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
return View("Edit", model);
return EditWithErrors(model);
}
}
else
@@ -127,13 +133,36 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
return View("Edit", model);
return EditWithErrors(model);
}
}
return RedirectToAction(nameof(Index));
}
private async Task<(string Password, string? Error)> ResolvePasswordForSave(
EmailChannelEditViewModel model, CancellationToken ct)
{
if (!string.IsNullOrEmpty(model.Password))
return (model.Password, null);
if (model.IsNew)
return ("", "The Password field is required.");
var channelResult = await mediator.Send(new GetEmailChannelQuery(model.Id), ct);
if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved)
return ("", "SMTP channel not found.");
if (!MatchesSavedSmtpConnection(model.Host, model.Port, model.Username, model.UseSsl, saved))
return ("", "Enter the password to save changed SMTP connection settings.");
return (saved.Password, null);
}
private IActionResult EditWithErrors(EmailChannelEditViewModel model)
{
model.Password = null;
return View("Edit", model);
}
// POST /admin/channels/test-smtp
[HttpPost("test-smtp")]
public async Task<IActionResult> TestSmtp([FromBody] TestSmtpRequest request, CancellationToken ct)
@@ -145,7 +174,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved)
return Ok(new { success = false, message = "SMTP channel not found." });
if (!MatchesSavedSmtpConnection(request, saved))
if (!MatchesSavedSmtpConnection(request.Host, request.Port, request.Username, request.UseSsl, saved))
return Ok(new { success = false, message = "Enter the password to test changed SMTP connection settings." });
password = saved.Password;
@@ -161,11 +190,12 @@ public class AdminChannelsController(IMediator mediator) : Controller
return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." });
}
private static bool MatchesSavedSmtpConnection(TestSmtpRequest request, SmtpChannelSettings saved) =>
string.Equals(request.Host, saved.Host, StringComparison.OrdinalIgnoreCase)
&& request.Port == saved.Port
&& string.Equals(request.Username, saved.Username, StringComparison.Ordinal)
&& request.UseSsl == saved.UseSsl;
private static bool MatchesSavedSmtpConnection(
string host, int port, string username, bool useSsl, SmtpChannelSettings saved) =>
string.Equals(host, saved.Host, StringComparison.OrdinalIgnoreCase)
&& port == saved.Port
&& string.Equals(username, saved.Username, StringComparison.Ordinal)
&& useSsl == saved.UseSsl;
// POST /admin/channels/{id}/test
[HttpPost("{id:guid}/test")]
@@ -34,8 +34,7 @@ public class EmailChannelEditViewModel
[Required]
public string Username { get; set; } = "";
[Required]
public string Password { get; set; } = "";
public string? Password { get; set; }
public bool UseSsl { get; set; } = true;
@@ -94,6 +94,10 @@
<label asp-for="Password" class="form-label fw-semibold">Password</label>
<input asp-for="Password" class="form-control" type="password" autocomplete="new-password" />
<span asp-validation-for="Password" class="text-danger small"></span>
@if (!Model.IsNew)
{
<div class="form-text">Leave blank to keep the saved password when SMTP connection settings are unchanged.</div>
}
</div>
</div>
@@ -178,7 +178,8 @@ existing Dockerfile. Record the checked commit and archive SHA256, including any
explicitly recorded uncommitted delta. Mirror PostgreSQL, RabbitMQ, Mailpit and a
Python 3 Linux image to `registry.hrynco.com`. Copy `artifacts.example.json` to
`artifacts.local.json` and record all seven immutable image digests, source hashes
and the two reviewed migration IDs. The script rejects placeholder/tag references,
and the three reviewed migration IDs, ending with `20261017072410_EmailTemplateLocalizations`.
The script rejects placeholder/tag references,
other registries, mismatched application/migrator archives and unsupported DB/broker
major versions. The archive hash fields are an operator provenance record; independently
verify the build inputs and registry digests before live application.
@@ -266,13 +267,15 @@ python3 notification.py render --config config.local.json --artifacts artifacts.
python3 notification.py check --config config.local.json --artifacts artifacts.local.json
```
5. First install applies storage/isolated foundation and runs migrations with Web and
Worker stopped. The migration target is exactly `InitialCreate` + `PendingChanges`.
5. First install or a schema upgrade applies storage/isolated foundation and runs
migrations with Web and Worker stopped. The migration target is exactly
`InitialCreate` + `PendingChanges` + `EmailTemplateLocalizations`.
After migration success, the script verifies history, grants a separate
`notification_runtime` role only DML on the three application tables, and idempotently
seeds the Mailpit channel and neutral English `TestEmail`. Existing matching-service
channels/templates are left unchanged. It resumes Web after these gates; Worker
remains stopped for inspection:
`notification_runtime` role only DML on the application tables, including the
service/language/definition catalog, and idempotently seeds the Mailpit channel,
sandbox service/catalog and neutral English `TestEmail`. Existing matching-service
channels/templates and shared variable contracts are left unchanged. It resumes
Web after these gates; Worker remains stopped for inspection:
```sh
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate
@@ -331,6 +334,18 @@ a snapshot of RabbitMQ's queue files. Migration does not upgrade/recreate the br
volume; durable queues remain on that retained volume. Broker disk backup/recovery
must be verified before a broker upgrade or storage migration.
Before upgrading a live flat database to `EmailTemplateLocalizations`, inspect the
exact source columns/indexes/history and audit every template's identities and JSON
variable shape. Versions of one service/key must have the same variable contract
after sorting by variable name. Rehearse the current Migrator and sandbox/grants SQL
on a disposable restored PostgreSQL 17 copy, then verify preserved content and a
repeat no-op migration. The live `deploy --migrate` creates its own final backup after
stopping Web and Worker. Do not use the old Web/Worker images with the new schema.
Rollback requires restoring a verified pre-upgrade database backup with the matching
old application images; migration `Down` is not a general rollback path. The
production network add-on and production broker access remain separate, preserved
resources; verify them and the existing production queues before re-enabling Worker.
## Access and networking
Use SSH to the deployment host, then forward one local port when needed:
@@ -1,7 +1,7 @@
{
"sourceCommit": "REPLACE_WITH_CHECKED_SOURCE_COMMIT",
"sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256",
"migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges"],
"migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges", "20261017072410_EmailTemplateLocalizations"],
"images": {
"web": {"image": "registry.hrynco.com/hrynco.notificationservice.web@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
"worker": {"image": "registry.hrynco.com/hrynco.notificationservice.worker@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
@@ -18,7 +18,8 @@ import time
NAMESPACE = "shared-notification"
MANAGED = "hrynco-shared-notification"
MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges"]
MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges",
"20261017072410_EmailTemplateLocalizations"]
IMAGE = re.compile(r"registry\.hrynco\.com/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$")
SHA = re.compile(r"[a-f0-9]{64}$")
PRODUCTION_RESULT_QUEUE = "invemory.production.notification-results"
@@ -53,7 +54,8 @@ REVOKE ALL ON DATABASE notification_service FROM PUBLIC;
GRANT CONNECT ON DATABASE notification_service TO notification_runtime;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO notification_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage TO notification_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage,
services, service_languages, email_template_definitions TO notification_runtime;
'''
SANDBOX = r'''
@@ -62,11 +64,24 @@ INSERT INTO email_channels (id, service_name, priority, provider_type, settings,
SELECT gen_random_uuid(), 'Invemory.Staging.Sandbox', 1, 1,
'{"EmailChannelType":1,"Host":"mailpit","Port":1025,"Username":"","Password":"","UseSsl":false,"FromEmail":"no-reply@local.hrynco.test","FromName":"Invemory Sandbox"}'::jsonb, 90, true, now()
WHERE NOT EXISTS (SELECT 1 FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox');
INSERT INTO email_templates (id, service_name, key, language_code, subject, html_body, text_body, variables, created)
VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', 'TestEmail', 'en', 'Test notification',
'<p>Hello {{RecipientName}},</p><p>{{Message}}</p>', 'Hello {{RecipientName}}, {{Message}}',
'[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now())
ON CONFLICT (service_name, key, language_code) DO NOTHING;
INSERT INTO services (id, name, revision, created)
VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', gen_random_uuid(), now())
ON CONFLICT (name) DO NOTHING;
INSERT INTO service_languages (id, service_id, code, created)
SELECT gen_random_uuid(), s.id, 'en', now() FROM services s WHERE s.name = 'Invemory.Staging.Sandbox'
ON CONFLICT (service_id, code) DO NOTHING;
INSERT INTO email_template_definitions (id, service_id, key, revision, variables, created)
SELECT gen_random_uuid(), s.id, 'TestEmail', gen_random_uuid(),
'[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now()
FROM services s WHERE s.name = 'Invemory.Staging.Sandbox'
ON CONFLICT (service_id, key) DO NOTHING;
INSERT INTO email_templates (id, definition_id, service_id, service_language_id, subject, html_body, text_body, created)
SELECT gen_random_uuid(), d.id, s.id, l.id, 'Test notification',
'<p>Hello {{RecipientName}},</p><p>{{Message}}</p>', 'Hello {{RecipientName}}, {{Message}}', now()
FROM services s JOIN service_languages l ON l.service_id = s.id AND l.code = 'en'
JOIN email_template_definitions d ON d.service_id = s.id AND d.key = 'TestEmail'
WHERE s.name = 'Invemory.Staging.Sandbox'
ON CONFLICT (definition_id, service_language_id) DO NOTHING;
COMMIT;
'''
@@ -75,9 +90,12 @@ SANDBOX_CHECK = """SELECT
settings->>'Host' = 'mailpit' AND settings->>'Port' = '1025' AND
settings->>'UseSsl' = 'false' AND settings->>'Username' = '' AND settings->>'Password' = '')
FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox') AND
EXISTS (SELECT 1 FROM email_templates WHERE service_name = 'Invemory.Staging.Sandbox'
AND key = 'TestEmail' AND language_code = 'en'
AND variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)"""
EXISTS (SELECT 1 FROM email_templates t
JOIN email_template_definitions d ON d.id = t.definition_id AND d.service_id = t.service_id
JOIN service_languages l ON l.id = t.service_language_id AND l.service_id = t.service_id
JOIN services s ON s.id = t.service_id
WHERE s.name = 'Invemory.Staging.Sandbox' AND d.key = 'TestEmail' AND l.code = 'en'
AND d.variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)"""
def read_json(path):
@@ -640,7 +658,7 @@ def deploy(cluster, config, artifacts, args):
except Exception:
cluster.scale("worker", 0)
raise
print("deployment complete; SMTP is sandbox-only, Invemory publisher cutover is separate")
print("deployment complete; verify configured SMTP channels and publisher routes separately")
def main(argv=None):
+2
View File
@@ -75,6 +75,8 @@ The service/catalog lookup, validation, revision check, explicit removals, trans
In the admin channel editor, **Send Test Email** tests the form's current values without saving them. An explicitly entered password takes precedence. When the password field is blank for an existing channel, the Web server reads its saved password only if host, port, username, and SSL/TLS mode still match the saved SMTP connection. Changed connection settings require an explicit password. The saved password is not placed in the page or test response. A new channel keeps the ad-hoc test behavior, including unauthenticated SMTP when username and password are empty.
Saving an existing channel follows the same password rule: a blank password keeps the saved value when host, port, username, and SSL/TLS mode are unchanged, so sender name/address and limit edits do not require re-entering it. A changed SMTP connection needs an explicitly entered password. Creating a channel in the admin editor still requires a password. Validation errors redisplay the editor without rendering the saved or submitted password.
- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row.
- Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit.
- Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.