using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
///
///
///
with the indented
// JSON. Pretty-printed JSON inserts newlines + spaces — the verbatim
// single-line string we seeded has neither. Asserting on "headers"
// being followed by a colon+newline confirms System.Text.Json's
// WriteIndented produced the body, not the raw RequestSummary.
var requestBody = page.Locator("[data-test='request-body'] pre");
await Assertions.Expect(requestBody).ToBeVisibleAsync();
var bodyText = await requestBody.TextContentAsync();
Assert.NotNull(bodyText);
// Pretty-printed JSON contains a newline after the opening '{'.
Assert.Contains("\n", bodyText);
// The first-level keys are indented (System.Text.Json uses 2 spaces).
Assert.Contains(" \"method\"", bodyText);
// And the inner object's body is also pretty-printed (nested indent).
Assert.Contains(" \"unit\"", bodyText);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound()
{
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/curl-button/{runId}/";
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "method",
actor: "playwright-test-key",
httpStatus: 200,
durationMs: 12,
requestSummary: "{\"method\":\"POST\",\"headers\":{\"X-API-Key\":\"redacted\"},\"body\":{\"ping\":true}}");
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(row).ToBeVisibleAsync();
await row.ClickAsync();
// The Copy-as-cURL button is only rendered for API channels — the
// drawer's IsApiChannel guard. We assert visibility + clickability
// only; clipboard content varies between headless and headed runs.
var curlButton = page.Locator("[data-test='copy-as-curl']");
await Assertions.Expect(curlButton).ToBeVisibleAsync();
await Assertions.Expect(curlButton).ToBeEnabledAsync();
// Clicking should not throw or crash the page — clipboard interop
// failures are swallowed by CopyCurl(). The page stays alive.
await curlButton.ClickAsync();
await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext()
{
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/drill-in/{runId}/";
var correlationId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "Notification",
kind: "NotifySend",
status: "Delivered",
target: targetPrefix + "ops-list",
correlationId: correlationId,
durationMs: 8);
var page = await _fixture.NewAuthenticatedPageAsync();
// This is the exact URL the Notifications "View audit history" link
// produces: /audit/log?correlationId={NotificationId}. We assert the
// drill-in lands on the Audit Log page, the query-string survives,
// and the audit results grid surface is rendered (the filter bar +
// grid are present, so an operator can refine + Apply from here).
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?correlationId={correlationId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"correlationId={correlationId}", page.Url);
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
// Bundle D auto-load: the query-string drill-in populates the grid
// WITHOUT an Apply click. The grid resolves the ?correlationId= filter
// on OnInitialized and the seeded row appears.
//
// This was previously blocked by an EF "A second operation was started
// on this context instance" error — the page-level auto-load raced
// AuditFilterBar.GetAllSitesAsync() on the shared scoped Blazor
// DbContext. AuditLogQueryService now opens its own DI scope per query
// (scope-per-query), so the auto-load no longer contends with the
// filter bar's site enumeration and the assertion is restored.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{
// Lighter-weight than the auto-load test above: we don't need MSSQL,
// because we only verify that the Notifications page is reachable and
// is gated by the right policy — the link target itself is exercised by
// DrillInFromCorrelationId_AutoLoadsAuditLog. If the notifications list
// is empty (no seed) we still validate the empty-state markup is in
// place; if rows are present we confirm at least one "View audit history"
// link is rendered.
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/notifications/report");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Page reachable for the multi-role user; the Notification Report page
// is Deployment-gated, so this also covers that authorization path.
Assert.Contains("/notifications/report", page.Url);
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
// When there are notifications visible, the "View audit history" link
// is rendered. When there are none, we just confirm the empty-state.
var auditLinks = page.Locator("a:has-text('View audit history')");
var linkCount = await auditLinks.CountAsync();
if (linkCount > 0)
{
var firstLink = auditLinks.First;
var href = await firstLink.GetAttributeAsync("href");
Assert.NotNull(href);
Assert.StartsWith("/audit/log?correlationId=", href);
}
// No notifications visible? The page still rendered correctly, which is
// all we can assert without exercising the Akka query path to seed one.
}
[Fact]
public async Task ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload()
{
// Multi-role test user holds Admin, which grants AuditExport — the
// Export-CSV anchor is gated by AuthorizeView Policy=AuditExport.
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var exportLink = page.Locator("a:has-text('Export CSV')");
await Assertions.Expect(exportLink).ToBeVisibleAsync();
var href = await exportLink.GetAttributeAsync("href");
Assert.NotNull(href);
Assert.StartsWith("/api/centralui/audit/export", href!);
// Click the link via Playwright's download API so the framework drains
// the response body instead of letting the browser leave a dangling
// navigation. The endpoint streams text/csv; we assert the suggested
// filename matches the audit-log-{timestamp}.csv pattern from
// AuditExportEndpoints.HandleExportAsync.
var download = await page.RunAndWaitForDownloadAsync(async () =>
{
await exportLink.ClickAsync();
});
Assert.NotNull(download);
Assert.StartsWith("audit-log-", download.SuggestedFilename);
Assert.EndsWith(".csv", download.SuggestedFilename);
}
[Fact]
public async Task PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess()
{
// The designer LDAP user holds only the Design role, which does NOT
// grant OperationalAudit (Component-AuditLog.md §Authorization +
// AuthorizationPolicies.OperationalAuditRoles). The page-level
// [Authorize(Policy = OperationalAudit)] attribute is enforced by the
// ASP.NET Core authorization middleware, which redirects an
// authenticated-but-unauthorized user to the cookie scheme's
// AccessDenied path (/Account/AccessDenied?ReturnUrl=...) BEFORE the
// Blazor router runs — so the audit page never renders.
var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Access was denied: we landed on the AccessDenied path, not the audit
// page. The ReturnUrl carries the original /audit/log target.
Assert.Contains("/Account/AccessDenied", page.Url);
Assert.Contains("ReturnUrl", page.Url);
// The audit results grid never rendered for the unauthorized user.
Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync());
}
}