using Microsoft.Data.SqlClient; namespace ScadaLink.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 ScadaLinkConfig /// 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 scadalink_app account /// from docker/central-node-a/appsettings.Central.json, with the host /// pointed at the host-exposed port (localhost:1433). The /// SCADALINK_PLAYWRIGHT_DB env var lets CI override the connection /// without recompiling. /// /// internal static class AuditDataSeeder { private const string DefaultConnectionString = "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5"; private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB"; /// /// Connection string for the running cluster's configuration DB. Resolved /// from SCADALINK_PLAYWRIGHT_DB when set, otherwise the local docker /// dev defaults. /// public static string ConnectionString { get { var fromEnv = Environment.GetEnvironmentVariable(EnvVar); return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv; } } /// /// Inserts a single audit row into AuditLog. 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, int? httpStatus = null, int? durationMs = null, string? errorMessage = null, string? requestSummary = null, string? responseSummary = null, string? extra = null, CancellationToken ct = default) { const string sql = @" INSERT INTO [AuditLog] ([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId], [ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary], [ResponseSummary], [PayloadTruncated], [Extra], [ForwardState]) VALUES (@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId, @executionId, @sourceSiteId, NULL, NULL, @actor, @target, @status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary, @responseSummary, 0, @extra, NULL);"; 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("@channel", channel); cmd.Parameters.AddWithValue("@kind", kind); cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value); cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value); cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value); cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value); cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value); cmd.Parameters.AddWithValue("@status", status); cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value); cmd.Parameters.AddWithValue("@durationMs", (object?)durationMs ?? DBNull.Value); cmd.Parameters.AddWithValue("@errorMessage", (object?)errorMessage ?? DBNull.Value); cmd.Parameters.AddWithValue("@requestSummary", (object?)requestSummary ?? DBNull.Value); cmd.Parameters.AddWithValue("@responseSummary", (object?)responseSummary ?? DBNull.Value); cmd.Parameters.AddWithValue("@extra", (object?)extra ?? DBNull.Value); 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; } } }