feat: separate create/edit form pages, Playwright test infrastructure, /auth/token endpoint
Move all CRUD create/edit forms from inline on list pages to dedicated form pages with back-button navigation and post-save redirect. Add Playwright Docker container (browser server on port 3000) with 25 passing E2E tests covering login, navigation, and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
This commit is contained in:
115
tests/ScadaLink.CentralUI.PlaywrightTests/LoginTests.cs
Normal file
115
tests/ScadaLink.CentralUI.PlaywrightTests/LoginTests.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class LoginTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public LoginTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnauthenticatedUser_RedirectsToLogin()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync(PlaywrightFixture.BaseUrl);
|
||||
|
||||
Assert.Contains("/login", page.Url);
|
||||
await Expect(page.Locator("h4")).ToHaveTextAsync("ScadaLink");
|
||||
await Expect(page.Locator("#username")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("#password")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("button[type='submit']")).ToHaveTextAsync("Sign In");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidCredentials_AuthenticatesSuccessfully()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// Should be on the dashboard, not the login page
|
||||
Assert.DoesNotContain("/login", page.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidCredentials_ShowsError()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
// POST invalid credentials via fetch
|
||||
var status = await page.EvaluateAsync<int>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=baduser&password=badpass',
|
||||
redirect: 'follow'
|
||||
});
|
||||
return resp.status;
|
||||
}
|
||||
");
|
||||
|
||||
// The login endpoint redirects to /login?error=... on failure.
|
||||
// Reload the page to see the error state.
|
||||
await page.ReloadAsync();
|
||||
Assert.Equal(200, status); // redirect followed to login page
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_ReturnsJwt()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
var result = await page.EvaluateAsync<JsonElement>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=multi-role&password=password'
|
||||
});
|
||||
const json = await resp.json();
|
||||
return { status: resp.status, hasToken: !!json.access_token, username: json.username || '' };
|
||||
}
|
||||
");
|
||||
|
||||
Assert.Equal(200, result.GetProperty("status").GetInt32());
|
||||
Assert.True(result.GetProperty("hasToken").GetBoolean());
|
||||
Assert.Equal("multi-role", result.GetProperty("username").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_InvalidCredentials_Returns401()
|
||||
{
|
||||
var page = await _fixture.NewPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
var status = await page.EvaluateAsync<int>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=baduser&password=badpass'
|
||||
});
|
||||
return resp.status;
|
||||
}
|
||||
");
|
||||
|
||||
Assert.Equal(401, status);
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
76
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
Normal file
76
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class NavigationTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public NavigationTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_IsVisibleAfterLogin()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// The nav sidebar should be visible with the brand
|
||||
await Expect(page.Locator(".brand")).ToHaveTextAsync("ScadaLink");
|
||||
// The nav should contain "Dashboard" link (exact match to avoid "Health Dashboard")
|
||||
await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sites", "/admin/sites")]
|
||||
[InlineData("Data Connections", "/admin/data-connections")]
|
||||
[InlineData("API Keys", "/admin/api-keys")]
|
||||
[InlineData("LDAP Mappings", "/admin/ldap-mappings")]
|
||||
public async Task AdminNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Templates", "/design/templates")]
|
||||
[InlineData("Shared Scripts", "/design/shared-scripts")]
|
||||
[InlineData("External Systems", "/design/external-systems")]
|
||||
public async Task DesignNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Instances", "/deployment/instances")]
|
||||
[InlineData("Deployments", "/deployment/deployments")]
|
||||
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Health Dashboard", "/monitoring/health")]
|
||||
[InlineData("Event Logs", "/monitoring/event-logs")]
|
||||
[InlineData("Parked Messages", "/monitoring/parked-messages")]
|
||||
[InlineData("Audit Log", "/monitoring/audit-log")]
|
||||
public async Task MonitoringNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath)
|
||||
{
|
||||
await page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
|
||||
await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
|
||||
Assert.Contains(expectedPath, page.Url);
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
108
tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs
Normal file
108
tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that manages the Playwright browser connection.
|
||||
/// Creates a single browser connection per test collection, reused across all tests.
|
||||
/// Requires the Playwright Docker container running at ws://localhost:3000.
|
||||
/// </summary>
|
||||
public class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>
|
||||
/// Playwright Server WebSocket endpoint (Docker container on host port 3000).
|
||||
/// </summary>
|
||||
private const string PlaywrightWsEndpoint = "ws://localhost:3000";
|
||||
|
||||
/// <summary>
|
||||
/// Central UI base URL as seen from inside the Docker network.
|
||||
/// The browser runs in the Playwright container, so it uses the Docker hostname.
|
||||
/// </summary>
|
||||
public const string BaseUrl = "http://scadalink-traefik";
|
||||
|
||||
/// <summary>Test LDAP credentials (multi-role user with Admin + Design + Deployment).</summary>
|
||||
public const string TestUsername = "multi-role";
|
||||
public const string TestPassword = "password";
|
||||
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await Browser.CloseAsync();
|
||||
Playwright.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new browser context and page. Each test gets an isolated session.
|
||||
/// </summary>
|
||||
public async Task<IPage> NewPageAsync()
|
||||
{
|
||||
var context = await Browser.NewContextAsync();
|
||||
return await context.NewPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page and log in with the test user.
|
||||
/// Uses JavaScript fetch() to POST to /auth/login from within the browser,
|
||||
/// which sets the auth cookie in the browser context. Then navigates to the dashboard.
|
||||
/// </summary>
|
||||
public async Task<IPage> NewAuthenticatedPageAsync()
|
||||
{
|
||||
var page = await NewPageAsync();
|
||||
|
||||
// Navigate to the login page first to establish the origin
|
||||
await page.GotoAsync($"{BaseUrl}/login");
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
|
||||
|
||||
// POST to /auth/login via fetch() inside the browser.
|
||||
// This sets the auth cookie in the browser context automatically.
|
||||
// Use redirect: 'follow' so the browser follows the 302 and the cookie is stored.
|
||||
var finalUrl = await page.EvaluateAsync<string>(@"
|
||||
async () => {
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=' + encodeURIComponent('" + TestUsername + @"')
|
||||
+ '&password=' + encodeURIComponent('" + TestPassword + @"'),
|
||||
redirect: 'follow'
|
||||
});
|
||||
return resp.url;
|
||||
}
|
||||
");
|
||||
|
||||
// The fetch followed the redirect. If it ended on /login, auth failed.
|
||||
if (finalUrl.Contains("/login"))
|
||||
{
|
||||
throw new InvalidOperationException($"Login failed — redirected back to login: {finalUrl}");
|
||||
}
|
||||
|
||||
// Navigate to the dashboard — cookie authenticates us
|
||||
await page.GotoAsync(BaseUrl);
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for Blazor enhanced navigation to update the URL path.
|
||||
/// Blazor Server uses SignalR for client-side navigation (no full page reload),
|
||||
/// so standard WaitForURLAsync times out. This polls window.location instead.
|
||||
/// </summary>
|
||||
public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000)
|
||||
{
|
||||
var js = excludePath != null
|
||||
? $"() => window.location.pathname.includes('{path}') && !window.location.pathname.includes('{excludePath}')"
|
||||
: $"() => window.location.pathname.includes('{path}')";
|
||||
await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs });
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("Playwright")]
|
||||
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.58.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
94
tests/ScadaLink.CentralUI.PlaywrightTests/SiteCrudTests.cs
Normal file
94
tests/ScadaLink.CentralUI.PlaywrightTests/SiteCrudTests.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class SiteCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public SiteCrudTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SitesPage_ShowsTable()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("table")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSiteButton_NavigatesToCreatePage()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Add Site')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
|
||||
var inputCount = await page.Locator("input").CountAsync();
|
||||
Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_BackButton_ReturnsToList()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Back')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_CancelButton_ReturnsToList()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Cancel')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_HasNodeSubsections()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
|
||||
await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_SaveWithoutName_ShowsError()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.ClickAsync("button:has-text('Save')");
|
||||
|
||||
// Should stay on create page with validation error
|
||||
Assert.Contains("/admin/sites/create", page.Url);
|
||||
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
Reference in New Issue
Block a user