test(desec): end-to-end Playwright coverage against a local mock

- desec-mock.mjs: a dependency-free Node mock of the deSEC API endpoints the code
  uses, with the real status-code semantics (202 on account creation, 403 until
  verified then 200 + token, 201/409 on domain creation) plus /__control hooks to
  verify and reset state.
- desec-register.spec.js / desec-existing.spec.js drive the real AIO UI through
  the register -> verify -> domain flow and the existing-account login flow;
  desec-helpers.js holds the shared login helper. Each scenario ends by setting a
  domain, so they run as separate CI steps with a re-seed in between.
- seed-desec-mock-config.php points desec_api_base / desec_update_url at the mock
  (config key only, no env override, so production is unaffected) and seeds a
  known master password, since seeding configuration.json makes AIO consider
  itself already installed and /setup no longer shows a generated password.
- Both Playwright workflows start the mock, mount ./community-containers so the
  caddy/dnsmasq definitions enabled by the flow are present, seed the config, and
  run the two deSEC specs with a re-seed between them.

Co-Authored-By: szaimen <42591237+szaimen@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Simon L. <szaimen@e.mail.de>
This commit is contained in:
Simon L.
2026-06-18 13:19:16 +02:00
co-authored by szaimen Claude Opus 4.8
parent 0753c6c902
commit 4c8012adcd
7 changed files with 426 additions and 2 deletions
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js';
// Exercises the deSEC "I already have a verified account" login path: supplying a valid
// password logs straight in and registers the domain in one step (no email-verification
// round-trip). See desec-register.spec.js for the full setup notes.
//
// This flow also ends by registering a domain, so it is run as its own CI step with the
// deSEC state re-seeded beforehand (configuration.json must have no domain set for the
// registration UI to render).
test('deSEC existing-account login flow', async ({ page: setupPage }) => {
test.setTimeout(5 * 60 * 1000);
// Pre-create a verified account in the mock so the password (login) path is exercised
// directly, without the verification round-trip.
await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' });
const email = `existing-${Math.floor(Math.random() * 2147483647)}@example.com`;
const password = 'correct horse battery staple';
await fetch(`${DESEC_MOCK_URL}/api/v1/auth/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' });
const containersPage = await logInToContainersPage(setupPage);
const slug = `aio-existing-${Math.floor(Math.random() * 2147483647)}`;
await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click();
await containersPage.locator('input[name="desec_email"]').fill(email);
await containersPage.locator('input[name="desec_password"]').fill(password);
await containersPage.locator('input[name="desec_slug"]').first().fill(slug);
await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click();
// Supplying a valid password logs straight in and registers the domain in one step.
await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 });
await expect(
containersPage.getByText("Don't have a domain? Get a free one from deSEC"),
).toHaveCount(0);
});
+22
View File
@@ -0,0 +1,22 @@
// Shared helpers for the deSEC Playwright scenarios.
//
// The deSEC mock is wired up by seeding configuration.json (see seed-desec-mock-config.php),
// which makes AIO consider itself already installed: /setup no longer renders the
// initial-password page. The seed step therefore writes a known master password (AIO_TEST_PASSWORD)
// that we log in with directly here instead of scraping it from /setup.
export const DESEC_MOCK_URL = process.env.DESEC_MOCK_URL ?? 'http://localhost:8090';
const AIO_PASSWORD = process.env.AIO_TEST_PASSWORD;
export async function logInToContainersPage(page) {
if (!AIO_PASSWORD) {
throw new Error('AIO_TEST_PASSWORD must be set to the master password seeded into configuration.json');
}
await page.goto('./');
await page.locator('#master-password').click();
await page.locator('#master-password').fill(AIO_PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('./containers');
return page;
}
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js';
// Drives the real AIO interface through the full deSEC "register a free domain" flow
// against the local mock (php/tests/desec-mock.mjs). The mastercontainer must be started
// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration form is rendered) and its
// configuration.json must point desec_api_base / desec_update_url at the mock (see the
// Playwright CI workflow). The mock's control endpoint is reachable from the test runner
// on the host at DESEC_MOCK_URL (default http://localhost:8090).
//
// This flow ends by registering a domain, which persists in configuration.json. It is
// therefore run as its own CI step; the deSEC state is reset (re-seeded) before the
// separate existing-account flow runs.
test('deSEC register -> verify -> domain flow', async ({ page: setupPage }) => {
test.setTimeout(5 * 60 * 1000);
// Start from a clean mock state so this test is independent of any other run.
await fetch(`${DESEC_MOCK_URL}/__control/reset`, { method: 'POST' });
const containersPage = await logInToContainersPage(setupPage);
const email = `e2e-${Math.floor(Math.random() * 2147483647)}@example.com`;
const slug = `aio-e2e-${Math.floor(Math.random() * 2147483647)}`;
// Open the deSEC registration section.
await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click();
// 1) Submit email only -> a new account is "created" (mock 202) and AIO asks the user
// to verify their email. This is a normal (non-error) state transition: the page
// reloads into the awaiting-verification UI, which renders inside <main> and shows
// the dedicated re-submit button.
await containersPage.locator('input[name="desec_email"]').fill(email);
await containersPage.locator('input[name="desec_slug"]').first().fill(slug);
await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click();
await expect(containersPage.getByRole('main')).toContainText('check your inbox', { timeout: 30 * 1000 });
await expect(
containersPage.getByRole('button', { name: 'I have verified my email register domain' }),
).toBeVisible();
// 2) Re-submit BEFORE verifying -> login still fails (mock 403) -> friendly hint shown.
await containersPage.getByRole('button', { name: 'I have verified my email register domain' }).click();
// Same as above: the failure hint is a transient toast, not <main> content.
await expect(containersPage.locator('.toast.error')).toContainText('Could not log in to deSEC', { timeout: 8 * 1000 });
// 3) Simulate the user clicking the verification link in their email.
const verifyResponse = await fetch(`${DESEC_MOCK_URL}/__control/verify`, { method: 'POST' });
expect(verifyResponse.status).toBe(200);
// 4) Re-submit after verification -> login succeeds, domain is registered, the deSEC
// section disappears and the container-start UI appears.
await containersPage.getByRole('button', { name: 'I have verified my email register domain' }).click();
await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 });
await expect(
containersPage.getByText("Don't have a domain? Get a free one from deSEC"),
).toHaveCount(0);
});