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:16:22 +02:00
parent 0753c6c902
commit 4c8012adcd
7 changed files with 426 additions and 2 deletions
+65 -1
View File
@@ -20,6 +20,10 @@ concurrency:
env:
BASE_URL: https://localhost:8080
# Master password seeded into configuration.json for the deSEC test. Seeding the config
# makes AIO consider itself already installed, so /setup no longer generates/shows a
# password; the seed helper writes this one and the test logs in with it directly.
AIO_TEST_PASSWORD: correct horse battery staple aircraft
jobs:
test:
@@ -116,7 +120,7 @@ jobs:
- name: Run Playwright tests for backup restore
run: |
cd php/tests
cd php/tests
export DEBUG=pw:api
if ! npx playwright test tests/restore-instance.spec.js; then
docker logs nextcloud-aio-mastercontainer
@@ -124,6 +128,66 @@ jobs:
exit 1
fi
- name: Start deSEC mock and fresh development server
run: |
# The deSEC flow must not touch the real deSEC API, so point it at a local mock.
node php/tests/desec-mock.mjs 8090 > desec-mock.log 2>&1 &
echo $! > desec-mock.pid
docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup,caddy,dnsmasq} || true
docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true
# SKIP_DOMAIN_VALIDATION=false so the deSEC registration form is rendered; the deSEC
# flow sets the domain with validation skipped internally regardless of this.
docker run \
-d \
--init \
--name nextcloud-aio-mastercontainer \
--restart always \
--publish 8080:8080 \
--add-host host.docker.internal:host-gateway \
--volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
--volume ./php:/var/www/docker-aio/php \
--volume ./community-containers:/var/www/docker-aio/community-containers \
--volume ./Containers/mastercontainer/internal.Caddyfile:/internal.Caddyfile \
--volume ./Containers/mastercontainer/headers.Caddyfile:/headers.Caddyfile \
--volume ./Containers/mastercontainer/start.sh:/start.sh \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--env SKIP_DOMAIN_VALIDATION=false \
--env APACHE_PORT=11000 \
ghcr.io/nextcloud-releases/all-in-one:develop
echo Waiting for 10 seconds for the development container to start ...
sleep 10
# Point the running interface at the deSEC mock via configuration.json (config-only,
# no env override). The container runs PHP and has ./php mounted, so use the helper.
# AIO_TEST_PASSWORD is forwarded so the helper can seed the master password too.
docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \
php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \
http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update
- name: Run Playwright tests for deSEC domain registration
run: |
export DEBUG=pw:api
export DESEC_MOCK_URL=http://localhost:8090
# Each deSEC scenario ends by registering a domain, which persists in
# configuration.json and would hide the registration UI for the next scenario.
# Run each spec as its own step and re-seed (reset the deSEC state) in between.
reseed() {
docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \
php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \
http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update
}
dump_logs() {
docker logs nextcloud-aio-mastercontainer
echo "--- deSEC mock log ---"
cat "$GITHUB_WORKSPACE/desec-mock.log" || true
}
cd php/tests
if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi
reseed
if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi
kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
@@ -5,6 +5,10 @@ on:
env:
BASE_URL: https://localhost:8080
# Master password seeded into configuration.json for the deSEC test. Seeding the config
# makes AIO consider itself already installed, so /setup no longer generates/shows a
# password; the seed helper writes this one and the test logs in with it directly.
AIO_TEST_PASSWORD: correct horse battery staple aircraft
jobs:
test:
@@ -74,7 +78,7 @@ jobs:
- name: Run Playwright tests for backup restore
run: |
cd php/tests
cd php/tests
export DEBUG=pw:api
if ! npx playwright test tests/restore-instance.spec.js; then
docker logs nextcloud-aio-mastercontainer
@@ -82,6 +86,63 @@ jobs:
exit 1
fi
- name: Start deSEC mock and fresh development server
run: |
# The deSEC flow must not touch the real deSEC API, so point it at a local mock.
node php/tests/desec-mock.mjs 8090 > desec-mock.log 2>&1 &
echo $! > desec-mock.pid
docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup,caddy,dnsmasq} || true
docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true
# SKIP_DOMAIN_VALIDATION=false so the deSEC registration form is rendered; the deSEC
# flow sets the domain with validation skipped internally regardless of this.
# Mount ./php/tests so the locally-checked-out DesecManager is exercised.
docker run \
-d \
--init \
--name nextcloud-aio-mastercontainer \
--restart always \
--publish 8080:8080 \
--add-host host.docker.internal:host-gateway \
--volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
--volume ./php/tests:/var/www/docker-aio/php/tests \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--env SKIP_DOMAIN_VALIDATION=false \
--env APACHE_PORT=11000 \
ghcr.io/nextcloud-releases/all-in-one:develop
echo Waiting for 10 seconds for the development container to start ...
sleep 10
# Point the running interface at the deSEC mock via configuration.json (config-only,
# no env override). The container runs PHP and has ./php mounted, so use the helper.
# AIO_TEST_PASSWORD is forwarded so the helper can seed the master password too.
docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \
php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \
http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update
- name: Run Playwright tests for deSEC domain registration
run: |
export DEBUG=pw:api
export DESEC_MOCK_URL=http://localhost:8090
# Each deSEC scenario ends by registering a domain, which persists in
# configuration.json and would hide the registration UI for the next scenario.
# Run each spec as its own step and re-seed (reset the deSEC state) in between.
reseed() {
docker exec --env AIO_TEST_PASSWORD="$AIO_TEST_PASSWORD" nextcloud-aio-mastercontainer \
php /var/www/docker-aio/php/tests/seed-desec-mock-config.php \
http://host.docker.internal:8090/api/v1 http://host.docker.internal:8090/update
}
dump_logs() {
docker logs nextcloud-aio-mastercontainer
echo "--- deSEC mock log ---"
cat "$GITHUB_WORKSPACE/desec-mock.log" || true
}
cd php/tests
if ! npx playwright test tests/desec-register.spec.js; then dump_logs; exit 1; fi
reseed
if ! npx playwright test tests/desec-existing.spec.js; then dump_logs; exit 1; fi
kill "$(cat "$GITHUB_WORKSPACE/desec-mock.pid")" || true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
+124
View File
@@ -0,0 +1,124 @@
// Minimal mock of the deSEC API, implementing exactly the endpoints that
// php/src/Desec/DesecManager.php calls, with the same HTTP status-code semantics
// as the real service (see https://desec.readthedocs.io). It exists so the deSEC
// register -> verify -> domain flow can be exercised end-to-end in CI without
// touching the real deSEC service (which would create real accounts, be flaky and
// rate-limited, and require a human to click an email verification link).
//
// We do not probe deSEC's live API (their ToS discourages automated requests, and the
// /api/v1/ interface is contractually stable). Instead, the scheduled workflow
// .github/workflows/desec-api-version-watch.yml watches deSEC's published API-version
// table for changes; if it goes red, review the change and update this mock (plus
// DesecManager) to match.
//
// Usage: node desec-mock.mjs [port] (default port 8090)
// Routes:
// POST /api/v1/auth/ -> 202 (account requested; verify email)
// POST /api/v1/auth/login/ -> 403 until verified, then 200 + {token}
// POST /api/v1/domains/ -> 201 (new name) | 409 (name already taken)
// POST /api/v1/domains/{name}/rrsets/ -> 201 (wildcard CNAME)
// GET /update -> 200 "good" (dyndns update endpoint)
// POST /__control/verify -> test hook: mark accounts email-verified
// POST /__control/reset -> test hook: wipe in-memory state
import { createServer } from 'node:http'
const port = Number(process.argv[2] ?? process.env.DESEC_MOCK_PORT ?? 8090)
// In-memory state. Reset between independent test scenarios via /__control/reset.
const state = {
accounts: new Map(), // email -> { password }
domains: new Set(), // fully-qualified domain names that have been created
verified: false, // simulates whether the (most recent) account's email was verified
}
function readBody(req) {
return new Promise((resolve) => {
let data = ''
req.on('data', (chunk) => { data += chunk })
req.on('end', () => {
if (data === '') return resolve({})
try { resolve(JSON.parse(data)) } catch { resolve({}) }
})
})
}
function send(res, code, body) {
const payload = typeof body === 'string' ? body : JSON.stringify(body)
res.writeHead(code, { 'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json' })
res.end(payload)
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${port}`)
const path = url.pathname
const method = req.method ?? 'GET'
// --- Test control hooks (not part of the real deSEC API) ---
if (method === 'POST' && path === '/__control/verify') {
state.verified = true
return send(res, 200, { verified: true })
}
if (method === 'POST' && path === '/__control/reset') {
state.accounts.clear()
state.domains.clear()
state.verified = false
return send(res, 200, { reset: true })
}
// --- dyndns update endpoint (DesecManager::updateIpIfDesecDomain) ---
if (method === 'GET' && path === '/update') {
return send(res, 200, 'good')
}
// --- Account registration: deSEC always answers 202 and emails a link; ---
// --- it returns 202 even for an already-registered email (anti-enumeration). ---
if (method === 'POST' && path === '/api/v1/auth/') {
const body = await readBody(req)
if (body.email && !state.accounts.has(body.email)) {
state.accounts.set(body.email, { password: body.password ?? '' })
// A freshly created account starts unverified; the test flips this via /__control/verify.
state.verified = false
}
return send(res, 202, '')
}
// --- Login: 403 until the email is verified, then 200 + a token object. ---
if (method === 'POST' && path === '/api/v1/auth/login/') {
const body = await readBody(req)
const account = body.email ? state.accounts.get(body.email) : undefined
const passwordMatches = account && account.password === body.password
if (!state.verified || !passwordMatches) {
return send(res, 403, { detail: 'Invalid email or password, or email not verified.' })
}
return send(res, 200, {
token: 'mock-token-' + Buffer.from(body.email).toString('hex').slice(0, 16),
id: 'mock-token-id',
created: '2024-01-01T00:00:00.000000Z',
max_age: '7 00:00:00',
})
}
// --- Domain creation: 201 for a new name, 409 if the same name already exists. ---
if (method === 'POST' && path === '/api/v1/domains/') {
const body = await readBody(req)
const name = String(body.name ?? '')
if (state.domains.has(name)) {
return send(res, 409, { detail: 'This domain name is unavailable.' })
}
state.domains.add(name)
return send(res, 201, { name })
}
// --- RRset creation (wildcard CNAME): 201. ---
if (method === 'POST' && /^\/api\/v1\/domains\/[^/]+\/rrsets\/$/.test(path)) {
const body = await readBody(req)
return send(res, 201, { subname: body.subname ?? '', type: body.type ?? '', records: body.records ?? [] })
}
return send(res, 404, { detail: 'Not found in mock: ' + method + ' ' + path })
})
server.listen(port, '0.0.0.0', () => {
console.log(`deSEC mock listening on http://0.0.0.0:${port}`)
})
+54
View File
@@ -0,0 +1,54 @@
<?php
// Test helper: point the running mastercontainer's deSEC integration at a local mock by
// writing the (config-only) desec_api_base / desec_update_url keys into configuration.json.
// Run inside the mastercontainer via `docker exec` from the Playwright CI workflows; there
// is no env override for these on purpose (see ConfigurationManager::$desecApiBase).
//
// Writing configuration.json makes Setup::CanBeInstalled() return false, so /setup no longer
// renders the initial-password page and no master password is generated. To keep the deSEC
// Playwright test able to log in, this helper also seeds a known master password (passed via
// the AIO_TEST_PASSWORD env var) that the test then uses directly. The password is stored in
// plaintext, which matches how AIO compares it (AuthManager::CheckCredentials uses
// hash_equals against the plaintext config value).
//
// Re-running this helper also RESETS the deSEC-specific state (domain, desec_email and the
// DESEC_TOKEN / DESEC_PASSWORD secrets) so that a deSEC flow which already registered a domain
// in a previous test does not bleed into the next test (where the registration UI only renders
// while no domain is set). The Playwright deSEC scenarios are therefore each run as a separate
// CI step with a re-seed in between (see the Playwright workflows). Other config and secrets
// are preserved.
//
// Usage: AIO_TEST_PASSWORD=... php seed-desec-mock-config.php <api_base> <update_url>
declare(strict_types=1);
$apiBase = $argv[1] ?? '';
$updateUrl = $argv[2] ?? '';
if ($apiBase === '' || $updateUrl === '') {
fwrite(STDERR, "usage: php seed-desec-mock-config.php <api_base> <update_url>\n");
exit(1);
}
$password = getenv('AIO_TEST_PASSWORD');
if ($password === false || $password === '') {
fwrite(STDERR, "AIO_TEST_PASSWORD env var must be set to the master password to seed\n");
exit(1);
}
$file = '/mnt/docker-aio-config/data/configuration.json';
$config = is_file($file) ? json_decode((string)file_get_contents($file), true) : [];
if (!is_array($config)) {
$config = [];
}
$config['desec_api_base'] = $apiBase;
$config['desec_update_url'] = $updateUrl;
$config['password'] = $password;
// Reset any deSEC state from a previous test run so the registration UI renders again.
unset($config['domain'], $config['desec_email']);
if (isset($config['secrets']) && is_array($config['secrets'])) {
unset($config['secrets']['DESEC_TOKEN'], $config['secrets']['DESEC_PASSWORD']);
}
file_put_contents($file, json_encode($config, JSON_PRETTY_PRINT));
echo "Seeded deSEC mock config into $file\n";
+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);
});