diff --git a/.github/workflows/playwright-on-push.yml b/.github/workflows/playwright-on-push.yml index 755cfb8d..ca120bc5 100644 --- a/.github/workflows/playwright-on-push.yml +++ b/.github/workflows/playwright-on-push.yml @@ -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: diff --git a/.github/workflows/playwright-on-workflow-dispatch.yml b/.github/workflows/playwright-on-workflow-dispatch.yml index 83880462..afad1c98 100644 --- a/.github/workflows/playwright-on-workflow-dispatch.yml +++ b/.github/workflows/playwright-on-workflow-dispatch.yml @@ -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: diff --git a/php/tests/desec-mock.mjs b/php/tests/desec-mock.mjs new file mode 100644 index 00000000..fb5267a4 --- /dev/null +++ b/php/tests/desec-mock.mjs @@ -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}`) +}) diff --git a/php/tests/seed-desec-mock-config.php b/php/tests/seed-desec-mock-config.php new file mode 100644 index 00000000..78d27de0 --- /dev/null +++ b/php/tests/seed-desec-mock-config.php @@ -0,0 +1,54 @@ + + +declare(strict_types=1); + +$apiBase = $argv[1] ?? ''; +$updateUrl = $argv[2] ?? ''; +if ($apiBase === '' || $updateUrl === '') { + fwrite(STDERR, "usage: php seed-desec-mock-config.php \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"; diff --git a/php/tests/tests/desec-existing.spec.js b/php/tests/tests/desec-existing.spec.js new file mode 100644 index 00000000..22a05075 --- /dev/null +++ b/php/tests/tests/desec-existing.spec.js @@ -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); +}); diff --git a/php/tests/tests/desec-helpers.js b/php/tests/tests/desec-helpers.js new file mode 100644 index 00000000..9332b308 --- /dev/null +++ b/php/tests/tests/desec-helpers.js @@ -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; +} diff --git a/php/tests/tests/desec-register.spec.js b/php/tests/tests/desec-register.spec.js new file mode 100644 index 00000000..a9d760a7 --- /dev/null +++ b/php/tests/tests/desec-register.spec.js @@ -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
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
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); +});