using System.Globalization; using System.Text.Json; using Microsoft.Data.SqlClient; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// /// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16). /// /// /// The Playwright suite runs against the live Docker cluster (the same one that /// answers http://localhost:9000), which talks to the ScadaBridgeConfig /// database on localhost:1433. infra/mssql/seed-config.sql is off /// limits per the task's strict rules, so each test inserts its own /// AuditLog rows at setup time and best-effort deletes them at teardown. /// /// /// /// Rows are tagged with a unique Target prefix derived from the test /// name + a GUID so the teardown DELETE never touches rows the cluster /// itself produced. The OccurredAtUtc is pinned to "now" so the default /// /// time-range filter still sees the row after Apply. /// /// /// /// Connection string mirrors the Docker cluster's scadabridge_app account /// from docker/central-node-a/appsettings.Central.json, with the host /// pointed at the host-exposed port (localhost:1433). The /// SCADABRIDGE_PLAYWRIGHT_DB env var lets CI override the connection /// without recompiling. /// /// internal static class AuditDataSeeder { /// /// Connection string for the running cluster's configuration DB. /// Delegates to . /// public static string ConnectionString => PlaywrightDbConnection.ConnectionString; /// /// Inserts a single audit row into the canonical AuditLog table. After the /// CollapseAuditLogToCanonical migration the typed audit fields live inside /// DetailsJson (camelCase), and Kind/Status/SourceSiteId/ /// ExecutionId/ParentExecutionId/IngestedAtUtc are computed columns /// derived from it — so this seeder writes only the 10 stored columns plus a /// DetailsJson bag matching the production codec, and lets the computed columns /// derive automatically. All optional fields are nullable so individual tests can shape /// the row to whatever payload they need for their drawer/grid assertions. /// public static async Task InsertAuditEventAsync( Guid eventId, DateTime occurredAtUtc, string channel, string kind, string status, string? sourceSiteId = null, string? target = null, string? actor = null, Guid? correlationId = null, Guid? executionId = null, Guid? parentExecutionId = null, int? httpStatus = null, int? durationMs = null, string? errorMessage = null, string? requestSummary = null, string? responseSummary = null, string? extra = null, CancellationToken ct = default) { // Typed audit fields ride inside DetailsJson (camelCase, nulls omitted) exactly as // the production AuditDetailsCodec writes them; the computed columns read these JSON // paths ($.kind, $.status, $.sourceSiteId, $.executionId, $.parentExecutionId, // $.ingestedAtUtc). Property order is irrelevant — the readers look up by name. var details = new Dictionary { ["channel"] = channel, ["kind"] = kind, ["status"] = status, }; if (executionId is { } ex) details["executionId"] = ex; if (parentExecutionId is { } pex) details["parentExecutionId"] = pex; if (sourceSiteId is not null) details["sourceSiteId"] = sourceSiteId; if (httpStatus is { } hs) details["httpStatus"] = hs; if (durationMs is { } dm) details["durationMs"] = dm; if (errorMessage is not null) details["errorMessage"] = errorMessage; if (requestSummary is not null) details["requestSummary"] = requestSummary; if (responseSummary is not null) details["responseSummary"] = responseSummary; if (extra is not null) details["extra"] = extra; details["payloadTruncated"] = false; details["ingestedAtUtc"] = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture); var detailsJson = JsonSerializer.Serialize(details); // Action/Outcome/Category derive from (channel, kind, status) exactly as the // production canonical factory and the migration's SQL projection do. var action = $"{channel}.{kind}"; var category = channel; var outcome = kind == "InboundAuthFailure" ? "Denied" : status == "Delivered" ? "Success" : status is "Failed" or "Parked" or "Discarded" ? "Failure" : "Success"; const string sql = @" INSERT INTO [AuditLog] ([EventId], [OccurredAtUtc], [Actor], [Action], [Outcome], [Category], [Target], [SourceNode], [CorrelationId], [DetailsJson]) VALUES (@eventId, @occurredAtUtc, @actor, @action, @outcome, @category, @target, NULL, @correlationId, @detailsJson);"; await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); await using var cmd = connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@eventId", eventId); cmd.Parameters.AddWithValue("@occurredAtUtc", occurredAtUtc); cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value); cmd.Parameters.AddWithValue("@action", action); cmd.Parameters.AddWithValue("@outcome", outcome); cmd.Parameters.AddWithValue("@category", category); cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value); cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value); cmd.Parameters.AddWithValue("@detailsJson", detailsJson); await cmd.ExecuteNonQueryAsync(ct); } /// /// Best-effort cleanup. Deletes every AuditLog row whose Target /// starts with . Swallows all errors — a /// stuck row carrying a random GUID suffix does not collide with future /// runs and tests should not fail teardown. /// public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default) { try { await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); await using var cmd = connection.CreateCommand(); cmd.CommandText = "DELETE FROM [AuditLog] WHERE [Target] LIKE @prefix"; cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%"); await cmd.ExecuteNonQueryAsync(ct); } catch { // Best-effort — the prefix carries a GUID so the rows are unique to // this test run and won't collide on the next pass. } } /// /// Probe whether the configuration DB is reachable. Tests gate their /// per-test setup on this; when the cluster is down the test fails with a /// clear "MSSQL unavailable" message instead of an opaque SqlException. /// public static async Task IsAvailableAsync(CancellationToken ct = default) { try { await using var connection = new SqlConnection(ConnectionString); await connection.OpenAsync(ct); return true; } catch { return false; } } }