Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m23s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
141 lines
7.3 KiB
TypeScript
141 lines
7.3 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { PASSWORD, USERNAME } from './credentials';
|
|
|
|
// UI-interactive boot-gate flows (ersatztv#445), deferred from #363 because they cannot be expressed
|
|
// as curl calls. `scripts/e2e-functional.sh` already asserts the auth *HTTP* contracts (setup-claim
|
|
// 200/409, login 401/200, CSRF 403, security-stamp rotation). What it CANNOT reach, and what these
|
|
// specs exist for:
|
|
//
|
|
// 1. Client-side form validation — the Setup card's confirm-password gate is pure React state.
|
|
// No request is made, so there is no HTTP contract to assert.
|
|
// 2. AuthGate's state machine as rendered — that `setupRequired` yields the Setup card, a
|
|
// configured server yields the Login card, and a 401 yields neither.
|
|
// 3. The session cookie authenticating the SPA's OWN fetch layer. curl proves the cookie works for
|
|
// curl; it cannot prove the browser sends it on the app's `/api/v1/*` XHRs, which is the thing
|
|
// that actually breaks for a user.
|
|
// 4. Sign-out wired through the UserMenu back to the login gate.
|
|
//
|
|
// SERVER STATE IS SHARED AND PARTLY ONE-SHOT. The setup-claim can happen exactly once per config
|
|
// dir, so these tests are `serial` and the claim must run first. `scripts/e2e-ui.sh` boots a FRESH
|
|
// instance so the first test always finds `setupRequired: true`.
|
|
//
|
|
// Each `test` gets its own browser context (so its own cookie jar) — that is deliberate: it gives
|
|
// tests 2 and 3 a genuinely signed-out browser without a logout dance. Anything that depends on
|
|
// holding a session across steps must therefore stay INSIDE one test.
|
|
|
|
|
|
// The Setup/Login cards use the shared <Input>, which wraps its <input> in a <label> — so the
|
|
// accessible name picks up the error text too when a field is invalid ("Confirm password Passwords
|
|
// do not match."). Address the fields by their unique placeholders instead, which are stable
|
|
// regardless of validation state.
|
|
const setupUsername = (page: Page) => page.getByPlaceholder('Choose a username');
|
|
const setupPassword = (page: Page) => page.getByPlaceholder('Choose a password');
|
|
const setupConfirm = (page: Page) => page.getByPlaceholder('Re-enter the password');
|
|
const loginUsername = (page: Page) => page.getByPlaceholder('Username', { exact: true });
|
|
const loginPassword = (page: Page) => page.getByPlaceholder('Password', { exact: true });
|
|
|
|
/** The authenticated landmark: the TopBar UserMenu button renders the signed-in username. */
|
|
const userMenu = (page: Page) => page.getByRole('button', { name: new RegExp(USERNAME) });
|
|
|
|
/** Shown by UnauthorizedBanner on any 401 from the SPA's fetch layer. Must never appear when authed. */
|
|
const authBanner = (page: Page) => page.getByText('Your session has expired or you are signed out.');
|
|
|
|
test.describe.serial('boot gate', () => {
|
|
test('setup: a fresh instance gates on Setup, enforces confirmation, and the claim enters the app', async ({
|
|
page
|
|
}) => {
|
|
await page.goto('/app');
|
|
|
|
// (1) A fresh config dir => `/api/v1/auth/config.setupRequired` is true => the Setup card.
|
|
await expect(page.getByText('Welcome to ChicoryTV')).toBeVisible();
|
|
await expect(page.getByText('Create the first administrator account')).toBeVisible();
|
|
|
|
const submit = page.getByRole('button', { name: 'Create account' });
|
|
|
|
// (2) Client-side confirm-password validation — no HTTP request is involved, so this contract
|
|
// exists ONLY in the browser. Submit stays disabled until the two passwords actually match.
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await setupUsername(page).fill(USERNAME);
|
|
await setupPassword(page).fill(PASSWORD);
|
|
await expect(submit).toBeDisabled(); // confirm still empty
|
|
|
|
await setupConfirm(page).fill(`${PASSWORD}-mismatch`);
|
|
await expect(page.getByText('Passwords do not match.')).toBeVisible();
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await setupConfirm(page).fill(PASSWORD);
|
|
await expect(page.getByText('Passwords do not match.')).toBeHidden();
|
|
await expect(submit).toBeEnabled();
|
|
|
|
// (3) The claim returns a session and AuthGate hands off to the app shell.
|
|
await submit.click();
|
|
await expect(userMenu(page)).toBeVisible();
|
|
|
|
// (4) The cookie survives a full reload — AuthGate's `/auth/session` path, not the claim path.
|
|
await page.reload();
|
|
await expect(userMenu(page)).toBeVisible();
|
|
await expect(page.getByText('Welcome back')).toBeHidden();
|
|
|
|
// (5) The session authenticates the SPA's OWN `/api/v1/*` fetches — the assertion curl
|
|
// structurally cannot make. Navigating to Channels must render server data, not the 401 banner.
|
|
//
|
|
// NOTE: a fresh config is NOT empty. `DbInitializer` seeds one default channel (Number "1",
|
|
// Name "ErsatzTV") on every new DB, so the Channels *empty state* is unreachable here — assert
|
|
// the seeded row instead. Asserting the table content (not merely "the screen rendered") is what
|
|
// makes this prove the fetch succeeded: an unauthenticated fetch yields a table with no rows.
|
|
await page.goto('/app/channels');
|
|
await expect(page.getByRole('heading', { level: 1, name: 'Channels' })).toBeVisible();
|
|
await expect(page.getByRole('table', { name: 'Channels lineup' })).toBeVisible();
|
|
await expect(page.getByRole('cell', { name: /ErsatzTV/ }).first()).toBeVisible();
|
|
await expect(page.getByText('1 of 1 channels')).toBeVisible();
|
|
await expect(authBanner(page)).toBeHidden();
|
|
});
|
|
|
|
test('login: a configured server gates on Login; a wrong password errors inline', async ({ page }) => {
|
|
await page.goto('/app');
|
|
|
|
// Setup is claimed, so this signed-out browser must get Login — NOT Setup, and not the app.
|
|
await expect(page.getByText('Welcome back')).toBeVisible();
|
|
await expect(page.getByText('Create the first administrator account')).toBeHidden();
|
|
|
|
const submit = page.getByRole('button', { name: 'Sign in' });
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await loginUsername(page).fill(USERNAME);
|
|
await loginPassword(page).fill('definitely-the-wrong-password');
|
|
await submit.click();
|
|
|
|
// A 401 renders inline on the card; login() suppresses the global banner for exactly this case.
|
|
await expect(page.getByText('Invalid username or password.')).toBeVisible();
|
|
await expect(authBanner(page)).toBeHidden();
|
|
await expect(userMenu(page)).toBeHidden();
|
|
|
|
// The same form then accepts the correct password (proving the 401 was credential-specific).
|
|
await loginPassword(page).fill(PASSWORD);
|
|
await expect(page.getByText('Invalid username or password.')).toBeHidden();
|
|
await submit.click();
|
|
await expect(userMenu(page)).toBeVisible();
|
|
});
|
|
|
|
test('sign out through the UserMenu returns to the Login gate', async ({ page }) => {
|
|
await page.goto('/app');
|
|
|
|
await loginUsername(page).fill(USERNAME);
|
|
await loginPassword(page).fill(PASSWORD);
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
await expect(userMenu(page)).toBeVisible();
|
|
|
|
await userMenu(page).click();
|
|
await page.getByRole('button', { name: 'Sign out' }).click();
|
|
|
|
// Back to the login card, and the app shell is gone.
|
|
await expect(page.getByText('Welcome back')).toBeVisible();
|
|
await expect(userMenu(page)).toBeHidden();
|
|
|
|
// The revoked cookie must not resurrect the session on a reload (rotated security stamp).
|
|
await page.reload();
|
|
await expect(page.getByText('Welcome back')).toBeVisible();
|
|
});
|
|
});
|