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 , which wraps its in a — 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();
});
});