refactor(site-runtime): excise vestigial site-side notification-list surface — repo, DI registration, dead write paths (arch-review 08 §1.3/#23)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -12,7 +12,7 @@ The component code lives in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/`:
|
|||||||
- `Scripts/` — `ScriptCompilationService`, `ScriptExecutionScheduler`, `SharedScriptLibrary`, `ScriptRuntimeContext`, `ScopeAccessors`, `TriggerExpressionGlobals`.
|
- `Scripts/` — `ScriptCompilationService`, `ScriptExecutionScheduler`, `SharedScriptLibrary`, `ScriptRuntimeContext`, `ScopeAccessors`, `TriggerExpressionGlobals`.
|
||||||
- `Streaming/` — `SiteStreamManager` (the site-wide Akka broadcast stream).
|
- `Streaming/` — `SiteStreamManager` (the site-wide Akka broadcast stream).
|
||||||
- `Persistence/` — `SiteStorageService` (raw SQLite via `Microsoft.Data.Sqlite`), `SiteStorageInitializer`.
|
- `Persistence/` — `SiteStorageService` (raw SQLite via `Microsoft.Data.Sqlite`), `SiteStorageInitializer`.
|
||||||
- `Repositories/` — `SiteExternalSystemRepository`, `SiteNotificationRepository`.
|
- `Repositories/` — `SiteExternalSystemRepository`. (The `SiteNotificationRepository` variant was removed 2026-07-10, arch-review 08 §1.3/#23, because notification config is central-only and never lives on a site.)
|
||||||
- `Tracking/` — `OperationTrackingStore`, `OperationTrackingOptions`.
|
- `Tracking/` — `OperationTrackingStore`, `OperationTrackingOptions`.
|
||||||
|
|
||||||
`ServiceCollectionExtensions.AddSiteRuntime(connectionString)` registers all singletons; the `Host` calls it and wires the `DeploymentManagerActor` cluster singleton separately via `AkkaHostedService`.
|
`ServiceCollectionExtensions.AddSiteRuntime(connectionString)` registers all singletons; the `Host` calls it and wires the `DeploymentManagerActor` cluster singleton separately via `AkkaHostedService`.
|
||||||
|
|||||||
@@ -799,6 +799,12 @@ public class SiteStorageService
|
|||||||
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
|
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
|
||||||
/// is cleared, not just future writes. The tables themselves are retained (the
|
/// is cleared, not just future writes. The tables themselves are retained (the
|
||||||
/// schema is harmless once empty); only their contents are removed.
|
/// schema is harmless once empty); only their contents are removed.
|
||||||
|
///
|
||||||
|
/// The site-side write paths (<c>StoreNotificationListAsync</c>/<c>StoreSmtpConfigurationAsync</c>)
|
||||||
|
/// and the <c>SiteNotificationRepository</c> were removed 2026-07-10 (arch-review 08 §1.3/#23)
|
||||||
|
/// because notification config is central-only and must never be written to a site — this
|
||||||
|
/// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
|
||||||
|
/// do not reintroduce site-side notification writes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that completes when both tables have been emptied.</returns>
|
/// <returns>A task that completes when both tables have been emptied.</returns>
|
||||||
public async Task PurgeCentralOnlyNotificationConfigAsync()
|
public async Task PurgeCentralOnlyNotificationConfigAsync()
|
||||||
@@ -813,80 +819,6 @@ public class SiteStorageService
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores or updates a notification list.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The name of the notification list.</param>
|
|
||||||
/// <param name="recipientEmails">List of recipient email addresses.</param>
|
|
||||||
/// <returns>A task that completes when the notification list has been stored or updated.</returns>
|
|
||||||
public async Task StoreNotificationListAsync(string name, IReadOnlyList<string> recipientEmails)
|
|
||||||
{
|
|
||||||
await using var connection = new SqliteConnection(_connectionString);
|
|
||||||
await connection.OpenAsync();
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = @"
|
|
||||||
INSERT INTO notification_lists (name, recipient_emails, updated_at)
|
|
||||||
VALUES (@name, @emails, @updatedAt)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
|
||||||
recipient_emails = excluded.recipient_emails,
|
|
||||||
updated_at = excluded.updated_at";
|
|
||||||
|
|
||||||
command.Parameters.AddWithValue("@name", name);
|
|
||||||
command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
|
|
||||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SMTP Configuration CRUD ──
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores or updates an SMTP configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The name of the SMTP configuration.</param>
|
|
||||||
/// <param name="server">SMTP server hostname.</param>
|
|
||||||
/// <param name="port">SMTP server port.</param>
|
|
||||||
/// <param name="authMode">Authentication mode ('None', 'BasicAuth', 'OAuth2').</param>
|
|
||||||
/// <param name="fromAddress">Email address used as the sender.</param>
|
|
||||||
/// <param name="username">Username for authentication, if applicable.</param>
|
|
||||||
/// <param name="password">Password for authentication, if applicable.</param>
|
|
||||||
/// <param name="oauthConfig">OAuth2 configuration JSON, if applicable.</param>
|
|
||||||
/// <returns>A task that completes when the SMTP configuration has been stored or updated.</returns>
|
|
||||||
public async Task StoreSmtpConfigurationAsync(
|
|
||||||
string name, string server, int port, string authMode, string fromAddress,
|
|
||||||
string? username, string? password, string? oauthConfig)
|
|
||||||
{
|
|
||||||
await using var connection = new SqliteConnection(_connectionString);
|
|
||||||
await connection.OpenAsync();
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = @"
|
|
||||||
INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
|
|
||||||
VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
|
||||||
server = excluded.server,
|
|
||||||
port = excluded.port,
|
|
||||||
auth_mode = excluded.auth_mode,
|
|
||||||
from_address = excluded.from_address,
|
|
||||||
username = excluded.username,
|
|
||||||
password = excluded.password,
|
|
||||||
oauth_config = excluded.oauth_config,
|
|
||||||
updated_at = excluded.updated_at";
|
|
||||||
|
|
||||||
command.Parameters.AddWithValue("@name", name);
|
|
||||||
command.Parameters.AddWithValue("@server", server);
|
|
||||||
command.Parameters.AddWithValue("@port", port);
|
|
||||||
command.Parameters.AddWithValue("@authMode", authMode);
|
|
||||||
command.Parameters.AddWithValue("@fromAddress", fromAddress);
|
|
||||||
command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
|
|
||||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data Connection Definition CRUD ──
|
// ── Data Connection Definition CRUD ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Site-side read-only implementation of <see cref="INotificationRepository"/>
|
|
||||||
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
|
|
||||||
/// Write operations throw <see cref="NotSupportedException"/> because site-local
|
|
||||||
/// artifacts are managed exclusively through deployment from Central.
|
|
||||||
/// </summary>
|
|
||||||
public class SiteNotificationRepository : INotificationRepository
|
|
||||||
{
|
|
||||||
private readonly SiteStorageService _storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="SiteNotificationRepository"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">The site storage service for database access.</param>
|
|
||||||
public SiteNotificationRepository(SiteStorageService storage)
|
|
||||||
{
|
|
||||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NotificationList (read) ──
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<NotificationList?> GetListByNameAsync(
|
|
||||||
string name, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var connection = CreateConnection();
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = @"
|
|
||||||
SELECT name, recipient_emails
|
|
||||||
FROM notification_lists
|
|
||||||
WHERE name = @name";
|
|
||||||
command.Parameters.AddWithValue("@name", name);
|
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
||||||
if (!await reader.ReadAsync(cancellationToken))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return MapNotificationList(reader);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var connection = CreateConnection();
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
|
|
||||||
|
|
||||||
var results = new List<NotificationList>();
|
|
||||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
results.Add(MapNotificationList(reader));
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<NotificationList?> GetNotificationListByIdAsync(
|
|
||||||
int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// The SQLite table is keyed by name, not by integer ID.
|
|
||||||
// Scan all rows and match on the synthetic ID derived from the name.
|
|
||||||
var all = await GetAllNotificationListsAsync(cancellationToken);
|
|
||||||
return all.FirstOrDefault(l => l.Id == id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NotificationRecipient (read) ──
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(
|
|
||||||
int notificationListId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Find the parent list to get its name, then parse the recipient_emails JSON.
|
|
||||||
var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
|
|
||||||
if (list is null)
|
|
||||||
return Array.Empty<NotificationRecipient>();
|
|
||||||
|
|
||||||
await using var connection = CreateConnection();
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = @"
|
|
||||||
SELECT recipient_emails
|
|
||||||
FROM notification_lists
|
|
||||||
WHERE name = @name";
|
|
||||||
command.Parameters.AddWithValue("@name", list.Name);
|
|
||||||
|
|
||||||
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
|
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
|
||||||
return Array.Empty<NotificationRecipient>();
|
|
||||||
|
|
||||||
return ParseRecipientEmails(json, notificationListId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<NotificationRecipient?> GetRecipientByIdAsync(
|
|
||||||
int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Scan all lists and their recipients to find the matching synthetic ID.
|
|
||||||
var lists = await GetAllNotificationListsAsync(cancellationToken);
|
|
||||||
foreach (var list in lists)
|
|
||||||
{
|
|
||||||
var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
|
||||||
var match = recipients.FirstOrDefault(r => r.Id == id);
|
|
||||||
if (match is not null)
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SmtpConfiguration (read) ──
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await using var connection = CreateConnection();
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = @"
|
|
||||||
SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
|
|
||||||
FROM smtp_configurations";
|
|
||||||
|
|
||||||
var results = new List<SmtpConfiguration>();
|
|
||||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
results.Add(MapSmtpConfiguration(reader));
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(
|
|
||||||
int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
|
|
||||||
return all.FirstOrDefault(s => s.Id == id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Write operations (not supported on site) ──
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
// ── SmsConfiguration (central-only — never deployed to or served from the site) ──
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<SmsConfiguration?> GetSmsConfigurationAsync(CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<SmsConfiguration?> GetSmsConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<SmsConfiguration>> GetAllSmsConfigurationsAsync(CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task AddSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task UpdateSmsConfigurationAsync(SmsConfiguration smsConfiguration, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteSmsConfigurationAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
|
||||||
|
|
||||||
// ── Private helpers ──
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new SQLite connection against the site database via
|
|
||||||
/// <see cref="SiteStorageService.CreateConnection"/> instead of
|
|
||||||
/// reaching into its private connection-string field via reflection.
|
|
||||||
/// </summary>
|
|
||||||
private SqliteConnection CreateConnection() => _storage.CreateConnection();
|
|
||||||
|
|
||||||
private static NotificationList MapNotificationList(SqliteDataReader reader)
|
|
||||||
{
|
|
||||||
var name = reader.GetString(0);
|
|
||||||
var list = new NotificationList(name)
|
|
||||||
{
|
|
||||||
Id = GenerateSyntheticId(name)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Eagerly populate Recipients from the JSON column.
|
|
||||||
var emailsJson = reader.GetString(1);
|
|
||||||
var recipients = ParseRecipientEmails(emailsJson, list.Id);
|
|
||||||
foreach (var r in recipients)
|
|
||||||
{
|
|
||||||
list.Recipients.Add(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<NotificationRecipient> ParseRecipientEmails(
|
|
||||||
string json, int notificationListId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var emails = JsonSerializer.Deserialize<List<string>>(json,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
||||||
|
|
||||||
if (emails is null)
|
|
||||||
return Array.Empty<NotificationRecipient>();
|
|
||||||
|
|
||||||
return emails.Select(email => new NotificationRecipient(
|
|
||||||
name: email,
|
|
||||||
emailAddress: email)
|
|
||||||
{
|
|
||||||
Id = GenerateSyntheticId($"{notificationListId}:{email}"),
|
|
||||||
NotificationListId = notificationListId
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return Array.Empty<NotificationRecipient>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
|
|
||||||
{
|
|
||||||
var name = reader.GetString(0);
|
|
||||||
return new SmtpConfiguration(
|
|
||||||
host: reader.GetString(1),
|
|
||||||
authType: reader.GetString(3),
|
|
||||||
fromAddress: reader.GetString(4))
|
|
||||||
{
|
|
||||||
Id = GenerateSyntheticId(name),
|
|
||||||
Port = reader.GetInt32(2),
|
|
||||||
Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
|
|
||||||
TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a stable positive integer ID from a string name.
|
|
||||||
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
|
|
||||||
/// which is randomized per process on .NET Core and would change every restart.
|
|
||||||
/// </summary>
|
|
||||||
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
|
|
||||||
}
|
|
||||||
@@ -62,7 +62,6 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
// Site-local repository implementations backed by SQLite
|
// Site-local repository implementations backed by SQLite
|
||||||
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
|
|
||||||
|
|
||||||
// Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
|
// Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
|
||||||
services.AddHttpClient<IDeploymentConfigFetcher, HttpDeploymentConfigFetcher>()
|
services.AddHttpClient<IDeploymentConfigFetcher, HttpDeploymentConfigFetcher>()
|
||||||
|
|||||||
@@ -424,7 +424,8 @@ public class SiteCompositionRootTests : IDisposable
|
|||||||
public static IEnumerable<object[]> SiteScopedServices => new[]
|
public static IEnumerable<object[]> SiteScopedServices => new[]
|
||||||
{
|
{
|
||||||
new object[] { typeof(IExternalSystemRepository) },
|
new object[] { typeof(IExternalSystemRepository) },
|
||||||
new object[] { typeof(INotificationRepository) },
|
// INotificationRepository is intentionally absent — notification config is
|
||||||
|
// central-only (arch-review 08 §1.3/#23); the site-side variant was removed.
|
||||||
new object[] { typeof(ExternalSystemClient) },
|
new object[] { typeof(ExternalSystemClient) },
|
||||||
new object[] { typeof(IExternalSystemClient) },
|
new object[] { typeof(IExternalSystemClient) },
|
||||||
new object[] { typeof(DatabaseGateway) },
|
new object[] { typeof(DatabaseGateway) },
|
||||||
@@ -442,11 +443,15 @@ public class SiteCompositionRootTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Site_NotificationRepository_IsSiteImplementation()
|
public void Site_NotificationRepository_IsNotRegistered()
|
||||||
{
|
{
|
||||||
|
// Notification delivery is central-only (CLAUDE.md "Notification delivery is
|
||||||
|
// central-only"); the site-local notification_lists/smtp_configurations tables
|
||||||
|
// are purged on every artifact apply, so a site-side INotificationRepository
|
||||||
|
// could only ever serve empty results. It must not be registered at all.
|
||||||
using var scope = _host.Services.CreateScope();
|
using var scope = _host.Services.CreateScope();
|
||||||
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
var repo = scope.ServiceProvider.GetService<INotificationRepository>();
|
||||||
Assert.IsType<SiteNotificationRepository>(repo);
|
Assert.Null(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Options ---
|
// --- Options ---
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
|
||||||
|
|
||||||
@@ -123,46 +122,67 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
|
|||||||
// Upsert should not throw
|
// Upsert should not throw
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification List Storage ──
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StoreNotificationList_DoesNotThrow()
|
|
||||||
{
|
|
||||||
await _storage.StoreNotificationListAsync(
|
|
||||||
"Ops Team", ["ops@example.com", "admin@example.com"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task StoreNotificationList_Upserts()
|
|
||||||
{
|
|
||||||
await _storage.StoreNotificationListAsync("Team1", ["a@b.com"]);
|
|
||||||
await _storage.StoreNotificationListAsync("Team1", ["x@y.com", "z@w.com"]);
|
|
||||||
|
|
||||||
// Upsert should not throw
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
|
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
|
||||||
|
//
|
||||||
|
// Notification config is central-only. The site-side write paths and
|
||||||
|
// SiteNotificationRepository were removed 2026-07-10 (arch-review 08 §1.3/#23);
|
||||||
|
// PurgeCentralOnlyNotificationConfigAsync is retained as the security cleanup for
|
||||||
|
// DBs written by older builds. These tests seed the (still-present) tables via raw
|
||||||
|
// SQL — the only way rows can now exist — and assert the purge empties them.
|
||||||
|
|
||||||
|
private async Task SeedNotificationRowAsync(string name, string emailsJson)
|
||||||
|
{
|
||||||
|
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"INSERT INTO notification_lists (name, recipient_emails, updated_at) VALUES (@n, @e, @u)";
|
||||||
|
command.Parameters.AddWithValue("@n", name);
|
||||||
|
command.Parameters.AddWithValue("@e", emailsJson);
|
||||||
|
command.Parameters.AddWithValue("@u", DateTimeOffset.UtcNow.ToString("O"));
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedSmtpRowAsync(string name, string password)
|
||||||
|
{
|
||||||
|
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
@"INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
|
||||||
|
VALUES (@n, 'smtp.example.com', 587, 'BasicAuth', 'noreply@example.com', 'smtpuser', @p, NULL, @u)";
|
||||||
|
command.Parameters.AddWithValue("@n", name);
|
||||||
|
command.Parameters.AddWithValue("@p", password);
|
||||||
|
command.Parameters.AddWithValue("@u", DateTimeOffset.UtcNow.ToString("O"));
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<long> RowCountAsync(string table)
|
||||||
|
{
|
||||||
|
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = $"SELECT COUNT(*) FROM {table}";
|
||||||
|
return (long)(await command.ExecuteScalarAsync())!;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows()
|
public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows()
|
||||||
{
|
{
|
||||||
// Simulate a pre-fix build that already shipped a notification list and an
|
// Simulate a pre-fix build that already shipped a notification list and an
|
||||||
// SMTP config (with a plaintext password) to the site.
|
// SMTP config (with a plaintext password) to the site.
|
||||||
await _storage.StoreNotificationListAsync("Ops Team", ["ops@example.com"]);
|
await SeedNotificationRowAsync("Ops Team", "[\"ops@example.com\"]");
|
||||||
await _storage.StoreSmtpConfigurationAsync(
|
await SeedSmtpRowAsync("smtp.example.com:587", "PLAINTEXT-SECRET");
|
||||||
"smtp.example.com:587", "smtp.example.com", 587, "BasicAuth",
|
|
||||||
"noreply@example.com", "smtpuser", "PLAINTEXT-SECRET", null);
|
|
||||||
|
|
||||||
var repo = new SiteNotificationRepository(_storage);
|
Assert.Equal(1, await RowCountAsync("notification_lists"));
|
||||||
Assert.NotEmpty(await repo.GetAllNotificationListsAsync());
|
Assert.Equal(1, await RowCountAsync("smtp_configurations"));
|
||||||
Assert.NotEmpty(await repo.GetAllSmtpConfigurationsAsync());
|
|
||||||
|
|
||||||
// The fix: every artifact apply/deploy purges these central-only rows.
|
// The fix: every artifact apply/deploy purges these central-only rows.
|
||||||
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
||||||
|
|
||||||
// Both tables are now empty — the plaintext SMTP credential is gone.
|
// Both tables are now empty — the plaintext SMTP credential is gone.
|
||||||
Assert.Empty(await repo.GetAllNotificationListsAsync());
|
Assert.Equal(0, await RowCountAsync("notification_lists"));
|
||||||
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
|
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -172,9 +192,8 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
|
|||||||
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
||||||
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
await _storage.PurgeCentralOnlyNotificationConfigAsync();
|
||||||
|
|
||||||
var repo = new SiteNotificationRepository(_storage);
|
Assert.Equal(0, await RowCountAsync("notification_lists"));
|
||||||
Assert.Empty(await repo.GetAllNotificationListsAsync());
|
Assert.Equal(0, await RowCountAsync("smtp_configurations"));
|
||||||
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schema includes all WP-33 tables ──
|
// ── Schema includes all WP-33 tables ──
|
||||||
@@ -186,7 +205,9 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
|
|||||||
await _storage.StoreSharedScriptAsync("s", "code", null, null);
|
await _storage.StoreSharedScriptAsync("s", "code", null, null);
|
||||||
await _storage.StoreExternalSystemAsync("e", "url", "None", null, null);
|
await _storage.StoreExternalSystemAsync("e", "url", "None", null, null);
|
||||||
await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero);
|
await _storage.StoreDatabaseConnectionAsync("d", "connstr", 1, TimeSpan.Zero);
|
||||||
await _storage.StoreNotificationListAsync("n", ["email@test.com"]);
|
|
||||||
|
// notification_lists / smtp_configurations remain in the schema (kept for the
|
||||||
|
// security purge) — their presence is exercised by the purge tests above.
|
||||||
|
|
||||||
// All succeeded without exceptions = tables exist
|
// All succeeded without exceptions = tables exist
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,25 +199,8 @@ public class SiteRepositoryTests : IDisposable
|
|||||||
Assert.Null(await repo.GetDatabaseConnectionByNameAsync("DoesNotExist"));
|
Assert.Null(await repo.GetDatabaseConnectionByNameAsync("DoesNotExist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// NotificationRepository_SyntheticId_IsStableAcrossRestart was removed 2026-07-10
|
||||||
/// SiteRuntime-007: the same stability guarantee for notification lists.
|
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
|
||||||
/// </summary>
|
// notification config is central-only and never lives on a site. The synthetic-ID
|
||||||
[Fact]
|
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
|
||||||
public async Task NotificationRepository_SyntheticId_IsStableAcrossRestart()
|
|
||||||
{
|
|
||||||
var storage1 = NewStorage();
|
|
||||||
await storage1.InitializeAsync();
|
|
||||||
await storage1.StoreNotificationListAsync(
|
|
||||||
"OnCall", new[] { "a@example.com", "b@example.com" });
|
|
||||||
|
|
||||||
var repo1 = new SiteNotificationRepository(storage1);
|
|
||||||
var idBeforeRestart = (await repo1.GetAllNotificationListsAsync())[0].Id;
|
|
||||||
|
|
||||||
var storage2 = NewStorage();
|
|
||||||
var repo2 = new SiteNotificationRepository(storage2);
|
|
||||||
var found = await repo2.GetNotificationListByIdAsync(idBeforeRestart);
|
|
||||||
|
|
||||||
Assert.NotNull(found);
|
|
||||||
Assert.Equal("OnCall", found.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user