244 lines
13 KiB
C#
244 lines
13 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport;
|
|
|
|
/// <summary>
|
|
/// End-to-end round-trip for the Transport Import wizard (Component #24, Task T22).
|
|
///
|
|
/// <para>
|
|
/// The test exercises the full five-step Apply path at
|
|
/// <c>/design/transport/import</c> against the running dev cluster, using a
|
|
/// synthetic single-template bundle the test itself exports via the CLI:
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>Create a throwaway template (one Double attribute) and export it to an
|
|
/// encrypted <c>.scadabundle</c> on the host temp dir.</item>
|
|
/// <item>Delete the source template so the importer sees the bundle's template
|
|
/// as <see cref="ConflictKind.New"/> — it renders as a static <c>Add</c>
|
|
/// row with no blocker, so the diff step has nothing to resolve.</item>
|
|
/// <item>Drive the wizard: upload → passphrase → diff (Next) → confirm
|
|
/// (type the source-environment name) → Apply → assert the success
|
|
/// summary and the audit drill-in link.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// Remote file upload note: the Playwright browser runs in a Docker container
|
|
/// (<c>ws://localhost:3000</c>) with no host-filesystem volume mount, so the
|
|
/// staged bundle on the host is NOT visible to the remote browser as a path.
|
|
/// Playwright's <see cref="ILocator.SetInputFilesAsync(string, LocatorSetInputFilesOptions?)"/>
|
|
/// handles this transparently: the Playwright client (running on the host, in this
|
|
/// test process) reads the file and streams its bytes over the WebSocket to the
|
|
/// remote browser, which materialises it for the <c><input type=file></c>.
|
|
/// This test is the de-risking evidence that the path-based overload works over a
|
|
/// WS-connected remote browser.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public sealed class TransportImportTests
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public TransportImportTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task ImportSyntheticBundle_AppliesAndShowsAuditDrillIn()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// The source-environment name doubles as the Step-4 confirm phrase, so it
|
|
// must round-trip verbatim through the bundle manifest and back into the UI.
|
|
var env = CliRunner.UniqueName("env");
|
|
var tmplName = CliRunner.UniqueName("imp");
|
|
var pass = "pw-" + env;
|
|
|
|
// Stage the exported bundle on the host temp dir. SetInputFilesAsync streams
|
|
// the bytes to the remote browser, so the path need not be visible inside
|
|
// the Playwright container.
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");
|
|
|
|
try
|
|
{
|
|
// ── ARRANGE: build + export a synthetic single-template bundle ─────────────
|
|
int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
|
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
|
await CliRunner.BundleExportAsync(bundlePath, tmplId, pass, env);
|
|
|
|
// Delete the source so the import diff classifies the bundle's template
|
|
// as New (Add) rather than Identical/Modified — no blocker, no conflict
|
|
// resolution required, Apply proceeds cleanly.
|
|
await CliRunner.DeleteTemplateAsync(tmplId);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
// ── STEP 1: Upload ────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// De-risk the remote upload: stream the host file to the container browser.
|
|
await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
|
|
|
|
// The bundle is encrypted (we exported with a passphrase), so the page
|
|
// peeks the manifest with no passphrase, fails to decrypt, and shows the
|
|
// encrypted-bundle notice instead of the (unencrypted-only) manifest
|
|
// summary. Either way, the Step-1 Next button only renders once the file
|
|
// was read successfully — its appearance proves SetInputFiles worked.
|
|
await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
|
|
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
|
|
|
await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();
|
|
|
|
// ── STEP 2: Passphrase ──────────────────────────────────────────────────────
|
|
await Assertions.Expect(page.Locator("#import-passphrase"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
await page.Locator("#import-passphrase").FillAsync(pass);
|
|
await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
|
|
|
|
// ── STEP 3: Diff ────────────────────────────────────────────────────────────
|
|
// Wait for the passphrase input to disappear before asserting the diff
|
|
// summary — this gives a clearer failure when decrypt/preview is slow and
|
|
// prevents a race between the decrypt completion and the render of the diff.
|
|
await Assertions.Expect(page.Locator("#import-passphrase"))
|
|
.ToBeHiddenAsync(new() { Timeout = 20_000 });
|
|
|
|
// A brand-new template renders as a static Add item with no blocker, so
|
|
// the diff summary appears and the Next button is enabled.
|
|
await Assertions.Expect(page.Locator("[data-testid='diff-summary']"))
|
|
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
|
|
|
var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')");
|
|
await Assertions.Expect(diffNext).ToBeEnabledAsync(new() { Timeout = 15_000 });
|
|
await diffNext.ClickAsync();
|
|
|
|
// ── STEP 4: Confirm ──────────────────────────────────────────────────────────
|
|
// The Apply button is disabled until the typed text matches the bundle's
|
|
// source-environment name exactly.
|
|
await Assertions.Expect(page.Locator("#confirm-env"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
await page.Locator("#confirm-env").FillAsync(env);
|
|
|
|
var applyBtn = page.Locator("button.btn.btn-danger:has-text('Apply Import')");
|
|
await Assertions.Expect(applyBtn).ToBeEnabledAsync();
|
|
await applyBtn.ClickAsync();
|
|
|
|
// ── STEP 5: Result ───────────────────────────────────────────────────────────
|
|
var resultSummary = page.Locator("div.alert.alert-success[data-testid='result-summary']");
|
|
await Assertions.Expect(resultSummary).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
|
await Assertions.Expect(resultSummary).ToContainTextAsync("Import complete.");
|
|
|
|
// The audit drill-in link must carry the correlated BundleImportId.
|
|
var auditLink = page.Locator("a:has-text('Audit trail →')");
|
|
await Assertions.Expect(auditLink).ToBeVisibleAsync();
|
|
var href = await auditLink.GetAttributeAsync("href");
|
|
Assert.NotNull(href);
|
|
Assert.Matches(@"^/audit/configuration\?bundleImportId=[0-9a-fA-F\-]{36}$", href);
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort teardown: drop any template the test left behind (the source
|
|
// template, plus the one created by a successful import — both carry the
|
|
// tmplName prefix) and delete the staged bundle file.
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — never mask the test's own failure.
|
|
}
|
|
|
|
try { File.Delete(bundlePath); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Negative path: feed a real encrypted bundle, then submit the WRONG
|
|
/// passphrase at Step 2. Per
|
|
/// <c>TransportImport.razor.cs::SubmitPassphraseAsync</c>, the importer throws
|
|
/// <see cref="System.Security.Cryptography.CryptographicException"/>, which
|
|
/// increments <c>_failedUnlockAttempts</c> to 1 (below the configured
|
|
/// <c>MaxUnlockAttemptsPerSession</c> of 3, so no lockout / no re-upload),
|
|
/// sets <c>_errorMessage = "Wrong passphrase. Please try again."</c>, clears
|
|
/// the passphrase field, and leaves <c>_step</c> on Passphrase. The wizard
|
|
/// therefore renders the <c>[data-testid='error-message']</c> alert, keeps
|
|
/// <c>#import-passphrase</c> visible, and never reaches the Diff step — so
|
|
/// <c>[data-testid='diff-summary']</c> stays absent.
|
|
///
|
|
/// <para>
|
|
/// We never reach the diff/apply, so the source template is deliberately NOT
|
|
/// deleted before import; teardown drops it by name prefix.
|
|
/// </para>
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var tmplName = CliRunner.UniqueName("wrongpass");
|
|
var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");
|
|
|
|
try
|
|
{
|
|
// ── ARRANGE: build + export a synthetic single-template encrypted bundle ──
|
|
int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
|
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
|
await CliRunner.BundleExportAsync(bundlePath, tmplId, "correct-passphrase-1", "src-env");
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
// ── STEP 1: Upload ────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
|
|
await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
|
|
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
|
|
|
await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();
|
|
|
|
// ── STEP 2: Passphrase (wrong) ────────────────────────────────────────────
|
|
await Assertions.Expect(page.Locator("#import-passphrase"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
await page.Locator("#import-passphrase").FillAsync("WRONG-passphrase-xyz");
|
|
await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
|
|
|
|
// The wrong passphrase surfaces the typed error, keeps the passphrase
|
|
// input visible (still on Step 2), and never reveals the diff summary.
|
|
await Assertions.Expect(page.Locator("[data-testid='error-message']"))
|
|
.ToContainTextAsync("Wrong passphrase. Please try again.", new() { Timeout = 10_000 });
|
|
await Assertions.Expect(page.Locator("#import-passphrase")).ToBeVisibleAsync();
|
|
// Assert the diff step is genuinely absent (the @switch never rendered it), not merely
|
|
// hidden — ToBeHiddenAsync is vacuously true for an element that doesn't exist.
|
|
await Assertions.Expect(page.Locator("[data-testid='diff-summary']")).ToHaveCountAsync(0);
|
|
|
|
// Secondary indicator: one failed attempt recorded (1 of MaxUnlockAttempts).
|
|
await Assertions.Expect(page.Locator("[data-testid='unlock-attempts']"))
|
|
.ToContainTextAsync("Failed unlock attempts: 1 of");
|
|
}
|
|
finally
|
|
{
|
|
// The source template was never deleted (we never reached apply), so
|
|
// teardown drops it by name prefix and removes the staged bundle.
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — never mask the test's own failure.
|
|
}
|
|
|
|
try { File.Delete(bundlePath); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
}
|