From bf1a9144d36e786d2fc3c22ac5beeb69d1fdde68 Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:40:13 -0700 Subject: [PATCH 01/15] test(e2e): faster API login, leaked-stack cleanup, mirrored helper images, template image check off the per-push suite --- .github/workflows/ci.yml | 4 +- .github/workflows/templates.yml | 34 +++++++-- e2e/fixtures/helper-images.yml | 9 +++ e2e/global-teardown.js | 41 ++++++++++- e2e/helpers/auth.js | 16 ++++- e2e/helpers/dbclients.js | 12 ---- e2e/helpers/docker.js | 8 --- e2e/helpers/images.js | 14 ++++ e2e/helpers/minio.js | 3 +- e2e/helpers/proxy.js | 21 ++++++ e2e/helpers/registry.js | 21 +++--- e2e/playwright.config.js | 1 - e2e/scripts/check-template-images.mjs | 86 ++++++++++++++++++++++ e2e/tests/00-template-images.spec.js | 100 -------------------------- e2e/tests/02-login.spec.js | 58 +++++++-------- 15 files changed, 258 insertions(+), 170 deletions(-) create mode 100644 e2e/fixtures/helper-images.yml create mode 100644 e2e/helpers/images.js create mode 100644 e2e/scripts/check-template-images.mjs delete mode 100644 e2e/tests/00-template-images.spec.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eaf1859..dda24daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,5 +151,7 @@ jobs: if: always() with: name: playwright-report - path: e2e/playwright-report/ + path: | + e2e/playwright-report/ + e2e/test-results/ retention-days: 14 diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index 93ac2bc8..2bc7b956 100644 --- a/.github/workflows/templates.yml +++ b/.github/workflows/templates.yml @@ -1,26 +1,33 @@ name: Templates -# Validate every app template by deploying it end-to-end. Triggered only -# when template definitions change, so it runs once per template-edit PR -# instead of on every push. Also exposed as a manual workflow_dispatch. +# Validate every app template by deploying it end-to-end, and check that +# every template image still resolves. Triggered when template definitions +# or the code that parses/deploys them change, weekly (upstream tags get +# yanked; the GHCR mirror used by per-push E2E hides that), and manually. on: pull_request: paths: - 'ui/src/lib/appTemplates.js' - 'ui/src/lib/serviceTemplates.js' - 'e2e/tests/templates-deploy-all.spec.js' - - 'e2e/tests/00-template-images.spec.js' + - 'e2e/scripts/check-template-images.mjs' - 'e2e/helpers/template-probes.js' - - 'internal/compose/inject.go' + - 'internal/compose/**' + - 'internal/deployer/**' + - 'go.mod' push: branches: [main] paths: - 'ui/src/lib/appTemplates.js' - 'ui/src/lib/serviceTemplates.js' - 'e2e/tests/templates-deploy-all.spec.js' - - 'e2e/tests/00-template-images.spec.js' + - 'e2e/scripts/check-template-images.mjs' - 'e2e/helpers/template-probes.js' - - 'internal/compose/inject.go' + - 'internal/compose/**' + - 'internal/deployer/**' + - 'go.mod' + schedule: + - cron: '17 4 * * 1' workflow_dispatch: permissions: @@ -31,6 +38,19 @@ concurrency: cancel-in-progress: true jobs: + template-images: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Every template image resolves + run: node e2e/scripts/check-template-images.mjs + deploy-all: # Shard the ~20-template matrix across parallel runners. Each shard # builds its own binary, runs its own server, and pre-pulls only the diff --git a/e2e/fixtures/helper-images.yml b/e2e/fixtures/helper-images.yml new file mode 100644 index 00000000..f5cd4917 --- /dev/null +++ b/e2e/fixtures/helper-images.yml @@ -0,0 +1,9 @@ +# Not deployed. Lists images that e2e helpers run directly (helpers/minio.js, +# helpers/registry.js) so scripts/list-mirror-images.mjs mirrors them to GHCR. +services: + minio: + image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + registry: + image: registry:2 + registry-push-source: + image: nginx:alpine diff --git a/e2e/global-teardown.js b/e2e/global-teardown.js index 230a4ba5..538c2423 100644 --- a/e2e/global-teardown.js +++ b/e2e/global-teardown.js @@ -1,7 +1,38 @@ -import { readFileSync, rmSync, existsSync } from 'fs'; +import { readFileSync, rmSync, existsSync, mkdirSync, copyFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { tmpdir } from 'os'; import { join } from 'path'; const STATE_FILE = join(import.meta.dirname, '.e2e-state.json'); +const LOG_DIR = join(import.meta.dirname, 'test-results'); + +// Compose projects deployed by e2e servers (shared and isolated) live under +// temp dirs named sd-e2e-*. Only those are removed, never other stacks on +// the machine. Specs clean up after themselves; this catches what a failed +// spec leaked. +function removeLeakedStacks() { + let out; + try { + out = execFileSync('docker', [ + 'ps', '-a', '--filter', 'label=com.docker.compose.project', + '--format', '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}', + ], { encoding: 'utf-8' }); + } catch { + return; + } + const prefix = join(tmpdir(), 'sd-e2e-'); + const projects = new Set(); + for (const line of out.split('\n')) { + const [project, dir] = line.split('\t'); + if (project && dir && (dir.startsWith(prefix) || dir.startsWith(`/private${prefix}`))) projects.add(project); + } + for (const p of projects) { + try { + execFileSync('docker', ['compose', '-p', p, 'down', '-v', '--remove-orphans'], { stdio: 'ignore', timeout: 60_000 }); + console.log(`[e2e] Removed leaked stack ${p}`); + } catch {} + } +} export default async function globalTeardown() { if (!existsSync(STATE_FILE)) return; @@ -14,6 +45,14 @@ export default async function globalTeardown() { try { process.kill(state.pid, 'SIGKILL'); } catch {} } catch {} + // Keep the server log next to the failure traces (CI uploads test-results/). + try { + mkdirSync(LOG_DIR, { recursive: true }); + copyFileSync(state.logPath, join(LOG_DIR, 'server.log')); + } catch {} + + removeLeakedStacks(); + if (!process.env.SIMPLEDEPLOY_KEEP) { for (const dir of [state.dataDir, state.appsDir]) { try { rmSync(dir, { recursive: true, force: true }); } catch {} diff --git a/e2e/helpers/auth.js b/e2e/helpers/auth.js index 63fcd742..d14669a6 100644 --- a/e2e/helpers/auth.js +++ b/e2e/helpers/auth.js @@ -14,7 +14,9 @@ export const TEST_ADMIN = { email: 'e2e@test.local', }; -export async function login(page, username, password) { +// loginViaUI signs in through the login form. Use it where the form itself +// is under test; everything else uses login(), which is faster. +export async function loginViaUI(page, username, password) { const state = getState(); await page.goto(`${state.baseURL}/#/login`); // Wait for login form to finish loading (setupStatus check) @@ -28,6 +30,18 @@ export async function login(page, username, password) { await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); } +// login signs in through the API (page.request shares the browser context's +// cookie jar), then opens the dashboard. +export async function login(page, username, password) { + const state = getState(); + const res = await page.request.post(`${state.baseURL}/api/auth/login`, { + data: { username: username || TEST_ADMIN.username, password: password || TEST_ADMIN.password }, + }); + if (!res.ok()) throw new Error(`login failed: ${res.status()} ${await res.text()}`); + await page.goto(`${state.baseURL}/#/`); + await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); +} + export async function loginAsAdmin(page) { await login(page, TEST_ADMIN.username, TEST_ADMIN.password); } diff --git a/e2e/helpers/dbclients.js b/e2e/helpers/dbclients.js index a775a3f3..eb306aec 100644 --- a/e2e/helpers/dbclients.js +++ b/e2e/helpers/dbclients.js @@ -3,16 +3,6 @@ // matching the pattern used by psql() in helpers/docker.js to avoid quoting hazards. import { execFileSync } from 'child_process'; -import { appendFileSync } from 'fs'; - -function trace(tag, container, payload, outOrErr) { - try { - appendFileSync( - '/tmp/e2e-dbclients-trace.log', - `[${tag} ${new Date().toISOString()}] container=${container}\nCMD: ${payload}\nOUT: ${JSON.stringify(outOrErr)}\n---\n`, - ); - } catch {} -} function runArgv(container, argv, stdinBuf) { const opts = { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }; @@ -22,12 +12,10 @@ function runArgv(container, argv, stdinBuf) { } try { const out = execFileSync('docker', argv, opts); - trace('OK', container, argv.join(' '), out); return out.trim(); } catch (e) { const stderr = e.stderr ? e.stderr.toString() : ''; const stdout = e.stdout ? e.stdout.toString() : ''; - trace('ERR', container, argv.join(' '), { status: e.status, stdout, stderr }); throw new Error( `docker exec failed container=${container} exitCode=${e.status}\nARGV: ${argv.join(' ')}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, ); diff --git a/e2e/helpers/docker.js b/e2e/helpers/docker.js index 30957237..e27af01a 100644 --- a/e2e/helpers/docker.js +++ b/e2e/helpers/docker.js @@ -68,8 +68,6 @@ export function countServiceReplicas(appSlug, serviceName) { ]).length; } -import { appendFileSync } from 'fs'; - export function psql(containerName, user, db, sql) { try { const out = execFileSync( @@ -77,16 +75,10 @@ export function psql(containerName, user, db, sql) { ['exec', containerName, 'psql', '-U', user, '-d', db, '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-c', sql], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, ); - try { - appendFileSync('/tmp/e2e-psql-trace.log', `[OK ${new Date().toISOString()}] container=${containerName}\nSQL: ${sql}\nOUT: ${JSON.stringify(out)}\n---\n`); - } catch {} return out.trim(); } catch (e) { const stderr = e.stderr ? e.stderr.toString() : ''; const stdout = e.stdout ? e.stdout.toString() : ''; - try { - appendFileSync('/tmp/e2e-psql-trace.log', `[ERR ${new Date().toISOString()}] container=${containerName} exitCode=${e.status}\nSQL: ${sql}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}\n---\n`); - } catch {} throw new Error( `psql failed container=${containerName} exitCode=${e.status} signal=${e.signal}\nSQL: ${sql}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, ); diff --git a/e2e/helpers/images.js b/e2e/helpers/images.js new file mode 100644 index 00000000..ebf1a1ce --- /dev/null +++ b/e2e/helpers/images.js @@ -0,0 +1,14 @@ +// Image refs for containers the tests start directly (outside SimpleDeploy, +// which rewrites its own deploys). With E2E_USE_MIRROR=1 (CI), Docker Hub +// refs go through the GHCR mirror, same naming as internal/mirror. Every +// ref used here must be listed in fixtures/helper-images.yml so the mirror +// workflow copies it. +export function mirrored(ref) { + if (process.env.E2E_USE_MIRROR !== '1') return ref; + let prefix = process.env.SIMPLEDEPLOY_IMAGE_MIRROR_PREFIX || 'ghcr.io/vazra/simpledeploy-mirror/'; + if (!prefix.endsWith('/')) prefix += '/'; + const first = ref.split('/')[0]; + const hasHost = ref.includes('/') && (first.includes('.') || first.includes(':') || first === 'localhost'); + if (hasHost && first !== 'docker.io') return ref; + return prefix + ref.replace(/^docker\.io\//, '').replace(/^library\//, ''); +} diff --git a/e2e/helpers/minio.js b/e2e/helpers/minio.js index b368994f..c0d30c64 100644 --- a/e2e/helpers/minio.js +++ b/e2e/helpers/minio.js @@ -1,5 +1,6 @@ import { execFileSync, spawnSync } from 'child_process'; import net from 'net'; +import { mirrored } from './images.js'; // MinIO fixture helper. Starts a MinIO-compatible server in a docker container // on a random host port, waits for health, creates a bucket, and exposes @@ -10,7 +11,7 @@ import net from 'net'; // and minio/mc from Docker Hub and quay.io/minio/* now requires login. Silo // keeps the MINIO_* env vars, ports, /minio/health/* routes and ships `mc`. // Same pinned tag as the MinIO app/service templates. -const MINIO_IMAGE = 'pgsty/silo:RELEASE.2026-09-16T00-00-00Z'; +const MINIO_IMAGE = mirrored('pgsty/silo:RELEASE.2026-09-16T00-00-00Z'); export const MINIO_ROOT_USER = 'minioadmin'; export const MINIO_ROOT_PASSWORD = 'minioadmin123'; diff --git a/e2e/helpers/proxy.js b/e2e/helpers/proxy.js index 0b3ef804..0cb87489 100644 --- a/e2e/helpers/proxy.js +++ b/e2e/helpers/proxy.js @@ -36,6 +36,27 @@ export async function fetchViaProxy(hostHeader, path = '/', opts = {}) { }; } +// waitForProxyStatus polls host through the proxy until it answers +// wantStatus (and, if given, a body containing bodyContains). opts is passed +// to fetchViaProxy (e.g. proxyURL for an isolated server). +export async function waitForProxyStatus(host, wantStatus, { timeoutMs = 15_000, bodyContains = null, path = '/', ...opts } = {}) { + const deadline = Date.now() + timeoutMs; + let last = 0; + let lastBody = ''; + while (Date.now() < deadline) { + const r = await fetchViaProxy(host, path, opts); + last = r.status; + const body = await r.text(); + lastBody = body.slice(0, 80); + if (r.status === wantStatus && (!bodyContains || body.includes(bodyContains))) { + return { ...r, text: async () => body }; + } + await new Promise((res) => setTimeout(res, 300)); + } + const hint = bodyContains ? ` body~${JSON.stringify(bodyContains)}` : ''; + throw new Error(`host ${host} did not reach status ${wantStatus}${hint} within ${timeoutMs}ms (last=${last}, lastBody=${lastBody})`); +} + export function curlViaProxy(hostHeader, path = '/', opts = {}) { const state = getState(); const base = opts.proxyURL || state.proxyURL; diff --git a/e2e/helpers/registry.js b/e2e/helpers/registry.js index e31cc129..48fbee74 100644 --- a/e2e/helpers/registry.js +++ b/e2e/helpers/registry.js @@ -10,6 +10,7 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import net from 'net'; +import { mirrored } from './images.js'; function getAvailablePort() { return new Promise((resolve, reject) => { @@ -50,12 +51,11 @@ export async function startRegistry(opts = {}) { const authDir = join(tmp, 'auth'); execFileSync('mkdir', ['-p', authDir]); - // Generate htpasswd entry via httpd image (avoids requiring htpasswd on host). - // httpd's htpasswd -Bbn prints to stdout. - const htpasswdLine = docker([ - 'run', '--rm', '--entrypoint', 'htpasswd', 'httpd:2', - '-Bbn', user, pass, - ]).trim(); + // Precomputed bcrypt entry (`htpasswd -Bbn testuser testpass123`) for the + // default creds avoids pulling an image just to hash a password. + const htpasswdLine = user === 'testuser' && pass === 'testpass123' + ? 'testuser:$2y$05$EZbMr1D2P.keiaO2HXX1I.QXSVo2ty1OaInfvxJJpVV/exwzpR5CW' + : docker(['run', '--rm', '--entrypoint', 'htpasswd', 'httpd:2', '-Bbn', user, pass]).trim(); writeFileSync(join(authDir, 'htpasswd'), htpasswdLine + '\n'); // Run registry:2 with htpasswd auth. @@ -67,7 +67,7 @@ export async function startRegistry(opts = {}) { '-e', 'REGISTRY_AUTH=htpasswd', '-e', 'REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm', '-e', 'REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd', - 'registry:2', + mirrored('registry:2'), ]); const host = `localhost:${port}`; @@ -100,10 +100,11 @@ export async function startRegistry(opts = {}) { // logs in, pushes, then logs out. Returns the full remote image ref. export function pushImage(reg, fromImage, toName) { const target = `${reg.host}/${toName}`; - try { docker(['pull', fromImage]); } catch (e) { - throw new Error(`pull ${fromImage} failed: ${e.message}`); + const source = mirrored(fromImage); + try { docker(['pull', source]); } catch (e) { + throw new Error(`pull ${source} failed: ${e.message}`); } - docker(['tag', fromImage, target]); + docker(['tag', source, target]); try { dockerStdin( ['login', '-u', reg.user, '--password-stdin', reg.host], diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 2e62008b..053f8fa5 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -49,7 +49,6 @@ export default defineConfig({ baseURL: `http://localhost:${process.env.SIMPLEDEPLOY_PORT || 8500}`, trace: 'retain-on-failure', screenshot: 'only-on-failure', - video: 'retain-on-failure', }, projects: [ { diff --git a/e2e/scripts/check-template-images.mjs b/e2e/scripts/check-template-images.mjs new file mode 100644 index 00000000..ebad2aed --- /dev/null +++ b/e2e/scripts/check-template-images.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Checks that every image referenced by an app template or service template +// resolves in its registry (`docker manifest inspect`, no layer pulls). +// Catches typos and yanked tags. Run by the Templates workflow on template +// changes and weekly; not part of the per-push E2E suite. +// +// node e2e/scripts/check-template-images.mjs + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { appTemplates } from '../../ui/src/lib/appTemplates.js'; +import { serviceTemplates } from '../../ui/src/lib/serviceTemplates.js'; + +const execFileP = promisify(execFile); +const CONCURRENCY = 4; +const ATTEMPTS = 4; +// Registry or network hiccups, not a verdict on the image. +const RATE_LIMIT_RE = /toomanyrequests|rate limit/i; +const TRANSIENT_RE = /toomanyrequests|rate limit|timeout|timed out|TLS handshake|connection reset|EOF|50[0-9] |Service Unavailable|Bad Gateway/i; + +function collectImages(node, out) { + if (node == null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const item of node) collectImages(item, out); + return; + } + for (const [k, v] of Object.entries(node)) { + if (k === 'image' && typeof v === 'string') out.add(v); + else collectImages(v, out); + } +} + +function allTemplateImages() { + const images = new Set(); + for (const tpl of appTemplates) collectImages(tpl.compose, images); + for (const svc of serviceTemplates) { + if (typeof svc?.config?.image === 'string') images.add(svc.config.image); + } + images.delete(''); + return [...images].sort(); +} + +async function inspect(img) { + let last = ''; + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + await execFileP('docker', ['manifest', 'inspect', img], { timeout: 60_000 }); + return null; + } catch (err) { + // A killed process (our timeout) has no stderr. + last = err?.killed ? 'timeout' : String(err?.stderr || err?.message || err).trim(); + if (!TRANSIENT_RE.test(last)) return last; + await new Promise((r) => setTimeout(r, 3_000 * attempt)); + } + } + return `transient errors on every attempt: ${last}`; +} + +const images = allTemplateImages(); +if (images.length === 0) { + console.error('no template images found'); + process.exit(1); +} +const failures = []; +let next = 0; +await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < images.length) { + const img = images[next++]; + const err = await inspect(img); + if (err) failures.push({ img, err }); + } + }), +); +const real = failures.filter((f) => !RATE_LIMIT_RE.test(f.err)); +if (real.length > 0) { + const lines = real.map((f) => ` - ${f.img}: ${f.err}`).sort().join('\n'); + console.error(`Unresolvable template images (${real.length}/${images.length}):\n${lines}`); + process.exit(1); +} +if (failures.length > 0) { + // Registry throttling says nothing about the images; flag it, don't fail. + console.log(`::warning::${failures.length} template image(s) not checked: registry rate limit`); + process.exit(0); +} +console.log(`All ${images.length} template images resolve.`); diff --git a/e2e/tests/00-template-images.spec.js b/e2e/tests/00-template-images.spec.js deleted file mode 100644 index e66d2a7a..00000000 --- a/e2e/tests/00-template-images.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -// Fast sanity check: every image referenced by an app template or service -// template resolves in its registry via `docker manifest inspect`. Catches -// typos and yanked tags without actually pulling layers. Runs in both -// e2e-lite and the full suite. - -import { test, expect } from '@playwright/test'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { appTemplates } from '../../ui/src/lib/appTemplates.js'; -import { serviceTemplates } from '../../ui/src/lib/serviceTemplates.js'; - -const execFileP = promisify(execFile); - -function collectImages(node, out) { - if (node == null) return; - if (typeof node !== 'object') return; - if (Array.isArray(node)) { - for (const item of node) collectImages(item, out); - return; - } - for (const [k, v] of Object.entries(node)) { - if (k === 'image' && typeof v === 'string') out.add(v); - else collectImages(v, out); - } -} - -function allTemplateImages() { - const images = new Set(); - for (const tpl of appTemplates) collectImages(tpl.compose, images); - for (const svc of serviceTemplates) { - if (typeof svc?.config?.image === 'string') images.add(svc.config.image); - } - images.delete(''); - return [...images].sort(); -} - -const CONCURRENCY = 4; -const RATE_LIMIT_RE = /toomanyrequests|rate limit/i; - -async function inspectOnce(img) { - await execFileP('docker', ['manifest', 'inspect', img], { timeout: 30_000 }); -} - -async function inspectWithRetry(img) { - // Up to 3 attempts with backoff; retry on rate-limit responses so a - // transient Docker Hub throttle does not fail the whole suite. - for (let attempt = 1; attempt <= 3; attempt++) { - try { - await inspectOnce(img); - return { img, ok: true }; - } catch (err) { - const msg = String(err?.stderr || err?.message || err); - if (attempt < 3 && RATE_LIMIT_RE.test(msg)) { - await new Promise((r) => setTimeout(r, 2_000 * attempt)); - continue; - } - return { img, ok: false, err: msg }; - } - } - return { img, ok: false, err: 'exhausted retries' }; -} - -async function mapPool(items, limit, fn) { - const results = new Array(items.length); - let next = 0; - const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (true) { - const i = next++; - if (i >= items.length) return; - results[i] = await fn(items[i]); - } - }); - await Promise.all(workers); - return results; -} - -test.describe('Template image manifests', () => { - test('every template image resolves via docker manifest inspect', async () => { - test.setTimeout(300_000); - const images = allTemplateImages(); - expect(images.length, 'no template images found').toBeGreaterThan(0); - - const results = await mapPool(images, CONCURRENCY, inspectWithRetry); - - const failures = results.filter((r) => !r.ok); - // If every failure is a Docker Hub rate-limit, don't fail the test - // (environmental) - just log. Real bad tags still surface. - const nonRateLimit = failures.filter((f) => !RATE_LIMIT_RE.test(f.err)); - if (failures.length > 0 && nonRateLimit.length === 0) { - console.warn( - `[template-images] skipping ${failures.length} image(s) due to Docker Hub rate limits`, - ); - return; - } - if (nonRateLimit.length > 0) { - const msg = nonRateLimit.map((f) => ` - ${f.img}: ${f.err.trim()}`).join('\n'); - throw new Error(`Unresolvable template images:\n${msg}`); - } - }); -}); diff --git a/e2e/tests/02-login.spec.js b/e2e/tests/02-login.spec.js index 5dbf3b36..57343ba4 100644 --- a/e2e/tests/02-login.spec.js +++ b/e2e/tests/02-login.spec.js @@ -1,48 +1,50 @@ import { test, expect } from '@playwright/test'; -import { getState, TEST_ADMIN, login, loginAsAdmin, logout } from '../helpers/auth.js'; +import { getState, TEST_ADMIN, loginViaUI, logout } from '../helpers/auth.js'; +// Other specs sign in through the API (helpers/auth.js login); the login +// form itself is only exercised here. test.describe('Authentication', () => { - test('login with correct credentials', async ({ page }) => { - await loginAsAdmin(page); - // Sidebar should be visible after login - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible(); + test('login form signs in as the admin', async ({ page }) => { + await loginViaUI(page); + const me = await page.request.get(`${getState().baseURL}/api/me`); + expect(me.status()).toBe(200); + const body = await me.json(); + expect(body.username).toBe(TEST_ADMIN.username); + expect(body.role).toBe('super_admin'); }); - test('login with wrong password shows error', async ({ page }) => { + test('wrong password is rejected with 401 and stays on login', async ({ page }) => { const state = getState(); await page.goto(`${state.baseURL}/#/login`); await page.locator('#username').fill(TEST_ADMIN.username); await page.locator('#password').fill('WrongPassword123!'); + const resP = page.waitForResponse('**/api/auth/login'); await page.getByRole('button', { name: 'Sign In' }).click(); - await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible({ timeout: 5_000 }); - }); - - test('login with nonexistent user shows error', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill('nonexistent'); - await page.locator('#password').fill('SomePassword123!'); - await page.getByRole('button', { name: 'Sign In' }).click(); - await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible({ timeout: 5_000 }); - }); - - test('logout and redirect to login', async ({ page }) => { - await loginAsAdmin(page); - await logout(page); + expect((await resP).status()).toBe(401); + await expect(page.getByText(/invalid credentials/i)).toBeVisible(); await expect(page).toHaveURL(/login/); + await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toHaveCount(0); }); test('accessing protected route without session redirects to login', async ({ page }) => { - const state = getState(); - await page.context().clearCookies(); - await page.goto(`${state.baseURL}/#/users`); + await page.goto(`${getState().baseURL}/#/users`); await expect(page).toHaveURL(/login/, { timeout: 5_000 }); }); - test('re-login after logout works', async ({ page }) => { - await loginAsAdmin(page); + test('logout revokes the session server-side; logging in again works', async ({ page }) => { + const state = getState(); + await loginViaUI(page); + const old = (await page.context().cookies()).find((c) => c.name === 'session'); + expect(old, 'session cookie after login').toBeTruthy(); + await logout(page); - await loginAsAdmin(page); - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible(); + await expect(page).toHaveURL(/login/); + + // The old cookie must be dead even if a client kept it. + const stale = await fetch(`${state.baseURL}/api/me`, { headers: { Cookie: `session=${old.value}` } }); + expect(stale.status).toBe(401); + + await loginViaUI(page); + expect((await page.request.get(`${state.baseURL}/api/me`)).status()).toBe(200); }); }); From 0d8bb2e3380ee9fe3f9a09c7d5ef2310b4d21947 Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:04:36 -0700 Subject: [PATCH 02/15] test(e2e): overhaul specs 01-11 to assert server state, drop duplicate tests --- e2e/helpers/api.js | 15 ++ e2e/helpers/recipes.js | 62 +++++ e2e/helpers/server.js | 5 +- e2e/tests/01-setup.spec.js | 62 ++--- e2e/tests/03-deploy.spec.js | 211 +++++++---------- e2e/tests/03b-deploy-templates.spec.js | 187 --------------- e2e/tests/03c-community-recipes.spec.js | 82 ++++--- e2e/tests/04-dashboard.spec.js | 60 ++--- e2e/tests/05-app-overview.spec.js | 47 ---- e2e/tests/06-app-actions.spec.js | 295 ++++++++---------------- e2e/tests/07-app-config.spec.js | 47 ++-- e2e/tests/08-app-endpoints.spec.js | 132 ++++------- e2e/tests/09-app-logs.spec.js | 50 ++-- e2e/tests/10-app-metrics.spec.js | 99 ++------ e2e/tests/11-app-versions.spec.js | 146 +++--------- 15 files changed, 514 insertions(+), 986 deletions(-) create mode 100644 e2e/helpers/recipes.js delete mode 100644 e2e/tests/03b-deploy-templates.spec.js delete mode 100644 e2e/tests/05-app-overview.spec.js diff --git a/e2e/helpers/api.js b/e2e/helpers/api.js index c1d9aa98..a6ad3e42 100644 --- a/e2e/helpers/api.js +++ b/e2e/helpers/api.js @@ -119,3 +119,18 @@ export async function apiRequestAt(baseURL, method, path, body, sessionCookie) { try { data = JSON.parse(text); } catch { data = text; } return { status: res.status, data, ok: res.ok, setCookie }; } + +// waitForDeployDone polls until no deploy, restart or pull is in flight for +// slug and returns the app. Call it once the deploy has visibly started: +// unlike waitForAppStatus it cannot pass on the pre-deploy status. +export async function waitForDeployDone(slug, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + const res = await apiRequest('GET', `/api/apps/${slug}`); + last = res.data; + if (res.ok && res.data && res.data.deploying === false) return res.data; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`App ${slug} still deploying after ${timeoutMs}ms (last: ${JSON.stringify(last)})`); +} diff --git a/e2e/helpers/recipes.js b/e2e/helpers/recipes.js new file mode 100644 index 00000000..f58a5103 --- /dev/null +++ b/e2e/helpers/recipes.js @@ -0,0 +1,62 @@ +import { createServer } from 'http'; + +// One-recipe community catalog served from 127.0.0.1, so specs do not depend +// on the live catalog. Pass indexURL as startServer's recipesIndexURL. +export const FIXTURE_RECIPE = { + id: 'e2e-static-site', + name: 'E2E Static Site', + description: 'Fixture recipe served by the E2E suite.', + compose: [ + 'services:', + ' web:', + ' image: nginx:alpine', + ' labels:', + ' simpledeploy.endpoints.0.domain: "recipe-e2e.local"', + ' simpledeploy.endpoints.0.port: "80"', + ' simpledeploy.endpoints.0.tls: "off"', + '', + ].join('\n'), + readme: '# E2E Static Site\n\nFixture README marker e2e-readme-7f3a.\n', +}; + +export async function startRecipesCatalog() { + let base = ''; + const r = FIXTURE_RECIPE; + const files = () => ({ + '/index.json': ['application/json', JSON.stringify({ + schema_version: 1, + generated_at: new Date().toISOString(), + recipes: [{ + id: r.id, + name: r.name, + category: 'web', + description: r.description, + tags: ['e2e'], + author: 'e2e', + compose_url: `${base}/recipes/${r.id}/compose.yml`, + readme_url: `${base}/recipes/${r.id}/README.md`, + schema_version: 1, + }], + })], + [`/recipes/${r.id}/compose.yml`]: ['text/plain', r.compose], + [`/recipes/${r.id}/README.md`]: ['text/plain', r.readme], + }); + + const server = createServer((req, res) => { + const f = files()[req.url]; + if (!f) { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': f[0] }); + res.end(f[1]); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + return { + indexURL: `${base}/index.json`, + stop: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/e2e/helpers/server.js b/e2e/helpers/server.js index 9d8bed39..99d1a9d3 100644 --- a/e2e/helpers/server.js +++ b/e2e/helpers/server.js @@ -71,7 +71,10 @@ export async function startServer(binPath, overrides = {}) { ` window: "60s"`, ].join('\n'); - writeFileSync(configPath, config); + // recipesIndexURL points the community catalog at a local fixture + // (helpers/recipes.js) instead of the live one. + const recipesLine = overrides.recipesIndexURL ? `\nrecipes_index_url: "${overrides.recipesIndexURL}"` : ''; + writeFileSync(configPath, config + recipesLine); console.log(`[e2e] Starting server on port ${port}...`); console.log(`[e2e] Data dir: ${dataDir}`); diff --git a/e2e/tests/01-setup.spec.js b/e2e/tests/01-setup.spec.js index 67f665a0..520360d0 100644 --- a/e2e/tests/01-setup.spec.js +++ b/e2e/tests/01-setup.spec.js @@ -1,58 +1,34 @@ import { test, expect } from '@playwright/test'; import { getState, TEST_ADMIN } from '../helpers/auth.js'; +// Creates the admin the rest of the suite uses. Setup input validation is a +// unit concern (ui Login.test.js, internal/api/auth_test.go). test.describe('Initial Setup', () => { - test('redirects to login when no users exist', async ({ page }) => { + test('creates the first admin account through the setup form', async ({ page }) => { const state = getState(); - await page.goto(state.baseURL); - await expect(page).toHaveURL(/login/); - }); - - test('shows setup mode on login page', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects empty username', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#password').fill(TEST_ADMIN.password); - await page.locator('#confirmPassword').fill(TEST_ADMIN.password); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects short password', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill(TEST_ADMIN.username); - await page.locator('#password').fill('short'); - await page.locator('#confirmPassword').fill('short'); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); - }); - - test('rejects mismatched passwords', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/login`); - await page.locator('#username').fill(TEST_ADMIN.username); - await page.locator('#password').fill(TEST_ADMIN.password); - await page.locator('#confirmPassword').fill('DifferentPass123!'); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByText(/match/i)).toBeVisible(); - }); + const before = await page.request.get(`${state.baseURL}/api/setup/status`); + expect(await before.json()).toEqual({ needs_setup: true }); - test('creates admin account successfully', async ({ page }) => { - const state = getState(); await page.goto(`${state.baseURL}/#/login`); await page.locator('#displayName').fill(TEST_ADMIN.displayName); await page.locator('#email').fill(TEST_ADMIN.email); await page.locator('#username').fill(TEST_ADMIN.username); await page.locator('#password').fill(TEST_ADMIN.password); await page.locator('#confirmPassword').fill(TEST_ADMIN.password); + const setupRes = page.waitForResponse((r) => r.url().endsWith('/api/setup') && r.request().method() === 'POST'); await page.getByRole('button', { name: 'Create Account' }).click(); - // SPA uses hash routing; wait for sidebar/nav to confirm dashboard loaded - await page.waitForSelector('aside nav, nav', { timeout: 10_000 }); + expect((await setupRes).status()).toBe(201); + await page.waitForSelector('aside:not([data-testid="activity-sidebar"])', { timeout: 15_000 }); + + const me = await page.request.get(`${state.baseURL}/api/me`); + expect(me.status()).toBe(200); + const body = await me.json(); + expect(body.username).toBe(TEST_ADMIN.username); + expect(body.role).toBe('super_admin'); + expect(body.display_name).toBe(TEST_ADMIN.displayName); + expect(body.email).toBe(TEST_ADMIN.email); + + const after = await page.request.get(`${state.baseURL}/api/setup/status`); + expect(await after.json()).toEqual({ needs_setup: false }); }); }); diff --git a/e2e/tests/03-deploy.spec.js b/e2e/tests/03-deploy.spec.js index e4452331..428aeeb3 100644 --- a/e2e/tests/03-deploy.spec.js +++ b/e2e/tests/03-deploy.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; import { findServiceContainer, containerRunning, @@ -10,9 +10,8 @@ import { listAppContainers, dockerExec, } from '../helpers/docker.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; -import { apiRequest, apiLogin } from '../helpers/api.js'; -import { TEST_ADMIN } from '../helpers/auth.js'; +import { fetchViaProxy, waitForProxyStatus } from '../helpers/proxy.js'; +import { apiRequest, apiLogin, waitForAppStatus } from '../helpers/api.js'; const FIXTURES = join(import.meta.dirname, '..', 'fixtures'); @@ -28,131 +27,100 @@ async function deployApp(page, appName, composeContent) { await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill(appName); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - await editor.fill(composeContent); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); + await dialog.locator('textarea').last().fill(composeContent); + await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - const viewAppBtn = dialog.getByRole('button', { name: 'View App' }); - if (await viewAppBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await viewAppBtn.click(); - } + await dialog.getByRole('button', { name: 'View App' }).click(); + await expect(page).toHaveURL(new RegExp(`#/apps/${appName}$`)); + await expect(page.locator('h1')).toHaveText(appName); + await waitForAppStatus(appName, 'running', 15_000); } test.describe('Deploy Apps', () => { + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + }); + test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('deploy nginx app', async ({ page }) => { - const compose = readFixture('compose-nginx.yml'); - await deployApp(page, 'e2e-nginx', compose); + await deployApp(page, 'e2e-nginx', readFixture('compose-nginx.yml')); }); test('deploy multi-service app', async ({ page }) => { - const compose = readFixture('compose-multi.yml'); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await deployApp(page, 'e2e-multi', compose); + await deployApp(page, 'e2e-multi', readFixture('compose-multi.yml')); }); test('deploy postgres app', async ({ page }) => { - const compose = readFixture('compose-postgres.yml'); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await deployApp(page, 'e2e-postgres', compose); + await deployApp(page, 'e2e-postgres', readFixture('compose-postgres.yml')); }); - test('reject invalid YAML', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); + test('invalid YAML fails server validation and blocks Next', async ({ page }) => { + const bad = 'this is not: valid: yaml: ['; await page.getByRole('button', { name: 'Deploy App' }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill('bad-app'); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - await editor.fill('this is not: valid: yaml: ['); - await expect(dialog.getByText(/failed|error|invalid/i).first()).toBeVisible({ timeout: 10_000 }); + const validated = page.waitForResponse((r) => + r.url().endsWith('/api/apps/validate-compose') && + r.request().postDataJSON()?.compose === Buffer.from(bad).toString('base64')); + await dialog.locator('textarea').last().fill(bad); + const res = await validated; + expect(res.status()).toBe(200); + expect((await res.json()).valid).toBe(false); + await expect(dialog.getByText(/^Invalid compose file:/)).toBeVisible(); + await expect(dialog.getByText('Valid compose file')).toHaveCount(0); + await expect(dialog.getByRole('button', { name: 'Next' })).toBeDisabled(); }); - test('deploying same template twice auto-suggests name-2', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - - // First deploy: pick nginx-static template, name it collide-tpl. - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - let dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /browse templates/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); - // On vars step: default access mode is quick-test with prefilled host. Apply. - await dialog.getByRole('button', { name: /apply/i }).click(); - // Step 1: set app name. - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - await nameInput.fill('collide-tpl'); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - // Close wizard via View App (matches the deployApp helper). - const viewApp = dialog.getByRole('button', { name: 'View App' }); - if (await viewApp.isVisible({ timeout: 3_000 }).catch(() => false)) { - await viewApp.click(); - } else { - await page.keyboard.press('Escape'); - } - await expect(dialog).toBeHidden({ timeout: 10_000 }); - await page.goto(`${state.baseURL}/#/`); - - // Second deploy: same template, same name -> expect name-taken modal. - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /browse templates/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); - await dialog.getByRole('button', { name: /apply/i }).click(); - const nameInput2 = dialog.getByPlaceholder('my-app'); - await nameInput2.fill('collide-tpl'); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - - // Expect the name-taken modal with pre-filled suggestion "collide-tpl-2". - const modal = page.getByTestId('name-taken-modal'); - await expect(modal).toBeVisible({ timeout: 15_000 }); - const suggestionInput = page.getByTestId('name-taken-input'); - await expect(suggestionInput).toHaveValue('collide-tpl-2'); - await modal.getByRole('button', { name: 'Deploy' }).click(); - - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - // Both apps should exist. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - const list = await apiRequest('GET', '/api/apps'); - const names = (list.data || []).map((a) => a.Name || a.name); - expect(names).toContain('collide-tpl'); - expect(names).toContain('collide-tpl-2'); - - // Clean up: leaving these apps behind pollutes the shared proxy with - // local-TLS endpoints, which breaks later plain-HTTP tests. - for (const slug of ['collide-tpl', 'collide-tpl-2']) { - await apiRequest('DELETE', `/api/apps/${slug}`); + test('template deploy reusing an existing name suggests name-2 and keeps the original', async ({ page }) => { + const before = await apiRequest('GET', '/api/apps/e2e-nginx'); + expect(before.status).toBe(200); + const beforeHash = before.data.ComposeHash; + expect(beforeHash).toBeTruthy(); + + try { + await page.getByRole('button', { name: 'Deploy App' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /browse templates/i }).click(); + await dialog.getByRole('button', { name: /use template nginx static site/i }).click(); + await dialog.getByRole('button', { name: /^custom domain$/i }).click(); + await dialog.locator('#tpl-var-domain').fill('tpl-e2e.localhost'); + await dialog.getByRole('button', { name: /^apply/i }).click(); + await dialog.getByPlaceholder('my-app').fill('e2e-nginx'); + await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole('button', { name: 'Next' }).click(); + await dialog.getByRole('button', { name: 'Deploy' }).click(); + + const modal = page.getByTestId('name-taken-modal'); + await expect(modal).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('name-taken-input')).toHaveValue('e2e-nginx-2'); + await modal.getByRole('button', { name: 'Deploy' }).click(); + await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); + + // The template's domain variable reaches the deployed app. + const copy = await waitForAppStatus('e2e-nginx-2', 'running', 15_000); + expect(copy.Domain).toBe('tpl-e2e.localhost'); + const after = await apiRequest('GET', '/api/apps/e2e-nginx'); + expect(after.data.ComposeHash).toBe(beforeHash); + } finally { + // Leftover template endpoints break later plain-HTTP specs. + await apiRequest('DELETE', '/api/apps/e2e-nginx-2'); } }); test('manual deploy with existing name shows inline error and does not overwrite', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - - // Capture existing compose hash for e2e-nginx before the attempt. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); const before = await apiRequest('GET', '/api/apps/e2e-nginx'); - expect(before.ok).toBe(true); - const beforeHash = before.data && (before.data.ComposeHash || before.data.compose_hash); + expect(before.status).toBe(200); + const beforeHash = before.data.ComposeHash; + expect(beforeHash).toBeTruthy(); // Attempt a manual deploy reusing e2e-nginx name with a different compose. await page.getByRole('button', { name: 'Deploy App' }).first().click(); @@ -161,62 +129,41 @@ test.describe('Deploy Apps', () => { await dialog.getByRole('button', { name: /build it yourself/i }).click(); await dialog.getByPlaceholder('my-app').fill('e2e-nginx'); await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); // A valid but different compose (distinct image tag) so hash would change if it overwrote. - await editor.fill( + await dialog.locator('textarea').last().fill( "services:\n web:\n image: nginx:1.25-alpine\n ports:\n - '8080:80'\n" ); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); // Expect inline name error on step 1 (no overwrite path in manual source). - await expect(dialog.getByText(/already exists/i).first()).toBeVisible({ timeout: 10_000 }); - // No template suggestion modal should appear for manual source. + await expect(dialog.getByText(/already exists/i)).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('name-taken-modal')).toHaveCount(0); - // Verify the original e2e-nginx compose hash is unchanged. const after = await apiRequest('GET', '/api/apps/e2e-nginx'); - const afterHash = after.data && (after.data.ComposeHash || after.data.compose_hash); - expect(afterHash).toBe(beforeHash); + expect(after.data.ComposeHash).toBe(beforeHash); }); }); -test.describe.configure({ mode: 'serial' }); test.describe('Deploy - Functional', () => { test('nginx container is running with correct image and labels', async () => { const name = findServiceContainer('e2e-nginx', 'web'); expect(name, 'expected to find e2e-nginx web container').toBeTruthy(); expect(containerRunning(name)).toBe(true); - const img = containerImage(name); // Tolerate the GHCR image mirror (E2E_USE_MIRROR=1 rewrites image - // refs server-side), so match on the trailing repo:tag instead of - // anchoring at the start. - expect(img).toMatch(/(^|\/)nginx:/); + // refs server-side), so match on the trailing repo:tag. + expect(containerImage(name)).toMatch(/(^|\/)nginx:alpine$/); const info = dockerInspect(name); expect(info.Config.Labels['com.docker.compose.project']).toBe('simpledeploy-e2e-nginx'); expect(info.Config.Labels['com.docker.compose.service']).toBe('web'); }); test('proxy routes HTTP traffic for nginx-test.local', async () => { - // Caddy reload after deploy is async; poll until the route is live. - const deadline = Date.now() + 30_000; - let res, body = ''; - while (Date.now() < deadline) { - res = await fetchViaProxy('nginx-test.local', '/'); - if (res.status === 200) { - body = await res.text(); - if (body.includes('Welcome to nginx')) break; - } - await new Promise((r) => setTimeout(r, 500)); - } - expect(res.status).toBe(200); - expect(body).toContain('Welcome to nginx'); - - const missing = await fetchViaProxy('nonexistent.local', '/'); - const missingBody = await missing.text(); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); // Caddy returns 200 with empty body when no route matches; ensure no nginx content. - expect(missingBody).not.toContain('Welcome to nginx'); + const missing = await fetchViaProxy('nonexistent.local', '/'); + expect(await missing.text()).not.toContain('Welcome to nginx'); }); test('multi-service deploy runs both services and routes traffic', async () => { @@ -225,14 +172,10 @@ test.describe('Deploy - Functional', () => { for (const c of containers) { expect(containerRunning(c)).toBe(true); } - const res = await fetchViaProxy('multi-test.local', '/'); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toContain('Welcome to nginx'); + await waitForProxyStatus('multi-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); - const redis = containers.find((n) => n.includes('cache')) || findServiceContainer('e2e-multi', 'cache'); + const redis = findServiceContainer('e2e-multi', 'cache'); expect(redis, 'expected to find redis/cache container').toBeTruthy(); - const out = dockerExec(redis, 'redis-cli PING').trim(); - expect(out).toBe('PONG'); + expect(dockerExec(redis, 'redis-cli PING').trim()).toBe('PONG'); }); }); diff --git a/e2e/tests/03b-deploy-templates.spec.js b/e2e/tests/03b-deploy-templates.spec.js deleted file mode 100644 index 114d8e64..00000000 --- a/e2e/tests/03b-deploy-templates.spec.js +++ /dev/null @@ -1,187 +0,0 @@ -// E2E: DeployWizard template flow. -// Exercises step 0 (Template grid), variable rendering, auto-generated secrets, -// Apply -> step 1 pre-fill, and full end-to-end deploy through the template path. -// Also covers the "Build it yourself" (blank compose) regression path. -// -// Runs AFTER 03-deploy.spec.js and BEFORE 04-dashboard.spec.js so the dashboard -// tests still see e2e-nginx / e2e-multi / e2e-postgres. The template-deployed -// apps (e2e-nginx-tpl, e2e-node-tpl, e2e-blank-tpl) are cleaned up via API -// after each test to avoid polluting later specs. - -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; - -test.describe.configure({ mode: 'serial' }); - -async function cleanupApp(slug) { - try { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); } catch {} - try { await apiRequest('DELETE', `/api/apps/${slug}`); } catch {} -} - -test.describe('Deploy via Templates', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('deploy via Nginx Static template', async ({ page }) => { - const slug = 'e2e-nginx-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Step 0: click the Nginx Static card (button aria-label "Use template Nginx Static Site"). - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: /use template nginx static/i }).click(); - - // Vars form: switch to Custom domain mode (default is Quick test via sslip.io). - await dialog.getByRole('button', { name: /^custom domain$/i }).click(); - const domainInput = dialog.locator('#tpl-var-domain'); - await expect(domainInput).toBeVisible(); - await domainInput.fill('e2e-nginx-tpl.localhost'); - - // Apply -> step 1. - await dialog.getByRole('button', { name: /^apply/i }).click(); - - // App name should be pre-filled by suggestName(). - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - const suggested = await nameInput.inputValue(); - expect(suggested.length).toBeGreaterThan(0); - // Override to a deterministic slug so we can clean up reliably. - await nameInput.fill(slug); - - // Wait for compose validation to settle. - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Switch to YAML tab and verify the rendered compose contains the template's - // TLS + domain labels. - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const yamlText = await editor.inputValue(); - expect(yamlText).toContain('tls: letsencrypt'); - expect(yamlText).toContain('domain: e2e-nginx-tpl.localhost'); - - // Re-validate (YAML edit may have rescheduled validation). - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Next -> Deploy. - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - await cleanupApp(slug); - }); - - test('deploy via Node API + Postgres template propagates backup label', async ({ page }) => { - // Deploy pulls node:20-alpine + postgres:16-alpine; on slow networks - // this can exceed 5 min end-to-end, so we give it a generous budget. - // test.setTimeout extends the default (360s) for this test only. - test.setTimeout(1_200_000); - const slug = 'e2e-node-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: /use template node api \+ postgres/i }).click(); - - await dialog.getByRole('button', { name: /^custom domain$/i }).click(); - const domainInput = dialog.locator('#tpl-var-domain'); - await expect(domainInput).toBeVisible(); - await domainInput.fill('e2e-node-tpl.localhost'); - - // Expand advanced accordion and confirm db_password auto-generated. - await dialog.getByText(/advanced \/ secrets/i).click(); - const pwInput = dialog.locator('#tpl-var-db_password'); - await expect(pwInput).toBeVisible(); - const originalPw = await pwInput.inputValue(); - expect(originalPw.length).toBeGreaterThan(0); - - // Regenerate and confirm the value changes. - await dialog.getByRole('button', { name: /^regenerate$/i }).click(); - const regenPw = await pwInput.inputValue(); - expect(regenPw.length).toBeGreaterThan(0); - expect(regenPw).not.toBe(originalPw); - - // Apply -> step 1. - await dialog.getByRole('button', { name: /^apply/i }).click(); - - const nameInput = dialog.getByPlaceholder('my-app'); - await expect(nameInput).toBeVisible(); - await nameInput.fill(slug); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Verify postgres backup label made it into the rendered compose. - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const yamlText = await editor.inputValue(); - expect(yamlText).toContain('simpledeploy.backup.strategy: postgres'); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - // Next -> Deploy. Node API + Postgres template pulls large images; - // allow up to 480s under the 600s test-level timeout. - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - // The point of this test is backup-label propagation, not container - // health. Accept any terminal state: the API service has no server.js - // in its empty volume by design (Unstable), and on slow CI runners - // the pull may be slow enough to land in Failed. Bumped timeout to - // cover slow Docker Hub pulls of node:20-alpine + postgres:16-alpine. - // Use a testid + data-deploy-status attr so we're resilient to whitespace - // and icon siblings inside the badge span (regex getByText was silently - // not matching "Unstable" despite the text being rendered). - const statusBadge = dialog.getByTestId('deploy-status'); - await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable|failed)$/, { timeout: 1_080_000 }); - - // Navigate to the app's Backups tab and confirm it renders. - await dialog.getByRole('button', { name: 'View App' }).click(); - await expect(page).toHaveURL(new RegExp(`/#/apps/${slug}`)); - // The tab labels render via capitalize; the DOM text is lowercase "backups". - await page.getByRole('button', { name: 'backups', exact: true }).click(); - // Hash routing: some deployments add ?tab=backups; just confirm tab content mounted. - // BackupsTab shows at minimum heading or controls; we assert no console-error surface - // by waiting for either a known control or an empty-state indicator. - await expect(page.locator('main')).toBeVisible(); - - await cleanupApp(slug); - }); - - test('start with a blank compose file still works', async ({ page }) => { - const slug = 'e2e-blank-tpl'; - - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - await dialog.getByRole('button', { name: /build it yourself/i }).click(); - - await dialog.getByPlaceholder('my-app').fill(slug); - - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const blankCompose = [ - 'services:', - ' web:', - ' image: nginx:alpine', - ' labels:', - ' simpledeploy.endpoints.0.domain: e2e-blank-tpl.localhost', - " simpledeploy.endpoints.0.port: '80'", - " simpledeploy.endpoints.0.tls: 'off'", - '', - ].join('\n'); - await editor.fill(blankCompose); - - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 10_000 }); - - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); - - await cleanupApp(slug); - }); -}); diff --git a/e2e/tests/03c-community-recipes.spec.js b/e2e/tests/03c-community-recipes.spec.js index c69854d9..77cd0f3b 100644 --- a/e2e/tests/03c-community-recipes.spec.js +++ b/e2e/tests/03c-community-recipes.spec.js @@ -1,51 +1,65 @@ // E2E: Community recipes browse + import flow. // -// Hits the live community catalog at vazra.github.io/simpledeploy-recipes. -// The catalog must contain at least the seed recipe `nginx-static` for this -// spec to pass. Catalog source: https://github.com/vazra/simpledeploy-recipes -// -// This spec only imports a recipe into the wizard editor; it does not deploy -// (no DNS/domain dependency, fast). +// Runs against an isolated server whose recipes_index_url points at a local +// fixture catalog (helpers/recipes.js), so the spec never depends on the +// live catalog. It imports a recipe into the wizard editor; it does not deploy. import { test, expect } from '@playwright/test'; -import { loginAsAdmin } from '../helpers/auth.js'; +import { startServer, stopServer, getBinaryPath } from '../helpers/server.js'; +import { apiRequestAt } from '../helpers/api.js'; +import { TEST_ADMIN } from '../helpers/auth.js'; +import { startRecipesCatalog, FIXTURE_RECIPE } from '../helpers/recipes.js'; test.describe('community recipes', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); + let catalog = null; + let server = null; + + test.beforeAll(async () => { + catalog = await startRecipesCatalog(); + server = await startServer(getBinaryPath(), { recipesIndexURL: catalog.indexURL }); + const setup = await apiRequestAt(server.baseURL, 'POST', '/api/setup', { + username: TEST_ADMIN.username, + password: TEST_ADMIN.password, + display_name: TEST_ADMIN.displayName, + email: TEST_ADMIN.email, + }); + expect(setup.status).toBe(201); }); - test('browse, view detail, import a recipe into deploy wizard', async ({ page }) => { - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog').first(); - await expect(dialog).toBeVisible(); + test.afterAll(async () => { + stopServer(server); + if (catalog) await catalog.stop(); + }); - // Open the templates grid first (step 0 has Browse Templates / Build it yourself). - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); + test('browse, view detail, import a recipe into deploy wizard', async ({ page }) => { + const login = await page.request.post(`${server.baseURL}/api/auth/login`, { + data: { username: TEST_ADMIN.username, password: TEST_ADMIN.password }, + }); + expect(login.status()).toBe(200); + await page.goto(`${server.baseURL}/#/`); - // Open the community recipes browser from the template picker. - await dialog.getByRole('button', { name: /^browse community recipes$/i }).click(); + await page.locator('main').getByRole('button', { name: 'Deploy App' }).first().click(); + const wizard = page.getByRole('dialog'); + await expect(wizard).toBeVisible(); + await wizard.getByRole('button', { name: /^browse templates$/i }).click(); + await wizard.getByRole('button', { name: /^browse community recipes$/i }).click(); - // The browser opens as its own dialog. Scope subsequent queries to it. const browser = page.getByRole('dialog', { name: /browse community recipes/i }); - await expect(browser).toBeVisible({ timeout: 15_000 }); - - // Live catalog must contain the nginx-static seed recipe. - const card = browser.getByRole('button', { name: /nginx static site/i }); - await expect(card).toBeVisible({ timeout: 15_000 }); - await card.click(); + await expect(browser).toBeVisible(); + await browser.getByRole('button', { name: new RegExp(FIXTURE_RECIPE.name) }).click(); - // Detail view shows README + Use Recipe button. - await expect(browser.getByRole('button', { name: /^use recipe$/i })).toBeVisible({ timeout: 10_000 }); - - // Import the recipe. + // README comes from the catalog through the server's file proxy. + await expect(browser.locator('pre')).toContainText('Fixture README marker e2e-readme-7f3a.'); await browser.getByRole('button', { name: /^use recipe$/i }).click(); + await expect(browser).toBeHidden(); + + await expect(wizard.locator('textarea').last()).toHaveValue(FIXTURE_RECIPE.compose); + await expect(wizard.getByPlaceholder('my-app')).toHaveValue(FIXTURE_RECIPE.id); + await expect(wizard.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); - // Wizard advances to step 1 with YAML editor in 'yaml' mode. - // The compose textarea should contain the recipe body. - const editor = dialog.locator('textarea').last(); - await expect(editor).toBeVisible({ timeout: 10_000 }); - const yamlText = await editor.inputValue(); - expect(yamlText.toLowerCase()).toMatch(/image:\s*nginx/); + // Using a recipe records a compose pull for the popularity report. + const pop = await page.request.get(`${server.baseURL}/api/recipes/community/popularity`); + expect(pop.status()).toBe(200); + expect((await pop.json()).recipes).toEqual([{ recipe_id: FIXTURE_RECIPE.id, count: 1 }]); }); }); diff --git a/e2e/tests/04-dashboard.spec.js b/e2e/tests/04-dashboard.spec.js index 81122f19..e163e0a8 100644 --- a/e2e/tests/04-dashboard.spec.js +++ b/e2e/tests/04-dashboard.spec.js @@ -1,38 +1,44 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; + +const APPS = ['e2e-nginx', 'e2e-multi', 'e2e-postgres']; test.describe('Dashboard', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('shows all 3 deployed app cards', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-multi', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-postgres', exact: true })).toBeVisible(); - }); + test('shows app status, host metrics, search and card navigation', async ({ page }) => { + // Host metrics flow collector -> /api/metrics/system -> stat cards. Wait + // for the first system point so the page load below must render it. + await expect.poll(async () => { + const res = await apiRequest('GET', '/api/metrics/system?range=1h'); + return res.data?.containers?.['']?.points?.length || 0; + }, { timeout: 30_000 }).toBeGreaterThan(0); - test('app cards show running status', async ({ page }) => { - // Filter bar shows "Running N" count; app cards show "running" badges - await expect(page.getByText(/Running \d/)).toBeVisible(); - }); + await loginAsAdmin(page); + const main = page.locator('main'); - test('system metrics section renders', async ({ page }) => { - await expect(page.getByText(/cpu/i).first()).toBeVisible(); - await expect(page.getByText(/memory/i).first()).toBeVisible(); - }); + for (const slug of APPS) { + const card = main.locator(`a[href="#/apps/${slug}"]`); + await expect(card.getByRole('heading', { name: slug, exact: true })).toBeVisible(); + await expect(card.getByText('running', { exact: true })).toBeVisible(); + } + const apps = (await apiRequest('GET', '/api/apps')).data; + const running = apps.filter((a) => a.Status === 'running').length; + await expect(main.getByRole('button', { name: /^Running/ }).locator('span').last()).toHaveText(String(running)); - test('search filters apps', async ({ page }) => { - await page.getByPlaceholder(/search/i).fill('nginx'); - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-multi', exact: true })).not.toBeVisible(); - await expect(page.getByRole('heading', { name: 'e2e-postgres', exact: true })).not.toBeVisible(); - }); + // Memory card sub-text reads " / "; both stay "0" without data. + await expect(main.getByText(/^\d+\.\d [KMGT]?B \/ \d+\.\d [KMGT]?B$/)).toBeVisible(); + + await main.getByPlaceholder(/search/i).fill('nginx'); + await expect(main.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); + await expect(main.getByRole('heading', { name: 'e2e-multi', exact: true })).toHaveCount(0); + await expect(main.getByRole('heading', { name: 'e2e-postgres', exact: true })).toHaveCount(0); - test('clicking app card navigates to app detail', async ({ page }) => { - await page.getByRole('heading', { name: 'e2e-nginx', exact: true }).click(); - await expect(page).toHaveURL(/apps\/e2e-nginx/); + await main.getByRole('heading', { name: 'e2e-nginx', exact: true }).click(); + await expect(page).toHaveURL(/#\/apps\/e2e-nginx$/); + await expect(page.locator('h1')).toHaveText('e2e-nginx'); }); }); diff --git a/e2e/tests/05-app-overview.spec.js b/e2e/tests/05-app-overview.spec.js deleted file mode 100644 index 684f658c..00000000 --- a/e2e/tests/05-app-overview.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; - -test.describe('App Overview', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - }); - - test('shows app name and running status', async ({ page }) => { - await expect(page.locator('h1').getByText('e2e-nginx')).toBeVisible(); - await expect(page.getByText(/running/i).first()).toBeVisible(); - }); - - test('shows services list', async ({ page }) => { - await expect(page.locator('main').getByText('web').first()).toBeVisible(); - }); - - test('shows action buttons', async ({ page }) => { - await expect(page.getByRole('button', { name: /restart/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /stop/i })).toBeVisible(); - }); - - test('multi-service app shows services', async ({ page }) => { - const state = getState(); - // beforeEach already mounted AppDetail for e2e-nginx. svelte-spa-router - // reuses the component instance on hash changes, and AppDetail only - // loads data in onMount (no reactive effect on slug). Force a full page - // load so the component remounts with the new slug. - await page.goto(`${state.baseURL}/#/apps/e2e-multi`, { waitUntil: 'load' }); - await page.reload(); - await expect(page.locator('h1').getByText('e2e-multi')).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('main').getByText('Services').first()).toBeVisible({ timeout: 10_000 }); - }); - - test('tab navigation works', async ({ page }) => { - const tabs = ['logs', 'metrics', 'backups', 'settings']; - for (const tab of tabs) { - const btn = page.getByRole('button', { name: new RegExp(tab, 'i') }); - await btn.click(); - // Verify tab became active (wait for content area to update) - await expect(btn).toBeVisible(); - } - await page.getByRole('button', { name: /overview/i }).click(); - }); -}); diff --git a/e2e/tests/06-app-actions.spec.js b/e2e/tests/06-app-actions.spec.js index bc47eb94..744ed009 100644 --- a/e2e/tests/06-app-actions.spec.js +++ b/e2e/tests/06-app-actions.spec.js @@ -1,229 +1,128 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiRequest, apiLogin, waitForAppStatus } from '../helpers/api.js'; import { findServiceContainer, - containerRunning, dockerInspect, waitForContainerState, countServiceReplicas, } from '../helpers/docker.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; - +import { waitForProxyStatus } from '../helpers/proxy.js'; + +async function eventCount(slug, action) { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + expect(res.status).toBe(200); + return res.data.filter((e) => e.action === action).length; +} + +function actionResponse(page, slug, action) { + return page.waitForResponse((r) => + r.url().endsWith(`/api/apps/${slug}/${action}`) && r.request().method() === 'POST'); +} + +// ActionModal streams the async action over WS and shows the done action. +async function expectActionComplete(page, action, timeout) { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Complete', { exact: true })).toBeVisible({ timeout }); + await expect(dialog.getByText(action, { exact: true })).toBeVisible(); + await dialog.locator('button:has-text("Close"):not([aria-label])').click(); + await expect(dialog).toBeHidden(); +} + +// Each test leaves the state the next one starts from (stopped -> started). test.describe('App Actions', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); + test.describe.configure({ mode: 'serial' }); - test('stop app', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /stop/i }).click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - const confirmBtn = dialog.getByRole('button', { name: /stop|confirm|yes/i }); - if (await confirmBtn.isVisible({ timeout: 1_000 }).catch(() => false)) { - await confirmBtn.click(); - } - } - await expect(page.getByText(/stopped/i).first()).toBeVisible({ timeout: 30_000 }); - }); - - test('start app', async ({ page }) => { - const state = getState(); - // Ensure stopped via API to avoid cross-test ordering flakiness. - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-nginx/stop'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - if (res.ok && res.data?.status === 'stopped') break; - await new Promise(r => setTimeout(r, 1_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await expect(page.getByRole('button', { name: 'Start' })).toBeVisible({ timeout: 30_000 }); - await page.getByRole('button', { name: 'Start' }).click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 30_000 }); - }); - - test('restart app', async ({ page }) => { - const state = getState(); - // Use the API to ensure app is running before testing restart - const { apiRequest, apiLogin } = await import('../helpers/api.js'); - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-nginx/start'); - // Wait for Docker to start the container - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - if (res.ok && res.data?.status === 'running') break; - await new Promise(r => setTimeout(r, 2_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await expect(page.getByRole('button', { name: /restart/i })).toBeVisible({ timeout: 15_000 }); - await page.getByRole('button', { name: /restart/i }).click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - const confirmBtn = dialog.getByRole('button', { name: /restart|confirm/i }); - if (await confirmBtn.isVisible({ timeout: 1_000 }).catch(() => false)) { - await confirmBtn.click(); - } - } - // Wait for action modal close button (inside the dialog, not the backdrop) - const actionDialog = page.getByRole('dialog'); - const closeBtn = actionDialog.locator('button:has-text("Close"):not([aria-label])'); - await expect(closeBtn).toBeVisible({ timeout: 60_000 }); - await closeBtn.click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 15_000 }); - }); - - test('pull and update', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /pull/i }).click(); - // Wait for action modal close button (inside dialog footer, not backdrop) - const actionDialog = page.getByRole('dialog'); - const closeBtn = actionDialog.locator('button:has-text("Close"):not([aria-label])'); - await expect(closeBtn).toBeVisible({ timeout: 120_000 }); - await closeBtn.click(); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('scale service', async ({ page }) => { - const state = getState(); - // Make sure app is running so services render with scale controls. - await apiLogin('e2eadmin', 'E2eTestPass123!'); - await apiRequest('POST', '/api/apps/e2e-multi/start'); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/e2e-multi'); - if (res.ok && res.data?.status === 'running') break; - await new Promise(r => setTimeout(r, 1_000)); - } - await page.goto(`${state.baseURL}/#/apps/e2e-multi`); - // Scale uses inline +/- buttons per scalable service in OverviewTab. - const incBtn = page.getByRole('button', { name: 'Increase replicas' }).first(); - await expect(incBtn).toBeVisible({ timeout: 30_000 }); - await incBtn.click(); - await expect(page.getByText(/running/i).first()).toBeVisible({ timeout: 30_000 }); - }); -}); - -test.describe.configure({ mode: 'serial' }); -test.describe('App Actions - Functional', () => { - test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); }); - test('stop actually stops container and proxy returns upstream error', async () => { - // Ensure running first. - await apiRequest('POST', '/api/apps/e2e-nginx/start'); + test('stop stops the container and the proxy reports a bad gateway', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); expect(name).toBeTruthy(); - await waitForContainerState(name, true, 30_000); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'stop'); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect((await res).status()).toBe(200); + await expect(page.getByRole('button', { name: 'Start', exact: true })).toBeVisible(); - const res = await apiRequest('POST', '/api/apps/e2e-nginx/stop'); - expect(res.ok).toBe(true); await waitForContainerState(name, false, 30_000); - expect(containerRunning(name)).toBe(false); - - // With container down, proxy should fail to reach upstream. - const r = await fetchViaProxy('nginx-test.local', '/'); - expect([502, 503, 504, 500, 0, 404].includes(r.status)).toBe(true); - expect(r.status).not.toBe(200); + await waitForAppStatus('e2e-nginx', 'stopped', 5_000); + await waitForProxyStatus('nginx-test.local', 502); }); - test('start actually starts container and proxy serves 200', async () => { - const res = await apiRequest('POST', '/api/apps/e2e-nginx/start'); - expect(res.ok).toBe(true); + test('start brings the container back and the proxy serves it', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'start'); + await page.getByRole('button', { name: 'Start', exact: true }).click(); + expect((await res).status()).toBe(200); + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible(); + await waitForContainerState(name, true, 30_000); - expect(containerRunning(name)).toBe(true); - - // Give proxy a moment to re-route. - const deadline = Date.now() + 30_000; - let last = 0; - while (Date.now() < deadline) { - const r = await fetchViaProxy('nginx-test.local', '/'); - last = r.status; - if (r.status === 200) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(last).toBe(200); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('restart preserves container but bumps StartedAt', async () => { + test('restart restarts the container in place', async ({ page }) => { const name = findServiceContainer('e2e-nginx', 'web'); - expect(name).toBeTruthy(); - const beforeStart = dockerInspect(name).State.StartedAt; - const beforeMs = new Date(beforeStart).getTime(); - - const res = await apiRequest('POST', '/api/apps/e2e-nginx/restart'); - expect(res.ok).toBe(true); - - const deadline = Date.now() + 60_000; - let advanced = false; - while (Date.now() < deadline) { - try { - const after = dockerInspect(findServiceContainer('e2e-nginx', 'web')).State.StartedAt; - const afterMs = new Date(after).getTime(); - if (afterMs > beforeMs) { - advanced = true; - break; - } - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(advanced).toBe(true); + const before = dockerInspect(name); + const restarts = await eventCount('e2e-nginx', 'restart'); + + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'Restart', exact: true }).click(); + const confirm = page.getByRole('dialog').filter({ hasText: 'Restart App' }); + const res = actionResponse(page, 'e2e-nginx', 'restart'); + await confirm.getByRole('button', { name: 'Restart', exact: true }).click(); + expect((await res).status()).toBe(202); + await expect(confirm).toBeHidden(); + await expectActionComplete(page, 'restart', 60_000); + + const after = dockerInspect(name); + expect(after.Id).toBe(before.Id); + expect(new Date(after.State.StartedAt).getTime()).toBeGreaterThan(new Date(before.State.StartedAt).getTime()); + // The event row is written just after the WS done message. + await expect.poll(() => eventCount('e2e-nginx', 'restart')).toBe(restarts + 1); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('scale to 2 creates 2nd replica then back to 1', async () => { - // Use the cache (redis) service since web has a fixed host-port mapping - // that would conflict when scaled to multiple replicas. - test.setTimeout(180_000); - const up = await apiRequest('POST', '/api/apps/e2e-multi/scale', { scales: { cache: 2 } }); - expect(up.ok, `scale up failed: ${JSON.stringify(up.data)}`).toBe(true); - - const deadline = Date.now() + 60_000; - while (Date.now() < deadline) { - if (countServiceReplicas('e2e-multi', 'cache') === 2) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(countServiceReplicas('e2e-multi', 'cache')).toBe(2); - - const down = await apiRequest('POST', '/api/apps/e2e-multi/scale', { scales: { cache: 1 } }); - expect(down.ok).toBe(true); - - const deadline2 = Date.now() + 60_000; - while (Date.now() < deadline2) { - if (countServiceReplicas('e2e-multi', 'cache') === 1) break; - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(countServiceReplicas('e2e-multi', 'cache')).toBe(1); + test('pull & update re-pulls and keeps serving', async ({ page }) => { + const pulls = await eventCount('e2e-nginx', 'pull'); + + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + const res = actionResponse(page, 'e2e-nginx', 'pull'); + await page.getByRole('button', { name: 'Pull & Update' }).click(); + expect((await res).status()).toBe(202); + await expectActionComplete(page, 'pull', 120_000); + + await expect.poll(() => eventCount('e2e-nginx', 'pull')).toBe(pulls + 1); + await waitForAppStatus('e2e-nginx', 'running', 5_000); + await waitForContainerState(findServiceContainer('e2e-nginx', 'web'), true, 5_000); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); - test('pull keeps app running and serving traffic', async () => { - test.setTimeout(180_000); - const name = findServiceContainer('e2e-nginx', 'web'); - expect(name).toBeTruthy(); - const beforeImage = dockerInspect(name).Image; - - const res = await apiRequest('POST', '/api/apps/e2e-nginx/pull'); - expect([200, 202].includes(res.status)).toBe(true); - - // pull is async; give it time to complete. - await new Promise((r) => setTimeout(r, 20_000)); - // Wait for running status. - await waitForAppStatus('e2e-nginx', 'running', 60_000); - - const currentName = findServiceContainer('e2e-nginx', 'web'); - expect(currentName).toBeTruthy(); - expect(containerRunning(currentName)).toBe(true); - // Image digest exists (may or may not change depending on registry state). - const afterImage = dockerInspect(currentName).Image; - expect(afterImage).toBeTruthy(); - expect(typeof beforeImage).toBe('string'); - - const r = await fetchViaProxy('nginx-test.local', '/'); - expect(r.status).toBe(200); + test('scale cache to 2 replicas and back to 1', async ({ page }) => { + // web has a fixed host port, so only cache is scalable. + await page.goto(`${getState().baseURL}/#/apps/e2e-multi`); + const row = page.locator('main div.rounded-lg').filter({ has: page.locator('span[title="cache"]') }); + const replicas = row.locator('span[title="Current replicas"]'); + await expect(replicas).toHaveText('1'); + + let res = actionResponse(page, 'e2e-multi', 'scale'); + await row.getByRole('button', { name: 'Increase replicas' }).click(); + expect((await res).status()).toBe(200); + await expect(replicas).toHaveText('2'); + await expect.poll(() => countServiceReplicas('e2e-multi', 'cache'), { timeout: 30_000 }).toBe(2); + + res = actionResponse(page, 'e2e-multi', 'scale'); + await row.getByRole('button', { name: 'Decrease replicas' }).click(); + expect((await res).status()).toBe(200); + await expect(replicas).toHaveText('1'); + await expect.poll(() => countServiceReplicas('e2e-multi', 'cache'), { timeout: 30_000 }).toBe(1); }); }); diff --git a/e2e/tests/07-app-config.spec.js b/e2e/tests/07-app-config.spec.js index 90d554ff..19cb5109 100644 --- a/e2e/tests/07-app-config.spec.js +++ b/e2e/tests/07-app-config.spec.js @@ -1,21 +1,42 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, waitForDeployDone } from '../helpers/api.js'; +import { findServiceContainer, containerImage } from '../helpers/docker.js'; +import { waitForProxyStatus } from '../helpers/proxy.js'; test.describe('App Configuration', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('view compose file in config tab', async ({ page }) => { - await page.getByRole('button', { name: /settings/i }).click(); - await expect(page.getByText('nginx:alpine')).toBeVisible({ timeout: 5_000 }); - }); + // Leaves e2e-nginx on nginx:1.27-alpine with a second compose version, + // which 11-app-versions and 23-rollback-and-restore build on. + test('Save & Deploy from the YAML editor redeploys with the edited image', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); + const main = page.locator('main'); + await main.getByRole('button', { name: 'YAML', exact: true }).click(); + + const editor = main.locator('textarea').first(); + await expect(editor).toHaveValue(/image: nginx:alpine\n/); + const edited = (await editor.inputValue()).replace('image: nginx:alpine', 'image: nginx:1.27-alpine'); + await editor.fill(edited); + await main.getByRole('button', { name: 'Save & Deploy' }).click(); + + const review = page.getByRole('dialog').filter({ hasText: 'Review Changes' }); + await expect(review.getByText(/^- +image: nginx:alpine$/)).toBeVisible(); + await expect(review.getByText(/^\+ +image: nginx:1\.27-alpine$/)).toBeVisible(); + const deployRes = page.waitForResponse((r) => r.url().endsWith('/api/apps/deploy') && r.request().method() === 'POST'); + await review.getByRole('button', { name: 'Confirm & Deploy' }).click(); + expect((await deployRes).status()).toBe(202); + await expect(review).toBeHidden(); - test('environment variables section visible', async ({ page }) => { - await page.getByRole('button', { name: /settings/i }).click(); - await expect(page.getByText(/environment variables/i).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.getByRole('button', { name: /add variable/i })).toBeVisible(); + await expect.poll(() => { + const name = findServiceContainer('e2e-nginx', 'web'); + return name ? containerImage(name) : ''; + }, { timeout: 90_000 }).toMatch(/(^|\/)nginx:1\.27-alpine$/); + expect((await waitForDeployDone('e2e-nginx')).Status).toBe('running'); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); }); diff --git a/e2e/tests/08-app-endpoints.spec.js b/e2e/tests/08-app-endpoints.spec.js index f84705e9..e8f12119 100644 --- a/e2e/tests/08-app-endpoints.spec.js +++ b/e2e/tests/08-app-endpoints.spec.js @@ -1,116 +1,72 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; - -test.describe('Endpoints & Access', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: 'Settings' }).click(); - }); - - test('shows current endpoints', async ({ page }) => { - await expect(page.getByText('nginx-test.local').first()).toBeVisible({ timeout: 10_000 }); - }); - - test('shows TLS mode', async ({ page }) => { - await expect(page.getByText(/No TLS|Auto TLS|Local CA|Custom TLS/i).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('Advanced section has IP allowlist', async ({ page }) => { - // Verify the Advanced section and its contents exist in the DOM - // even if the collapsible toggle has rendering issues - const hasAdvanced = await page.getByRole('button', { name: 'Advanced' }).isVisible(); - expect(hasAdvanced).toBeTruthy(); - }); -}); +import { fetchViaProxy, waitForProxyStatus } from '../helpers/proxy.js'; const ORIGINAL_NGINX_ENDPOINTS = [ { domain: 'nginx-test.local', port: '80', tls: 'off', service: 'web' }, ]; -// waitForProxyStatus polls Caddy until the response matches wantStatus and, -// when bodyContains is provided, the response body also contains that -// substring. The body check is necessary because Caddy's fallthrough -// handler can return 200 with an empty body during the window between the -// endpoints PUT and the reconciler installing the new route; accepting that -// empty 200 would mask real routing failures. -async function waitForProxyStatus(host, wantStatus, timeoutMs = 15_000, bodyContains = null) { - const deadline = Date.now() + timeoutMs; - let last = 0; - let lastBody = ''; - while (Date.now() < deadline) { - try { - const r = await fetchViaProxy(host, '/'); - last = r.status; - if (r.status === wantStatus) { - if (!bodyContains) return r; - const body = await r.text(); - lastBody = body.slice(0, 60); - if (body.includes(bodyContains)) { - return { ...r, text: async () => body }; - } - } - } catch {} - await new Promise((r) => setTimeout(r, 500)); - } - const hint = bodyContains ? ` body~${JSON.stringify(bodyContains)}` : ''; - throw new Error(`host ${host} did not reach status ${wantStatus}${hint} within ${timeoutMs}ms (last=${last}, lastBody=${lastBody})`); -} - async function waitForProxyNotNginx(host, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; let last = ''; while (Date.now() < deadline) { - try { - const r = await fetchViaProxy(host, '/'); - const body = await r.text(); - last = body.slice(0, 60); - if (r.status !== 200 || !body.includes('Welcome to nginx')) return { status: r.status, body }; - } catch { - return { status: 0, body: '' }; - } - await new Promise((r) => setTimeout(r, 500)); + const r = await fetchViaProxy(host, '/'); + const body = await r.text(); + last = body.slice(0, 60); + if (r.status !== 200 || !body.includes('Welcome to nginx')) return { status: r.status, body }; + await new Promise((res) => setTimeout(res, 300)); } throw new Error(`host ${host} still routed to nginx within ${timeoutMs}ms (last body=${last})`); } -test.describe.configure({ mode: 'serial' }); -test.describe('Endpoints - Functional', () => { +test.describe('Endpoints', () => { test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); + // Every test changes e2e-nginx endpoints; later specs need the original + // route back and serving. test.afterEach(async () => { - // Always restore nginx endpoints after each functional test. - await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', ORIGINAL_NGINX_ENDPOINTS); - try { - await waitForProxyStatus('nginx-test.local', 200, 20_000, 'Welcome to nginx'); - } catch {} + const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', ORIGINAL_NGINX_ENDPOINTS); + expect(res.status).toBe(200); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); - test('added domain becomes reachable via proxy', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', [ - ...ORIGINAL_NGINX_ENDPOINTS, - { domain: 'added-e2e.local', port: '80', tls: 'off', service: 'web' }, - ]); - expect(res.ok).toBe(true); + test('endpoint added in Settings routes through the proxy', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); - const r = await waitForProxyStatus('added-e2e.local', 200, 40_000, 'Welcome to nginx'); - const body = await r.text(); - expect(body).toContain('Welcome to nginx'); + const card = page.locator('main div.rounded-xl').filter({ has: page.getByRole('heading', { name: 'Endpoints', exact: true }) }); + const original = card.locator('div.group').filter({ hasText: 'nginx-test.local' }); + await expect(original).toContainText('web:80'); + await expect(original.getByText('No TLS', { exact: true })).toBeVisible(); + + await card.getByRole('button', { name: '+ Add' }).click(); + await card.getByPlaceholder('myapp.example.com').fill('added-e2e.local'); + await card.getByPlaceholder('3000').fill('80'); + await card.locator('select').filter({ has: page.locator('option[value="off"]') }).selectOption('off'); + const res = page.waitForResponse((r) => r.url().endsWith('/api/apps/e2e-nginx/endpoints') && r.request().method() === 'PUT'); + await card.getByRole('button', { name: 'Add Endpoint' }).click(); + expect((await res).status()).toBe(200); + + const added = card.locator('div.group').filter({ hasText: 'added-e2e.local' }); + await expect(added).toContainText('web:80'); + await expect(added.getByText('No TLS', { exact: true })).toBeVisible(); + await waitForProxyStatus('added-e2e.local', 200, { timeoutMs: 40_000, bodyContains: 'Welcome to nginx' }); + await waitForProxyStatus('nginx-test.local', 200, { bodyContains: 'Welcome to nginx' }); }); - test('removed domain returns non-200 via proxy', async () => { + test('removed domain stops routing to the app', async () => { const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', [ { domain: 'only-other.local', port: '80', tls: 'off', service: 'web' }, ]); - expect(res.ok).toBe(true); + expect(res.status).toBe(200); const r = await waitForProxyNotNginx('nginx-test.local', 20_000); - expect(r.body || '').not.toContain('Welcome to nginx'); + expect(r.body).not.toContain('Welcome to nginx'); + await waitForProxyStatus('only-other.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); test('multiple endpoints for same service both route to 200', async () => { @@ -118,11 +74,9 @@ test.describe('Endpoints - Functional', () => { ...ORIGINAL_NGINX_ENDPOINTS, { domain: 'alt-e2e.local', port: '80', tls: 'off', service: 'web' }, ]); - expect(res.ok).toBe(true); + expect(res.status).toBe(200); - const a = await waitForProxyStatus('nginx-test.local', 200, 20_000, 'Welcome to nginx'); - expect(a.status).toBe(200); - const b = await waitForProxyStatus('alt-e2e.local', 200, 20_000, 'Welcome to nginx'); - expect(b.status).toBe(200); + await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); + await waitForProxyStatus('alt-e2e.local', 200, { timeoutMs: 20_000, bodyContains: 'Welcome to nginx' }); }); }); diff --git a/e2e/tests/09-app-logs.spec.js b/e2e/tests/09-app-logs.spec.js index f363d956..1345eb7d 100644 --- a/e2e/tests/09-app-logs.spec.js +++ b/e2e/tests/09-app-logs.spec.js @@ -1,38 +1,38 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +import { fetchViaProxy } from '../helpers/proxy.js'; test.describe('App Logs', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /logs/i }).click(); + test.beforeAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('log viewer renders', async ({ page }) => { - const logContainer = page.locator('.font-mono').first(); - await expect(logContainer).toBeVisible({ timeout: 10_000 }); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); }); - test('log controls visible', async ({ page }) => { - const followBtn = page.getByText(/following|paused/i).first(); - await expect(followBtn).toBeVisible({ timeout: 5_000 }); + test('log viewer streams a fresh nginx access log line', async ({ page }) => { + await page.getByRole('button', { name: 'logs', exact: true }).click(); + const marker = `/e2e-log-${Date.now()}`; + const r = await fetchViaProxy('nginx-test.local', marker); + expect(r.status).toBe(404); + // nginx access log -> docker logs -> logs WS -> viewer. + await expect(page.locator('main').getByText(new RegExp(`"GET ${marker} HTTP/1\\.1" 404`))).toBeVisible(); }); - test('clear button works', async ({ page }) => { - const clearBtn = page.getByRole('button', { name: /clear/i }); - if (await clearBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await clearBtn.click(); - } - }); + test('events tab lists the deploy events from the API', async ({ page }) => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/events'); + expect(res.status).toBe(200); + const actions = res.data.map((e) => e.action); + expect(actions).toContain('deploy'); + expect(actions.filter((a) => /_(failed|unstable)$/.test(a))).toEqual([]); - test('deploy logs available in events tab', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - const eventsBtn = page.getByRole('button', { name: /event|history/i }); - if (await eventsBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await eventsBtn.click(); - await expect(page.getByText(/deploy/i).first()).toBeVisible({ timeout: 5_000 }); + await page.getByRole('button', { name: 'events', exact: true }).click(); + const main = page.locator('main'); + for (const action of new Set(actions)) { + await expect(main.getByText(action, { exact: true })).toHaveCount(actions.filter((a) => a === action).length); } }); }); diff --git a/e2e/tests/10-app-metrics.spec.js b/e2e/tests/10-app-metrics.spec.js index 773938bc..c4505e02 100644 --- a/e2e/tests/10-app-metrics.spec.js +++ b/e2e/tests/10-app-metrics.spec.js @@ -1,89 +1,40 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; import { fetchViaProxy } from '../helpers/proxy.js'; -import { sqliteQuery } from '../helpers/db.js'; +import { sqliteQuery, getAppId } from '../helpers/db.js'; test.describe('App Metrics', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /metrics/i }).click(); - }); - - test('metrics charts render', async ({ page }) => { - // Charts need time to fetch data and render via Chart.js - const canvases = page.locator('canvas'); - await expect(canvases.first()).toBeVisible({ timeout: 15_000 }); - }); - - test('time range buttons visible', async ({ page }) => { - await expect(page.getByRole('button', { name: '1h' })).toBeVisible(); - await expect(page.getByRole('button', { name: '24h' })).toBeVisible(); - }); - - test('switch time range', async ({ page }) => { - await page.getByRole('button', { name: '6h' }).click(); - await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15_000 }); - }); - - test('CPU and memory chart labels visible', async ({ page }) => { - await expect(page.getByText(/cpu/i).first()).toBeVisible(); - await expect(page.getByText(/memory/i).first()).toBeVisible(); - }); -}); - -test.describe.configure({ mode: 'serial' }); -test.describe('Metrics - Functional', () => { test.beforeAll(async () => { - await apiLogin('e2eadmin', 'E2eTestPass123!'); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('metrics DB accumulates data after traffic', async () => { - test.setTimeout(180_000); + test('switching to 6h loads container metrics', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'metrics', exact: true }).click(); + const res = page.waitForResponse((r) => r.url().endsWith('/api/apps/e2e-nginx/metrics?range=6h')); + await page.getByRole('button', { name: '6h', exact: true }).click(); + const metrics = await res; + expect(metrics.status()).toBe(200); + expect(Object.keys((await metrics.json()).containers || {}).length).toBeGreaterThan(0); + }); - // Generate HTTP load through the proxy. + test('proxied requests and container stats land in the metrics store', async () => { + const appId = getAppId('e2e-nginx'); + const since = Math.floor(Date.now() / 1000) - 1; for (let i = 0; i < 30; i++) { - try { - await fetchViaProxy('nginx-test.local', `/?i=${i}`); - } catch {} + const r = await fetchViaProxy('nginx-test.local', `/?i=${i}`); + expect(r.status).toBe(200); } - - // Wait for a couple of metrics + request-stats flush cycles. e2e runs - // both at 1s (SIMPLEDEPLOY_*_FLUSH_INTERVAL), so 5s is ample. - await new Promise((r) => setTimeout(r, 5_000)); - - const rows = sqliteQuery( - "SELECT COUNT(*) AS c FROM metrics WHERE app_id=(SELECT id FROM apps WHERE slug='e2e-nginx')", - ); - expect(rows.length).toBeGreaterThan(0); - const c = Number(rows[0].c); - expect(c).toBeGreaterThan(0); + // Request stats flush every 1s in e2e (SIMPLEDEPLOY_REQUEST_METRICS_FLUSH_INTERVAL). + await expect.poll(() => Number(sqliteQuery( + `SELECT COALESCE(SUM(count), 0) AS c FROM request_metrics WHERE app_id=${appId} AND tier='raw' AND ts >= ${since}`, + )[0].c), { timeout: 15_000 }).toBeGreaterThanOrEqual(30); const res = await apiRequest('GET', '/api/apps/e2e-nginx/metrics?range=1h'); - expect(res.ok).toBe(true); - expect(res.data).toBeTruthy(); - expect(res.data.containers).toBeTruthy(); - const cids = Object.keys(res.data.containers || {}); - expect(cids.length).toBeGreaterThan(0); - let sawCpu = false; - for (const cid of cids) { - const pts = res.data.containers[cid].points || []; - for (const p of pts) { - if (typeof p.c === 'number' && p.c >= 0) { - sawCpu = true; - break; - } - } - if (sawCpu) break; - } - expect(sawCpu).toBe(true); - - const reqRows = sqliteQuery( - "SELECT COUNT(*) AS c FROM request_metrics WHERE app_id=(SELECT id FROM apps WHERE slug='e2e-nginx')", - ); - expect(reqRows.length).toBeGreaterThan(0); - expect(Number(reqRows[0].c)).toBeGreaterThan(0); + expect(res.status).toBe(200); + const points = Object.values(res.data.containers || {}).flatMap((c) => c.points || []); + expect(points.some((p) => typeof p.c === 'number' && p.c >= 0)).toBe(true); }); }); diff --git a/e2e/tests/11-app-versions.spec.js b/e2e/tests/11-app-versions.spec.js index cd6a291f..db4a59a7 100644 --- a/e2e/tests/11-app-versions.spec.js +++ b/e2e/tests/11-app-versions.spec.js @@ -1,128 +1,46 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; -import { findServiceContainer, containerImage } from '../helpers/docker.js'; - -test.describe('Deploy History & Rollback', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.locator('button').filter({ hasText: 'settings' }).click(); - }); - - test('shows deploy history', async ({ page }) => { - // Deploy History is a collapsible section with text "Deploy History (N)" - // It only appears if versions > 0; wait for config tab to finish loading - const historyBtn = page.locator('button').filter({ hasText: /Deploy History/i }); - await expect(historyBtn).toBeVisible({ timeout: 10_000 }); - }); - - test('deploy history has entries', async ({ page }) => { - // Expand the deploy history section - const historyBtn = page.locator('button').filter({ hasText: /Deploy History/i }); - await historyBtn.click(); - // Entries now display as timeline items with "v1", "v2", etc. in a span. - await expect(page.locator('span').filter({ hasText: /^v\d+$/ }).first()).toBeVisible({ timeout: 5_000 }); - }); -}); - -test.describe('Versions - Functional', () => { - test.describe.configure({ mode: 'serial' }); +import { apiLogin, apiRequest, getSessionCookie } from '../helpers/api.js'; +// e2e-nginx has two compose versions by now: the 03-deploy fixture +// (nginx:alpine) and the 07-app-config Save & Deploy (nginx:1.27-alpine). +test.describe('Deploy History', () => { test.beforeAll(async () => { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('new deployment creates new version with updated image', async () => { - // Baseline image (should be nginx:alpine from original fixture) - const containerBefore = findServiceContainer('e2e-nginx', 'web'); - expect(containerBefore, 'expected e2e-nginx web container').toBeTruthy(); - const imageBefore = containerImage(containerBefore); - expect(imageBefore).toContain('nginx'); - - // Version count before redeploy - const beforeRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(beforeRes.ok).toBeTruthy(); - const countBefore = beforeRes.data.length; - - // Redeploy with pinned tag nginx:1.27-alpine. Deploy endpoint accepts base64 compose. - const newCompose = [ - 'services:', - ' web:', - ' image: nginx:1.27-alpine', - ' ports:', - ' - "8091:80"', - ' labels:', - ' simpledeploy.endpoints.0.domain: "nginx-test.local"', - ' simpledeploy.endpoints.0.port: "80"', - ' simpledeploy.endpoints.0.tls: "off"', - '', - ].join('\n'); - const b64 = Buffer.from(newCompose, 'utf-8').toString('base64'); - const deployRes = await apiRequest('POST', '/api/apps/deploy', { - name: 'e2e-nginx', - compose: b64, - force: true, - }); - expect(deployRes.status).toBe(202); - - // Wait for app to be running with new image (poll up to 60s) - const deadline = Date.now() + 90_000; - let currentImage = imageBefore; - let containerNow = containerBefore; - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 2_000)); - containerNow = findServiceContainer('e2e-nginx', 'web'); - if (containerNow) { - try { - currentImage = containerImage(containerNow); - if (currentImage.includes('1.27')) break; - } catch {} - } - } - expect(currentImage).toContain('nginx:1.27-alpine'); - - // Wait for app status to stabilise before asserting versions - await waitForAppStatus('e2e-nginx', 'running', 60_000).catch(() => {}); - - // Versions list now has at least countBefore+1 (and newest-first) - const afterRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(afterRes.ok).toBeTruthy(); - expect(Array.isArray(afterRes.data)).toBe(true); - expect(afterRes.data.length).toBeGreaterThanOrEqual(countBefore + 1); - expect(afterRes.data.length).toBeGreaterThanOrEqual(2); - - // Newest first: compare first two timestamps (ms precision) - if (afterRes.data.length >= 2) { - const t0 = new Date(afterRes.data[0].created_at).getTime(); - const t1 = new Date(afterRes.data[1].created_at).getTime(); - expect(t0).toBeGreaterThanOrEqual(t1); + test('versions are newest first and the latest downloads as an attachment', async () => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); + expect(res.status).toBe(200); + const versions = res.data; + expect(versions.length).toBeGreaterThanOrEqual(2); + for (let i = 1; i < versions.length; i++) { + expect(versions[i - 1].version).toBeGreaterThan(versions[i].version); + expect(new Date(versions[i - 1].created_at).getTime()).toBeGreaterThanOrEqual(new Date(versions[i].created_at).getTime()); } + const latest = versions[0]; + const oldest = versions[versions.length - 1]; + expect(latest.content).toContain('image: nginx:1.27-alpine'); + expect(oldest.content).toContain('image: nginx:alpine'); - // Latest version's compose contains the new image tag - const latest = afterRes.data[0]; - expect(typeof latest.content).toBe('string'); - expect(latest.content).toContain('nginx:1.27-alpine'); + const dl = await fetch(`${getState().baseURL}/api/apps/e2e-nginx/versions/${latest.id}/download`, { + headers: { Cookie: getSessionCookie() }, + }); + expect(dl.status).toBe(200); + expect(dl.headers.get('content-disposition')).toMatch(/^attachment; filename=/); + expect(await dl.text()).toBe(latest.content); }); - test('version compose content is retrievable and valid', async () => { - const listRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(listRes.ok).toBeTruthy(); - expect(listRes.data.length).toBeGreaterThanOrEqual(1); - const latest = listRes.data[0]; + test('settings deploy history lists every stored version', async ({ page }) => { + const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); + expect(res.status).toBe(200); + const versions = res.data; - // Inline content from list - expect(typeof latest.content).toBe('string'); - expect(latest.content.length).toBeGreaterThan(0); - expect(latest.content).toMatch(/services\s*:/); - expect(latest.content).toContain('web'); - - // Download endpoint returns raw YAML (apiRequest falls through to text on non-JSON) - const dlRes = await apiRequest('GET', `/api/apps/e2e-nginx/versions/${latest.id}/download`); - expect(dlRes.ok).toBeTruthy(); - const body = typeof dlRes.data === 'string' ? dlRes.data : JSON.stringify(dlRes.data); - expect(body).toMatch(/services\s*:/); - expect(body).toContain('web'); + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/e2e-nginx`); + await page.getByRole('button', { name: 'settings', exact: true }).click(); + const main = page.locator('main'); + await main.getByRole('button', { name: `Deploy History (${versions.length})` }).click(); + await expect(main.locator('span').filter({ hasText: /^v\d+$/ })).toHaveText(versions.map((v) => `v${v.version}`)); }); }); From f25c11cc1a24346b9367e082216cdd96a8a39eeb Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:15:03 -0700 Subject: [PATCH 03/15] test(e2e): exact validation text match, scoped memory card, atomic image poll --- e2e/tests/03-deploy.spec.js | 8 ++++---- e2e/tests/03c-community-recipes.spec.js | 2 +- e2e/tests/04-dashboard.spec.js | 5 +++-- e2e/tests/07-app-config.spec.js | 12 +++++++----- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/e2e/tests/03-deploy.spec.js b/e2e/tests/03-deploy.spec.js index 428aeeb3..4e58b340 100644 --- a/e2e/tests/03-deploy.spec.js +++ b/e2e/tests/03-deploy.spec.js @@ -28,7 +28,7 @@ async function deployApp(page, appName, composeContent) { await dialog.getByPlaceholder('my-app').fill(appName); await dialog.getByRole('button', { name: 'YAML' }).click(); await dialog.locator('textarea').last().fill(composeContent); - await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); await expect(dialog.getByText('Deployed', { exact: true })).toBeVisible({ timeout: 300_000 }); @@ -75,7 +75,7 @@ test.describe('Deploy Apps', () => { expect(res.status()).toBe(200); expect((await res.json()).valid).toBe(false); await expect(dialog.getByText(/^Invalid compose file:/)).toBeVisible(); - await expect(dialog.getByText('Valid compose file')).toHaveCount(0); + await expect(dialog.getByText('Valid compose file', { exact: true })).toHaveCount(0); await expect(dialog.getByRole('button', { name: 'Next' })).toBeDisabled(); }); @@ -95,7 +95,7 @@ test.describe('Deploy Apps', () => { await dialog.locator('#tpl-var-domain').fill('tpl-e2e.localhost'); await dialog.getByRole('button', { name: /^apply/i }).click(); await dialog.getByPlaceholder('my-app').fill('e2e-nginx'); - await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); @@ -133,7 +133,7 @@ test.describe('Deploy Apps', () => { await dialog.locator('textarea').last().fill( "services:\n web:\n image: nginx:1.25-alpine\n ports:\n - '8080:80'\n" ); - await expect(dialog.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); await dialog.getByRole('button', { name: 'Next' }).click(); await dialog.getByRole('button', { name: 'Deploy' }).click(); diff --git a/e2e/tests/03c-community-recipes.spec.js b/e2e/tests/03c-community-recipes.spec.js index 77cd0f3b..bf9b8723 100644 --- a/e2e/tests/03c-community-recipes.spec.js +++ b/e2e/tests/03c-community-recipes.spec.js @@ -55,7 +55,7 @@ test.describe('community recipes', () => { await expect(wizard.locator('textarea').last()).toHaveValue(FIXTURE_RECIPE.compose); await expect(wizard.getByPlaceholder('my-app')).toHaveValue(FIXTURE_RECIPE.id); - await expect(wizard.getByText('Valid compose file')).toBeVisible({ timeout: 10_000 }); + await expect(wizard.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 10_000 }); // Using a recipe records a compose pull for the popularity report. const pop = await page.request.get(`${server.baseURL}/api/recipes/community/popularity`); diff --git a/e2e/tests/04-dashboard.spec.js b/e2e/tests/04-dashboard.spec.js index e163e0a8..876b8edd 100644 --- a/e2e/tests/04-dashboard.spec.js +++ b/e2e/tests/04-dashboard.spec.js @@ -29,8 +29,9 @@ test.describe('Dashboard', () => { const running = apps.filter((a) => a.Status === 'running').length; await expect(main.getByRole('button', { name: /^Running/ }).locator('span').last()).toHaveText(String(running)); - // Memory card sub-text reads " / "; both stay "0" without data. - await expect(main.getByText(/^\d+\.\d [KMGT]?B \/ \d+\.\d [KMGT]?B$/)).toBeVisible(); + // Memory stat card sub-text reads " / "; both stay "0" without data. + const memCard = main.getByText('Memory', { exact: true }).locator('xpath=../..'); + await expect(memCard.getByText(/^\d+\.\d [KMGT]?B \/ \d+\.\d [KMGT]?B$/)).toBeVisible(); await main.getByPlaceholder(/search/i).fill('nginx'); await expect(main.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible(); diff --git a/e2e/tests/07-app-config.spec.js b/e2e/tests/07-app-config.spec.js index 19cb5109..ef08ced0 100644 --- a/e2e/tests/07-app-config.spec.js +++ b/e2e/tests/07-app-config.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, waitForDeployDone } from '../helpers/api.js'; -import { findServiceContainer, containerImage } from '../helpers/docker.js'; +import { dockerCLI } from '../helpers/docker.js'; import { waitForProxyStatus } from '../helpers/proxy.js'; test.describe('App Configuration', () => { @@ -32,10 +32,12 @@ test.describe('App Configuration', () => { expect((await deployRes).status()).toBe(202); await expect(review).toBeHidden(); - await expect.poll(() => { - const name = findServiceContainer('e2e-nginx', 'web'); - return name ? containerImage(name) : ''; - }, { timeout: 90_000 }).toMatch(/(^|\/)nginx:1\.27-alpine$/); + // One docker ps call: the container is replaced mid-poll. + await expect.poll(() => dockerCLI([ + 'ps', '--format', '{{.Image}}', + '--filter', 'label=com.docker.compose.project=simpledeploy-e2e-nginx', + '--filter', 'label=com.docker.compose.service=web', + ]).trim(), { timeout: 90_000 }).toMatch(/^(\S+\/)?nginx:1\.27-alpine$/); expect((await waitForDeployDone('e2e-nginx')).Status).toBe('running'); await waitForProxyStatus('nginx-test.local', 200, { timeoutMs: 30_000, bodyContains: 'Welcome to nginx' }); }); From 189439b9c31a339a25a4ff99b654eb9fb8b623d9 Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:17:55 -0700 Subject: [PATCH 04/15] test(e2e): drop serial mode in app actions, restart e2e-nginx if left stopped --- e2e/tests/06-app-actions.spec.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/tests/06-app-actions.spec.js b/e2e/tests/06-app-actions.spec.js index 744ed009..0c65634e 100644 --- a/e2e/tests/06-app-actions.spec.js +++ b/e2e/tests/06-app-actions.spec.js @@ -29,14 +29,18 @@ async function expectActionComplete(page, action, timeout) { await expect(dialog).toBeHidden(); } -// Each test leaves the state the next one starts from (stopped -> started). +// stop leaves e2e-nginx stopped for start; the rest expect it running. test.describe('App Actions', () => { - test.describe.configure({ mode: 'serial' }); - test.beforeAll(async () => { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); + // Later specs need e2e-nginx running even if start failed here. + test.afterAll(async () => { + const app = await apiRequest('GET', '/api/apps/e2e-nginx'); + if (app.data?.Status === 'stopped') await apiRequest('POST', '/api/apps/e2e-nginx/start'); + }); + test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); From 0aa84d4f679ec52cd77a0af2dbbefeabdc15ce7a Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:01:53 -0700 Subject: [PATCH 05/15] test(e2e): shared isolated-server helper; rework TLS, rate limit, private registry and DR specs 26a un-skipped: the old failures were test bugs (server config written over the global sidecar at data_dir/config.yml, SIGTERM without waiting for the flush, invalid role/metric). It now fails on a product bug: per-app sidecars are pruned at boot before the reconciler re-creates app rows. --- e2e/helpers/isolated.js | 107 +++++++ e2e/helpers/server.js | 4 +- e2e/tests/26-tls-ssl.spec.js | 378 ++++++------------------- e2e/tests/26a-config-recovery.spec.js | 370 ++++++------------------ e2e/tests/29a-ratelimit.spec.js | 226 +++++---------- e2e/tests/29b-private-registry.spec.js | 202 +++---------- 6 files changed, 400 insertions(+), 887 deletions(-) create mode 100644 e2e/helpers/isolated.js diff --git a/e2e/helpers/isolated.js b/e2e/helpers/isolated.js new file mode 100644 index 00000000..d57a16dc --- /dev/null +++ b/e2e/helpers/isolated.js @@ -0,0 +1,107 @@ +// Helpers for specs that boot their own SimpleDeploy server (TLS modes, rate +// limits, private registries, disaster recovery) instead of the shared one. +import { copyFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { startServer, getBinaryPath } from './server.js'; +import { apiRequestAt } from './api.js'; +import { TEST_ADMIN } from './auth.js'; + +const LOG_DIR = join(import.meta.dirname, '..', 'test-results'); + +// loginAt logs in against an isolated server and returns the session cookie. +export async function loginAt(baseURL, username = TEST_ADMIN.username, password = TEST_ADMIN.password) { + const res = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { username, password }); + if (res.status !== 200 || !res.setCookie) { + throw new Error(`login as ${username} failed: ${res.status} ${JSON.stringify(res.data)}`); + } + return res.setCookie; +} + +function session(server, cookie) { + return (method, path, body) => apiRequestAt(server.baseURL, method, path, body, cookie); +} + +// startIsolatedServer boots a server on fresh ports (opts go to startServer), +// creates the admin via /api/setup (unless opts.setup === false) and logs in. +// api(method, path, body) is pre-bound to the server and session. If setup +// or login fails the server is stopped, keeping its log under opts.logName. +export async function startIsolatedServer(opts = {}) { + const { setup = true, admin = TEST_ADMIN, logName, ...serverOpts } = opts; + const server = await startServer(getBinaryPath(), serverOpts); + try { + if (setup) { + const res = await apiRequestAt(server.baseURL, 'POST', '/api/setup', { + username: admin.username, + password: admin.password, + display_name: admin.displayName, + email: admin.email, + }); + if (res.status !== 201) throw new Error(`setup failed: ${res.status} ${JSON.stringify(res.data)}`); + } + const cookie = await loginAt(server.baseURL, admin.username, admin.password); + return { server, cookie, api: session(server, cookie) }; + } catch (e) { + await stopIsolatedServer(server, logName); + throw e; + } +} + +// stopGracefully sends SIGTERM and resolves once the process exited, so +// shutdown work (sidecar flush, Caddy stop) has finished. SIGKILL after +// timeoutMs. Data dirs are left in place. +export function stopGracefully(server, timeoutMs = 20_000) { + const { proc } = server; + if (!isRunning(server)) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => proc.kill('SIGKILL'), timeoutMs); + proc.once('exit', () => { clearTimeout(timer); resolve(); }); + proc.kill('SIGTERM'); + }); +} + +export function isRunning(server) { + return server.proc.exitCode === null && server.proc.signalCode === null; +} + +// stopIsolatedServer stops the server, keeps its log as +// test-results/-server.log (when logName is given) and removes its +// temp dirs. +export async function stopIsolatedServer(server, logName) { + if (!server) return; + await stopGracefully(server); + if (logName) { + try { + mkdirSync(LOG_DIR, { recursive: true }); + copyFileSync(server.logPath, join(LOG_DIR, `${logName}-server.log`)); + } catch {} + } + for (const dir of [server.dataDir, server.appsDir]) { + rmSync(dir, { recursive: true, force: true }); + } +} + +// deployAt posts a compose file to /api/apps/deploy and asserts the 202. +export async function deployAt(api, name, composeYAML) { + const res = await api('POST', '/api/apps/deploy', { + name, + compose: Buffer.from(composeYAML).toString('base64'), + }); + if (res.status !== 202) throw new Error(`deploy ${name} failed: ${res.status} ${JSON.stringify(res.data)}`); + return res; +} + +// waitForAppStatusAt polls GET /api/apps/ on an isolated server until +// Status is one of `status` (string or array). Returns the app. +export async function waitForAppStatusAt(baseURL, cookie, slug, status, timeoutMs = 180_000) { + const want = Array.isArray(status) ? status : [status]; + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); + last = res; + // store.App has no JSON tags; fields serialize as PascalCase. + if (res.status === 200 && want.includes(res.data.Status)) return res.data; + await new Promise((r) => setTimeout(r, 1_000)); + } + throw new Error(`app ${slug} did not reach ${want.join('|')} within ${timeoutMs}ms; last: ${last && last.status} ${JSON.stringify(last && last.data)}`); +} diff --git a/e2e/helpers/server.js b/e2e/helpers/server.js index 99d1a9d3..c7756720 100644 --- a/e2e/helpers/server.js +++ b/e2e/helpers/server.js @@ -36,7 +36,9 @@ export async function startServer(binPath, overrides = {}) { const proxyPort = overrides.proxyPort || await getAvailablePort(); const dataDir = overrides.dataDir || mkdtempSync(join(tmpdir(), 'sd-e2e-data-')); const appsDir = overrides.appsDir || mkdtempSync(join(tmpdir(), 'sd-e2e-apps-')); - const configPath = join(dataDir, 'config.yml'); + // Not data_dir/config.yml: that path is SimpleDeploy's own global sidecar, + // which would overwrite this file (and a restart would read it back). + const configPath = join(dataDir, 'server-config.yaml'); const logPath = join(dataDir, 'server.log'); const tlsMode = overrides.tlsMode || 'off'; // ratelimit override only affects the auth/API rate limiter (cfg.RateLimit). diff --git a/e2e/tests/26-tls-ssl.spec.js b/e2e/tests/26-tls-ssl.spec.js index 1d751e3a..d1a396f0 100644 --- a/e2e/tests/26-tls-ssl.spec.js +++ b/e2e/tests/26-tls-ssl.spec.js @@ -1,331 +1,119 @@ // 26-tls-ssl.spec.js // -// Verifies simpledeploy TLS handling end-to-end. The main e2e server runs with -// tls.mode=off, so this spec spins up a *separate* isolated server with -// tls.mode='local' (Caddy internal CA, self-signed) for most TLS assertions. -// It also asserts that the main server (tls off) exposes plain HTTP and no -// HTTPS, and that endpoint-level TLS config persists through the API. -// -// NOTE: the isolated server uses the binary already built by global-setup -// (via getBinaryPath) — no rebuild. +// TLS end-to-end on an isolated server with tls.mode=local (Caddy internal +// CA). One app, two endpoints: tls-local.test keeps the default tls (cert +// from the internal CA); custom-tls.test is switched to an uploaded cert. +// The plain-HTTP (tls=off) path is covered by 03-deploy and 08-app-endpoints. import { test, expect } from '@playwright/test'; import { execFileSync } from 'child_process'; -import { writeFileSync, mkdtempSync, readFileSync, existsSync } from 'fs'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { startServer, stopServer, getBinaryPath } from '../helpers/server.js'; import { curlHTTPS, openSSLGetCert } from '../helpers/proxy.js'; -import { apiRequestAt, apiRequest } from '../helpers/api.js'; -import { getState, TEST_ADMIN } from '../helpers/auth.js'; +import { startIsolatedServer, stopIsolatedServer, deployAt, waitForAppStatusAt } from '../helpers/isolated.js'; test.describe.configure({ mode: 'serial' }); -// -------- helpers -------- - -function haveOpenSSL() { - try { - execFileSync('openssl', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] }); - return true; - } catch { return false; } -} - -async function setupAdminAndLogin(baseURL) { - // Create the first user (setup endpoint only works when user count is 0). - const setup = await apiRequestAt(baseURL, 'POST', '/api/setup', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - display_name: TEST_ADMIN.displayName, - email: TEST_ADMIN.email, - }); - expect(setup.status, 'initial setup should succeed').toBe(201); - - const login = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - }); - expect(login.status, 'login should succeed').toBe(200); - expect(login.setCookie, 'login should set session cookie').toBeTruthy(); - return login.setCookie; -} - -async function waitForAppRunning(baseURL, cookie, slug, timeoutMs = 180_000) { +const APP = 'e2e-tls'; +const LOCAL_DOMAIN = 'tls-local.test'; +const CUSTOM_DOMAIN = 'custom-tls.test'; + +const COMPOSE = [ + 'services:', + ' web:', + ' image: nginx:alpine', + ' ports:', + ' - "8191:80"', + ' labels:', + ` simpledeploy.endpoints.0.domain: "${LOCAL_DOMAIN}"`, + ' simpledeploy.endpoints.0.port: "80"', + ` simpledeploy.endpoints.1.domain: "${CUSTOM_DOMAIN}"`, + ' simpledeploy.endpoints.1.port: "80"', +].join('\n'); + +async function pollUntil(fn, ok, timeoutMs, what) { const deadline = Date.now() + timeoutMs; - let last = null; + let last; while (Date.now() < deadline) { - const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); - last = res; - // store.App has no JSON tags — fields serialize as PascalCase (Status). - const status = res.data && (res.data.Status || res.data.status); - if (res.ok && status === 'running') return res.data; - await new Promise((r) => setTimeout(r, 1500)); + last = fn(); + if (ok(last)) return last; + await new Promise((r) => setTimeout(r, 500)); } - throw new Error(`app ${slug} did not reach running. last: ${JSON.stringify(last && last.data)}`); -} - -function composeYAMLForDomain(domain, hostPort) { - // A simple nginx single-service compose with one endpoint pointed at `domain`. - // tls is left unset so the per-endpoint default ("auto") applies and Caddy - // automatic_https picks up the host (server tls.mode must be local/auto for - // certs to actually be issued). - return [ - 'services:', - ' web:', - ' image: nginx:alpine', - ' ports:', - ` - "${hostPort}:80"`, - ' labels:', - ` simpledeploy.endpoints.0.domain: "${domain}"`, - ' simpledeploy.endpoints.0.port: "80"', - ' simpledeploy.endpoints.0.service: "web"', - ].join('\n'); + throw new Error(`${what} not reached within ${timeoutMs}ms; last: ${JSON.stringify(last).slice(0, 300)}`); } -// -------- describe 1: local (self-signed) TLS mode -------- - -test.describe('TLS mode=local (self-signed via Caddy internal CA)', () => { +test.describe('TLS mode=local', () => { let server = null; - let cookie = null; - const appName = 'e2e-tls-local'; - const domain = 'tls-local.test'; + let api = null; test.beforeAll(async () => { test.setTimeout(240_000); - const bin = getBinaryPath(); - if (!existsSync(bin)) test.skip(true, `binary not found at ${bin}`); - server = await startServer(bin, { tlsMode: 'local' }); - cookie = await setupAdminAndLogin(server.baseURL); - - // Deploy nginx via API. Compose content is base64 as required by handleDeploy. - const yaml = composeYAMLForDomain(domain, 8191); - const deploy = await apiRequestAt(server.baseURL, 'POST', '/api/apps/deploy', { - name: appName, - compose: Buffer.from(yaml).toString('base64'), - }, cookie); - expect(deploy.status, `deploy failed: ${JSON.stringify(deploy.data)}`).toBe(202); - - await waitForAppRunning(server.baseURL, cookie, appName); - // Give Caddy a couple seconds to issue the internal cert + hook up route. - await new Promise((r) => setTimeout(r, 3000)); + let cookie; + ({ server, cookie, api } = await startIsolatedServer({ tlsMode: 'local' })); + await deployAt(api, APP, COMPOSE); + await waitForAppStatusAt(server.baseURL, cookie, APP, 'running'); }); test.afterAll(async () => { - if (server) { - try { - // Best-effort: ask simpledeploy to tear down the app before stopping - // the server to avoid leaking containers/networks. - await apiRequestAt(server.baseURL, 'DELETE', `/api/apps/${appName}`, null, cookie); - } catch {} - stopServer(server); - } + if (!server) return; + await api('DELETE', `/api/apps/${APP}`); + await stopIsolatedServer(server, '26-tls'); }); - test('HTTPS request on self-signed cert returns 200 with nginx body', async () => { - const res = curlHTTPS(domain, server.proxyPort, '/'); - expect(res.status).toBe(200); - expect(res.body).toContain('Welcome to nginx'); - }); - - test('cert is issued by Caddy internal CA (self-signed)', async () => { - if (!haveOpenSSL()) test.skip(true, 'openssl not installed'); - const info = openSSLGetCert(domain, server.proxyPort); - expect(info.error, `openssl error: ${info.error}`).toBeFalsy(); - // Caddy internal CA issuer contains "Caddy" in CN/O (e.g. "Caddy Local Authority - ECC Intermediate"). - const issuer = info.issuer || ''; - expect( - /Caddy/i.test(issuer) || /local/i.test(issuer), - `expected Caddy/local issuer, got: ${issuer}`, - ).toBe(true); + test('default endpoint serves the app over HTTPS with a Caddy internal CA cert', async () => { + await pollUntil( + () => curlHTTPS(LOCAL_DOMAIN, server.proxyPort, '/'), + (r) => r.status === 200 && r.body.includes('Welcome to nginx'), + 30_000, `HTTPS 200 from ${LOCAL_DOMAIN}`, + ); + const cert = openSSLGetCert(LOCAL_DOMAIN, server.proxyPort); + expect(cert.error, `openssl: ${cert.error}`).toBeUndefined(); + expect(cert.issuer).toMatch(/Caddy Local Authority/); }); - test('HTTP on the same listener does not serve 200 (Caddy redirects or refuses)', async () => { - // With tls.mode=local, Caddy will typically do automatic_https and emit a - // redirect (or refuse plain HTTP on the TLS port). Either way the code - // should NOT be 200. - const args = [ - '-sS', '-o', '/dev/null', '-w', '%{http_code}', - '--max-time', '5', - '--resolve', `${domain}:${server.proxyPort}:127.0.0.1`, - `http://${domain}:${server.proxyPort}/`, - ]; - let code = '0'; + test('uploaded cert is served for its domain only', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sd-e2e-tls-custom-')); try { - code = execFileSync('curl', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); - } catch (e) { - // Connection refused / reset => treated as "not 200", which is what we want. - code = '0'; + const crt = join(dir, 'custom.crt'); + const key = join(dir, 'custom.key'); + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '30', + '-keyout', key, '-out', crt, + '-subj', `/CN=${CUSTOM_DOMAIN}`, + '-addext', `subjectAltName=DNS:${CUSTOM_DOMAIN}`, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const up = await api('PUT', `/api/apps/${APP}/certs/${CUSTOM_DOMAIN}`, { + cert: readFileSync(crt, 'utf-8'), + key: readFileSync(key, 'utf-8'), + }); + expect(up.status, JSON.stringify(up.data)).toBe(200); + } finally { + rmSync(dir, { recursive: true, force: true }); } - expect(code).not.toBe('200'); - }); -}); - -// -------- describe 2: custom cert via upload API -------- - -test.describe('Custom TLS cert via /api/apps/{slug}/certs/{domain}', () => { - let server = null; - let cookie = null; - let tmpDir = null; - const appName = 'e2e-tls-custom'; - const domain = 'custom-tls.test'; - - test.beforeAll(async () => { - test.setTimeout(240_000); - if (!haveOpenSSL()) test.skip(true, 'openssl not installed'); - - const bin = getBinaryPath(); - if (!existsSync(bin)) test.skip(true, `binary not found at ${bin}`); - - tmpDir = mkdtempSync(join(tmpdir(), 'sd-e2e-tls-custom-')); - const crtPath = join(tmpDir, 'custom.crt'); - const keyPath = join(tmpDir, 'custom.key'); - - // Generate a self-signed cert + key for the domain. The server never sees - // these files directly — we upload them via the API so simpledeploy writes - // them to the app's certs/ dir with the filename layout Caddy expects. - execFileSync('openssl', [ - 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '30', - '-keyout', keyPath, - '-out', crtPath, - '-subj', `/CN=${domain}`, - '-addext', `subjectAltName=DNS:${domain}`, - ], { stdio: ['ignore', 'pipe', 'pipe'] }); - - // Start server in tls.mode=local. With local mode, Caddy already loads a - // TLS app; any routes with TLS='custom' + CertDir will have load_files - // appended, so Caddy will serve the uploaded cert for that host. - server = await startServer(bin, { tlsMode: 'local' }); - cookie = await setupAdminAndLogin(server.baseURL); - // Deploy nginx first (needed before cert upload endpoint knows the app). - const yaml = composeYAMLForDomain(domain, 8192); - const deploy = await apiRequestAt(server.baseURL, 'POST', '/api/apps/deploy', { - name: appName, - compose: Buffer.from(yaml).toString('base64'), - }, cookie); - expect(deploy.status).toBe(202); - await waitForAppRunning(server.baseURL, cookie, appName); - - // Upload the PEM cert + key. - const certPEM = readFileSync(crtPath, 'utf-8'); - const keyPEM = readFileSync(keyPath, 'utf-8'); - const up = await apiRequestAt( - server.baseURL, - 'PUT', - `/api/apps/${appName}/certs/${domain}`, - { cert: certPEM, key: keyPEM }, - cookie, - ); - expect(up.status, `cert upload failed: ${JSON.stringify(up.data)}`).toBe(200); - - // Flip endpoint TLS to 'custom' so the route becomes TLS='custom' with a - // cert_dir, triggering Caddy load_files for the uploaded PEM. - const epUpdate = await apiRequestAt( - server.baseURL, - 'PUT', - `/api/apps/${appName}/endpoints`, - [{ domain, port: '80', tls: 'custom', service: 'web' }], - cookie, + const app = await api('GET', `/api/apps/${APP}`); + expect(app.status).toBe(200); + const endpoints = app.data.endpoints.map((e) => (e.domain === CUSTOM_DOMAIN ? { ...e, tls: 'custom' } : e)); + expect(endpoints.map((e) => e.domain).sort()).toEqual([CUSTOM_DOMAIN, LOCAL_DOMAIN]); + const put = await api('PUT', `/api/apps/${APP}/endpoints`, endpoints); + expect(put.status, JSON.stringify(put.data)).toBe(200); + + // Caddy's internal leaf certs have an empty subject, so a CN match means + // the uploaded PEM is the one being served. + const cert = await pollUntil( + () => openSSLGetCert(CUSTOM_DOMAIN, server.proxyPort), + (c) => (c.subject || '').includes(CUSTOM_DOMAIN), + 30_000, `uploaded cert served for ${CUSTOM_DOMAIN}`, ); - expect(epUpdate.status, `endpoint update failed: ${JSON.stringify(epUpdate.data)}`).toBe(200); - - // Give reconciler + Caddy time to reload with the new cert. - await new Promise((r) => setTimeout(r, 5000)); - }); - - test.afterAll(async () => { - if (server) { - try { - await apiRequestAt(server.baseURL, 'DELETE', `/api/apps/${appName}`, null, cookie); - } catch {} - stopServer(server); - } - }); + expect(cert.issuer).not.toMatch(/Caddy/); - test('HTTPS request on custom cert returns 200', async () => { - const res = curlHTTPS(domain, server.proxyPort, '/'); - expect(res.status, `body: ${res.body} err: ${res.error}`).toBe(200); + const res = curlHTTPS(CUSTOM_DOMAIN, server.proxyPort, '/'); + expect(res.status, res.error).toBe(200); expect(res.body).toContain('Welcome to nginx'); - }); - - test('served cert subject CN matches uploaded cert', async () => { - if (!haveOpenSSL()) test.skip(true, 'openssl not installed'); - const info = openSSLGetCert(domain, server.proxyPort); - expect(info.error, `openssl error: ${info.error}`).toBeFalsy(); - // Subject line looks like "subject= CN=custom-tls.test" (order/format varies). - const subject = info.subject || ''; - expect(subject).toContain(domain); - }); -}); - -// -------- describe 3: main server (tls.mode=off) behavior -------- - -test.describe('Main server TLS mode=off', () => { - test('serves plain HTTP on the proxy port', async () => { - const state = getState(); - const args = [ - '-sS', '-o', '-', - '-w', '\n__HTTP_CODE__%{http_code}', - '--resolve', `nginx-test.local:${state.proxyPort}:127.0.0.1`, - `http://nginx-test.local:${state.proxyPort}/`, - ]; - let out = ''; - try { - out = execFileSync('curl', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); - } catch (e) { - throw new Error(`curl http failed: ${e.message}`); - } - const m = out.match(/\n__HTTP_CODE__(\d+)$/); - const code = m ? Number(m[1]) : 0; - expect(code).toBe(200); - expect(out).toContain('Welcome to nginx'); - }); - - test('HTTPS fails on the plain-HTTP listener', async () => { - const state = getState(); - const res = curlHTTPS('nginx-test.local', state.proxyPort, '/'); - // No TLS is served at all, so the TLS handshake should fail => status 0 - // (curl returns an error, our helper catches and returns { status: 0 }). - // Even if the server doesn't outright reject, it cannot be 200 via HTTPS. - expect(res.status).not.toBe(200); - }); -}); - -// -------- describe 4: endpoint TLS config persists through API -------- - -test.describe('Endpoint TLS field persistence (main server)', () => { - const slug = 'e2e-nginx'; - let originalEndpoints = null; - - test.beforeAll(async () => { - // Log in against main server to seed the shared apiRequest session cookie. - await apiRequest('POST', '/api/auth/login', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - }); - const got = await apiRequest('GET', `/api/apps/${slug}`); - if (!got.ok) test.skip(true, `app ${slug} not present on main server`); - originalEndpoints = (got.data.endpoints || []).map((e) => ({ - domain: e.domain, - port: e.port, - tls: e.tls, - service: e.service, - })); - }); - - test.afterAll(async () => { - if (originalEndpoints && originalEndpoints.length) { - await apiRequest('PUT', `/api/apps/${slug}/endpoints`, originalEndpoints); - } - }); - - test('PUT endpoints with tls=letsencrypt persists, then restores', async () => { - const updated = originalEndpoints.map((e) => ({ ...e, tls: 'letsencrypt' })); - const put = await apiRequest('PUT', `/api/apps/${slug}/endpoints`, updated); - expect(put.status, `put failed: ${JSON.stringify(put.data)}`).toBe(200); - const got = await apiRequest('GET', `/api/apps/${slug}`); - expect(got.ok).toBe(true); - const tlsVals = (got.data.endpoints || []).map((e) => e.tls); - expect(tlsVals.every((v) => v === 'letsencrypt')).toBe(true); + // The other endpoint keeps its internal CA cert. + expect(openSSLGetCert(LOCAL_DOMAIN, server.proxyPort).issuer).toMatch(/Caddy Local Authority/); }); }); diff --git a/e2e/tests/26a-config-recovery.spec.js b/e2e/tests/26a-config-recovery.spec.js index 5c42a278..e4981e4f 100644 --- a/e2e/tests/26a-config-recovery.spec.js +++ b/e2e/tests/26a-config-recovery.spec.js @@ -1,296 +1,110 @@ -/** - * DR: sidecar config recovery after DB wipe. - * - * Spawns its own isolated server (fresh data + apps dirs). Does NOT touch the - * shared server from global-setup. Requires the binary to be built first: - * make build-go # or make build - * - * Run standalone: - * cd e2e && npx playwright test 26a-config-recovery.spec.js --reporter=list - */ +// 26a-config-recovery.spec.js +// +// Disaster recovery from config sidecars (docs/operations/config-sidecars.md): +// stop the server, delete the SQLite DB, start again with the same data and +// apps dirs. Users, API keys, registries and webhooks come back from +// {data_dir}/config.yml (+ secrets.yml); alert rules and access grants from +// {apps_dir}//simpledeploy.yml. Runs on its own isolated server. import { test, expect } from '@playwright/test'; -import { rmSync, existsSync, unlinkSync, mkdtempSync } from 'fs'; +import { existsSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { tmpdir } from 'os'; -import { getBinaryPath, startServer } from '../helpers/server.js'; -import { apiRequestAt } from '../helpers/api.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +import { + startIsolatedServer, stopIsolatedServer, stopGracefully, isRunning, loginAt, deployAt, waitForAppStatusAt, +} from '../helpers/isolated.js'; const ADMIN = { username: 'dr-admin', password: 'DrTestPass123!', displayName: 'DR Admin', email: 'dr@test.local' }; +const EDITOR = { username: 'dr-editor', password: 'DrEditor123!' }; +const APP = 'dr-nginx'; +const COMPOSE = 'services:\n web:\n image: nginx:alpine\n'; -/** Bootstrap admin account via POST /api/setup. */ -async function setupAdmin(baseURL) { - const res = await apiRequestAt(baseURL, 'POST', '/api/setup', ADMIN, null); - if (!res.ok) throw new Error(`setup failed: ${res.status} ${JSON.stringify(res.data)}`); - return res; -} - -/** Login and return sessionCookie. */ -async function login(baseURL) { - const res = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { - username: ADMIN.username, - password: ADMIN.password, - }, null); - if (!res.ok) throw new Error(`login failed: ${res.status} ${JSON.stringify(res.data)}`); - if (!res.setCookie) throw new Error('no session cookie after login'); - return res.setCookie; -} - -/** Authenticated request against an isolated server. */ -function at(baseURL, cookie) { - return (method, path, body) => apiRequestAt(baseURL, method, path, body, cookie); -} - -/** Minimal nginx compose (no external ports to avoid collision). */ -const COMPOSE_NGINX = `services: - web: - image: nginx:alpine -`; - -/** Stop a server process without deleting its data dirs. */ -function stopProc(srv) { - if (!srv) return; - try { srv.proc.kill('SIGTERM'); } catch {} -} - -/** Delete SQLite DB files from dataDir. */ -function wipeDB(dataDir) { - for (const suffix of ['simpledeploy.db', 'simpledeploy.db-wal', 'simpledeploy.db-shm']) { - const p = join(dataDir, suffix); - if (existsSync(p)) unlinkSync(p); - } -} - -/** Wait until GET /api/health returns 200. */ -async function waitHealthy(baseURL, timeoutMs = 20_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(`${baseURL}/api/health`); - if (res.ok) return; - } catch {} - await new Promise((r) => setTimeout(r, 300)); - } - throw new Error(`server at ${baseURL} not healthy after ${timeoutMs}ms`); -} - -// --------------------------------------------------------------------------- -// Test -// --------------------------------------------------------------------------- - -// TODO: this DR test is aspirational. Multiple layers still need work: -// the sync debounce doesn't guarantee a flush before server stop, and -// login-after-restore fails. Skip until the sidecar recovery flow is -// re-proven end to end. -test.describe.skip('DR: sidecar recovery after DB wipe', () => { - test.setTimeout(120_000); - - let srv1 = null; +test.describe('DR: sidecar recovery after DB wipe', () => { + let server = null; + let api = null; test.afterAll(async () => { - // Kill any leftover server. Dirs are cleaned below or already cleaned. - stopProc(srv1); - if (srv1) { - for (const dir of [srv1.dataDir, srv1.appsDir]) { - try { rmSync(dir, { recursive: true, force: true }); } catch {} - } - } + if (!server) return; + if (isRunning(server)) await api('DELETE', `/api/apps/${APP}`); + await stopIsolatedServer(server, '26a-config-recovery'); }); - test('DB wipe and restart recovers app, webhook, alert rule, registry, user', async () => { - const bin = getBinaryPath(); - - // ----------------------------------------------------------------------- - // Phase 1: start fresh server, seed data - // ----------------------------------------------------------------------- - srv1 = await startServer(bin); - const { baseURL, dataDir, appsDir } = srv1; + test('restart on a wiped DB restores config from the sidecars', async () => { + test.setTimeout(240_000); + let cookie; + ({ server, cookie, api } = await startIsolatedServer({ admin: ADMIN })); + const { dataDir, appsDir } = server; - await setupAdmin(baseURL); - const cookie = await login(baseURL); - const api = at(baseURL, cookie); + // Seed one of each mirrored setting. + await deployAt(api, APP, COMPOSE); + const app = await waitForAppStatusAt(server.baseURL, cookie, APP, 'running'); - // Deploy nginx app - const deployRes = await api('POST', '/api/apps/deploy', { - name: 'dr-nginx', - compose: Buffer.from(COMPOSE_NGINX).toString('base64'), + const wh = await api('POST', '/api/webhooks', { name: 'dr-hook', type: 'slack', url: 'http://127.0.0.1:9/dr-hook' }); + expect(wh.status, JSON.stringify(wh.data)).toBe(201); + const rule = await api('POST', '/api/alerts/rules', { + app_id: app.ID, webhook_id: wh.data.id, metric: 'cpu_pct', operator: '>', threshold: 90, duration_sec: 300, enabled: true, }); - expect(deployRes.ok, `deploy failed: ${JSON.stringify(deployRes.data)}`).toBe(true); - - // Wait for app to appear in list (reconciler may take a moment) - let appFound = false; - const appDeadline = Date.now() + 30_000; - while (Date.now() < appDeadline) { - const r = await api('GET', '/api/apps', null); - if (r.ok && Array.isArray(r.data) && r.data.some((a) => a.slug === 'dr-nginx' || a.Slug === 'dr-nginx')) { - appFound = true; - break; - } - await new Promise((r) => setTimeout(r, 500)); + expect(rule.status, JSON.stringify(rule.data)).toBe(201); + const reg = await api('POST', '/api/registries', { name: 'dr-ghcr', url: 'ghcr.io', username: 'dr-user', password: 'dr-fake-token' }); + expect(reg.status, JSON.stringify(reg.data)).toBe(201); + const user = await api('POST', '/api/users', { ...EDITOR, role: 'manage', display_name: 'DR Editor', email: 'editor@dr.local' }); + expect(user.status, JSON.stringify(user.data)).toBe(201); + const grant = await api('POST', `/api/users/${user.data.id}/access`, { app_slug: APP }); + expect(grant.status, JSON.stringify(grant.data)).toBe(200); + const key = await api('POST', '/api/apikeys', { name: 'dr-key' }); + expect(key.status, JSON.stringify(key.data)).toBe(201); + + // Stop right after the last change: shutdown must flush the debounced + // sidecar writes. + await stopGracefully(server); + + const globalSidecar = readFileSync(join(dataDir, 'config.yml'), 'utf-8'); + for (const re of [/username: dr-admin/, /username: dr-editor/, /name: dr-hook/, /name: dr-ghcr/, /name: dr-key/]) { + expect(globalSidecar, 'config.yml after shutdown').toMatch(re); + } + const appSidecarPath = join(appsDir, APP, 'simpledeploy.yml'); + const appSidecar = readFileSync(appSidecarPath, 'utf-8'); + for (const re of [/metric: cpu_pct/, /webhook: dr-hook/, /username: dr-editor/]) { + expect(appSidecar, 'simpledeploy.yml after shutdown').toMatch(re); } - expect(appFound, 'dr-nginx not visible in /api/apps after deploy').toBe(true); - - // Create webhook - const whRes = await api('POST', '/api/webhooks', { - name: 'dr-slack', - url: 'https://hooks.slack.com/fake/dr-test', - type: 'slack', - events: ['deploy_success'], - }); - expect(whRes.ok, `webhook create failed: ${JSON.stringify(whRes.data)}`).toBe(true); - const webhookId = whRes.data.id || whRes.data.ID; - expect(webhookId).toBeTruthy(); - - // Look up app_id (alerts API expects numeric id, not slug) - const appsRes = await api('GET', '/api/apps', null); - const drApp = (appsRes.data || []).find((a) => (a.slug || a.Slug) === 'dr-nginx'); - expect(drApp, 'dr-nginx app not found').toBeTruthy(); - const appId = drApp.id || drApp.ID; - - // Create alert rule - const ruleRes = await api('POST', '/api/alerts/rules', { - app_id: appId, - webhook_id: webhookId, - metric: 'cpu_pct', - operator: '>', - threshold: 90, - duration_sec: 300, - enabled: true, - }); - expect(ruleRes.ok, `alert rule create failed: ${JSON.stringify(ruleRes.data)}`).toBe(true); - const ruleId = ruleRes.data.id || ruleRes.data.ID; - expect(ruleId).toBeTruthy(); - - // Create registry - const regRes = await api('POST', '/api/registries', { - name: 'dr-ghcr', - url: 'ghcr.io', - username: 'dr-user', - password: 'dr-fake-token', - }); - expect(regRes.ok, `registry create failed: ${JSON.stringify(regRes.data)}`).toBe(true); - const registryId = regRes.data.id || regRes.data.ID; - expect(registryId).toBeTruthy(); - - // Create additional user - const userRes = await api('POST', '/api/users', { - username: 'dr-editor', - password: 'DrEditor123!', - role: 'admin', - }); - expect(userRes.ok, `user create failed: ${JSON.stringify(userRes.data)}`).toBe(true); - - // ----------------------------------------------------------------------- - // Phase 2: verify sidecars exist - // ----------------------------------------------------------------------- - // Give syncer a moment to flush sidecars - await new Promise((r) => setTimeout(r, 1_000)); - - const globalSidecar = join(dataDir, 'config.yml'); - const appSidecar = join(appsDir, 'dr-nginx', 'simpledeploy.yml'); - - expect(existsSync(globalSidecar), `global sidecar missing: ${globalSidecar}`).toBe(true); - expect(existsSync(appSidecar), `app sidecar missing: ${appSidecar}`).toBe(true); - - // ----------------------------------------------------------------------- - // Phase 3: stop server 1, wipe DB - // ----------------------------------------------------------------------- - stopProc(srv1); - await new Promise((r) => setTimeout(r, 500)); - - wipeDB(dataDir); - - expect(existsSync(join(dataDir, 'simpledeploy.db')), 'DB should be deleted').toBe(false); - // Sidecars must still be present - expect(existsSync(globalSidecar), 'global sidecar must survive wipe').toBe(true); - expect(existsSync(appSidecar), 'app sidecar must survive wipe').toBe(true); - - // ----------------------------------------------------------------------- - // Phase 4: start second server with SAME dataDir + appsDir - // ----------------------------------------------------------------------- - const { port: port2, proxyPort: proxyPort2 } = srv1; // ports are freed; grab new ones via startServer - const srv2 = await startServer(bin, { dataDir, appsDir }); - // Update srv1 ref so afterAll cleanup handles dirs - srv1 = null; // dirs already owned by srv2; prevent double-delete in afterAll - - const baseURL2 = srv2.baseURL; - - try { - // ----------------------------------------------------------------------- - // Phase 5: login with same admin creds (proves user recovery from config.yml) - // ----------------------------------------------------------------------- - const cookie2 = await login(baseURL2); - const api2 = at(baseURL2, cookie2); - - // ----------------------------------------------------------------------- - // Phase 6: assert config recovery - // ----------------------------------------------------------------------- - - // App recovered - const appsRes = await api2('GET', '/api/apps', null); - expect(appsRes.ok, `GET /api/apps failed: ${appsRes.status}`).toBe(true); - const apps = Array.isArray(appsRes.data) ? appsRes.data : []; - const recoveredApp = apps.find((a) => (a.slug || a.Slug) === 'dr-nginx'); - expect(recoveredApp, 'dr-nginx app not recovered after DB wipe').toBeTruthy(); - - // Webhook recovered - const whListRes = await api2('GET', '/api/webhooks', null); - expect(whListRes.ok, `GET /api/webhooks failed: ${whListRes.status}`).toBe(true); - const webhooks = Array.isArray(whListRes.data) ? whListRes.data : []; - const recoveredWh = webhooks.find((w) => (w.name || w.Name) === 'dr-slack'); - expect(recoveredWh, 'dr-slack webhook not recovered').toBeTruthy(); - - // Alert rule recovered - const rulesRes = await api2('GET', '/api/alerts/rules', null); - expect(rulesRes.ok, `GET /api/alerts/rules failed: ${rulesRes.status}`).toBe(true); - const rules = Array.isArray(rulesRes.data) ? rulesRes.data : []; - const recoveredRule = rules.find( - (r) => (r.app_slug || r.AppSlug) === 'dr-nginx' && (r.metric || r.Metric) === 'cpu', - ); - expect(recoveredRule, 'alert rule not recovered').toBeTruthy(); - - // Registry recovered - const regListRes = await api2('GET', '/api/registries', null); - expect(regListRes.ok, `GET /api/registries failed: ${regListRes.status}`).toBe(true); - const registries = Array.isArray(regListRes.data) ? regListRes.data : []; - const recoveredReg = registries.find((r) => (r.name || r.Name) === 'dr-ghcr'); - expect(recoveredReg, 'dr-ghcr registry not recovered').toBeTruthy(); - - // Extra user recovered - const usersRes = await api2('GET', '/api/users', null); - expect(usersRes.ok, `GET /api/users failed: ${usersRes.status}`).toBe(true); - const users = Array.isArray(usersRes.data) ? usersRes.data : []; - const recoveredUser = users.find((u) => (u.username || u.Username) === 'dr-editor'); - expect(recoveredUser, 'dr-editor user not recovered').toBeTruthy(); - - // ----------------------------------------------------------------------- - // Phase 7: historical tables are empty (no metrics, no deploy events) - // ----------------------------------------------------------------------- - const metricsRes = await api2('GET', '/api/apps/dr-nginx/metrics?range=1h', null); - // Endpoint may return 200 with empty data or 404 if no metrics recorded. - // Either is acceptable; what matters is no crash. - if (metricsRes.ok && metricsRes.data && typeof metricsRes.data === 'object') { - const series = metricsRes.data.series || metricsRes.data.data || []; - // series may be an empty array or contain zero data points; both are fine. - // We just assert it's an array (no unexpected shape). - expect(Array.isArray(series) || series === null).toBe(true); - } - const eventsRes = await api2('GET', '/api/apps/dr-nginx/events', null); - if (eventsRes.ok) { - const events = Array.isArray(eventsRes.data) ? eventsRes.data : []; - // Deploy events are historical; should be empty after DB wipe. - expect(events.length).toBe(0); - } - } finally { - stopProc(srv2); - for (const dir of [srv2.dataDir, srv2.appsDir]) { - try { rmSync(dir, { recursive: true, force: true }); } catch {} - } + for (const f of ['simpledeploy.db', 'simpledeploy.db-wal', 'simpledeploy.db-shm']) { + rmSync(join(dataDir, f), { force: true }); } + expect(existsSync(join(dataDir, 'simpledeploy.db'))).toBe(false); + + // Same dirs, fresh ports. Logging in with the old admin password proves + // the users table came back from config.yml. + ({ server, cookie, api } = await startIsolatedServer({ setup: false, admin: ADMIN, dataDir, appsDir, logName: '26a-restart' })); + await expect.poll(() => readFileSync(server.logPath, 'utf-8'), { timeout: 5_000 }) + .toContain('[configsync] imported global sidecar into empty DB'); + + const users = await api('GET', '/api/users'); + expect(users.status).toBe(200); + const editor = users.data.find((u) => u.username === EDITOR.username); + expect(editor, JSON.stringify(users.data)).toMatchObject({ role: 'manage', display_name: 'DR Editor', email: 'editor@dr.local' }); + await loginAt(server.baseURL, EDITOR.username, EDITOR.password); + + const byKey = await fetch(`${server.baseURL}/api/apps`, { headers: { Authorization: `Bearer ${key.data.key}` } }); + expect(byKey.status, 'API key restored').toBe(200); + + const webhooks = await api('GET', '/api/webhooks'); + const hook = webhooks.data.find((w) => w.name === 'dr-hook'); + expect(hook, JSON.stringify(webhooks.data)).toMatchObject({ type: 'slack', url: 'http://127.0.0.1:9/dr-hook' }); + + // Registry credentials are encrypted with master_secret; the decrypted + // username proves they survived. + const regs = await api('GET', '/api/registries'); + expect(regs.data.find((r) => r.name === 'dr-ghcr'), JSON.stringify(regs.data)).toMatchObject({ url: 'ghcr.io', username: 'dr-user' }); + + // The reconciler re-adopts the app from apps_dir and imports its sidecar. + await waitForAppStatusAt(server.baseURL, cookie, APP, 'running', 60_000); + await expect.poll(async () => { + const rules = await api('GET', '/api/alerts/rules'); + return rules.data.filter((r) => r.app_slug === APP) + .map((r) => ({ metric: r.metric, operator: r.operator, threshold: r.threshold, duration_sec: r.duration_sec, webhook_id: r.webhook_id, enabled: r.enabled })); + }, { message: 'alert rule restored from simpledeploy.yml', timeout: 20_000 }) + .toEqual([{ metric: 'cpu_pct', operator: '>', threshold: 90, duration_sec: 300, webhook_id: hook.id, enabled: true }]); + const access = await api('GET', `/api/users/${editor.id}/access`); + expect(access.data, 'access grant restored from simpledeploy.yml').toEqual([APP]); }); }); diff --git a/e2e/tests/29a-ratelimit.spec.js b/e2e/tests/29a-ratelimit.spec.js index f88e2dc3..9b4a41bf 100644 --- a/e2e/tests/29a-ratelimit.spec.js +++ b/e2e/tests/29a-ratelimit.spec.js @@ -1,183 +1,95 @@ // 29a-ratelimit.spec.js // -// Verifies proxy rate-limiting end-to-end. The main e2e server runs with -// permissive limits, so this spec spins up a *separate* isolated server with a -// tightly rate-limited deployment. Per-app proxy rate limits come from compose -// labels (simpledeploy.ratelimit.*). Server-wide cfg.RateLimit only affects -// the auth/API rate limiter, not the proxy handler — we still tighten it via -// the new `ratelimit` override on startServer for completeness. -// -// Uses the binary already built by global-setup (via getBinaryPath) — no rebuild. +// Proxy rate limiting end-to-end on an isolated server. Limits come from the +// app's simpledeploy.ratelimit.* labels. Buckets are keyed by a request +// header so each test (and the readiness poll) starts on a fresh bucket +// without waiting out a window; ip keying is unit-tested in +// internal/proxy/ratelimit_test.go. burst is "0" so the expected sequence +// does not depend on burst semantics. import { test, expect } from '@playwright/test'; -import { existsSync } from 'fs'; -import { startServer, stopServer, getBinaryPath } from '../helpers/server.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; -import { apiRequestAt } from '../helpers/api.js'; -import { TEST_ADMIN } from '../helpers/auth.js'; +import { execFileSync } from 'child_process'; +import { fetchViaProxy, waitForProxyStatus } from '../helpers/proxy.js'; +import { startIsolatedServer, stopIsolatedServer, deployAt, waitForAppStatusAt } from '../helpers/isolated.js'; test.describe.configure({ mode: 'serial' }); -async function setupAdminAndLogin(baseURL) { - const setup = await apiRequestAt(baseURL, 'POST', '/api/setup', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - display_name: TEST_ADMIN.displayName, - email: TEST_ADMIN.email, - }); - expect(setup.status, 'initial setup should succeed').toBe(201); - - const login = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - }); - expect(login.status, 'login should succeed').toBe(200); - expect(login.setCookie).toBeTruthy(); - return login.setCookie; -} - -async function waitForAppRunning(baseURL, cookie, slug, timeoutMs = 180_000) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); - last = res; - const status = res.data && (res.data.Status || res.data.status); - if (res.ok && status === 'running') return res.data; - await new Promise((r) => setTimeout(r, 1500)); - } - throw new Error(`app ${slug} did not reach running. last: ${JSON.stringify(last && last.data)}`); -} - -function composeWithRateLimit(domain, hostPort, requests, window, burst) { - // All simpledeploy.* labels live on the single service; the parser merges - // non-endpoint labels across services (first-seen wins) into AppConfig. - // Rate-limit requests=N with window=W; burst defaults to requests when unset. - return [ - 'services:', - ' web:', - ' image: nginx:alpine', - ' ports:', - ` - "${hostPort}:80"`, - ' labels:', - ` simpledeploy.endpoints.0.domain: "${domain}"`, - ' simpledeploy.endpoints.0.port: "80"', - ' simpledeploy.endpoints.0.service: "web"', - ' simpledeploy.endpoints.0.tls: "off"', - ` simpledeploy.ratelimit.requests: "${requests}"`, - ` simpledeploy.ratelimit.window: "${window}"`, - ` simpledeploy.ratelimit.burst: "${burst}"`, - ' simpledeploy.ratelimit.by: "ip"', - ].join('\n'); -} +const APP = 'e2e-ratelimit'; +const DOMAIN = 'ratelimit-test.local'; +const REQUESTS = 5; +const WINDOW_MS = 3_000; +const KEY_HEADER = 'X-RL-Key'; + +const COMPOSE = [ + 'services:', + ' web:', + ' image: nginx:alpine', + ' ports:', + ' - "8291:80"', + ' labels:', + ` simpledeploy.endpoints.0.domain: "${DOMAIN}"`, + ' simpledeploy.endpoints.0.port: "80"', + ' simpledeploy.endpoints.0.tls: "off"', + ` simpledeploy.ratelimit.requests: "${REQUESTS}"`, + ` simpledeploy.ratelimit.window: "${WINDOW_MS / 1000}s"`, + ' simpledeploy.ratelimit.burst: "0"', + ` simpledeploy.ratelimit.by: "header:${KEY_HEADER}"`, +].join('\n'); test.describe('Proxy rate limiting', () => { let server = null; - let cookie = null; - const appName = 'e2e-ratelimit'; - const domain = 'ratelimit-test.local'; - // Small limits so tests complete quickly. - const REQUESTS = 5; - const WINDOW_STR = '10s'; - const WINDOW_MS = 10_000; - const BURST = 2; + let api = null; + + const hit = (key) => fetchViaProxy(DOMAIN, '/', { proxyURL: server.proxyURL, headers: { [KEY_HEADER]: key } }) + .then((r) => r.status); test.beforeAll(async () => { test.setTimeout(240_000); - const bin = getBinaryPath(); - if (!existsSync(bin)) test.skip(true, `binary not found at ${bin}`); - - // Tight server-level (auth) rate limit as well, for coverage of the override. - server = await startServer(bin, { - ratelimit: { requests: 5, window: '10s', burst: 2, by: 'ip' }, + let cookie; + ({ server, cookie, api } = await startIsolatedServer()); + await deployAt(api, APP, COMPOSE); + await waitForAppStatusAt(server.baseURL, cookie, APP, 'running'); + await waitForProxyStatus(DOMAIN, 200, { + proxyURL: server.proxyURL, headers: { [KEY_HEADER]: 'readiness' }, bodyContains: 'Welcome to nginx', timeoutMs: 20_000, }); - cookie = await setupAdminAndLogin(server.baseURL); - - const yaml = composeWithRateLimit(domain, 8291, REQUESTS, WINDOW_STR, BURST); - const deploy = await apiRequestAt(server.baseURL, 'POST', '/api/apps/deploy', { - name: appName, - compose: Buffer.from(yaml).toString('base64'), - }, cookie); - expect(deploy.status, `deploy failed: ${JSON.stringify(deploy.data)}`).toBe(202); - - await waitForAppRunning(server.baseURL, cookie, appName); - // Give Caddy a moment to register the route + rate limiter. - await new Promise((r) => setTimeout(r, 2000)); }); test.afterAll(async () => { - if (server) { - try { - await apiRequestAt(server.baseURL, 'DELETE', `/api/apps/${appName}`, null, cookie); - } catch {} - stopServer(server); - } + if (!server) return; + await api('DELETE', `/api/apps/${APP}`); + await stopIsolatedServer(server, '29a-ratelimit'); }); - test('burst fills, subsequent requests within window return 429', async () => { - // Wait the full window so we start with a fresh bucket. - await new Promise((r) => setTimeout(r, WINDOW_MS + 500)); - - // Fire 3x the allowed requests; we should see a mix of 200s and 429s. - const total = REQUESTS * 3; + test('allows exactly the configured requests per window, then 429 with Retry-After', async () => { const statuses = []; - for (let i = 0; i < total; i++) { - const res = await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - statuses.push(res.status); - } - - const ok = statuses.filter((s) => s === 200).length; - const tooMany = statuses.filter((s) => s === 429).length; - - expect( - ok, - `expected at least one 200; statuses=${JSON.stringify(statuses)}`, - ).toBeGreaterThan(0); - expect( - tooMany, - `expected at least one 429 within window; statuses=${JSON.stringify(statuses)}`, - ).toBeGreaterThan(0); - // With requests=5 over 10s, we shouldn't see all 15 succeed. - expect(ok).toBeLessThanOrEqual(REQUESTS + 1); + for (let i = 0; i < REQUESTS + 3; i++) statuses.push(await hit('sequence')); + expect(statuses).toEqual([...Array(REQUESTS).fill(200), 429, 429, 429]); + + const headers = execFileSync('curl', [ + '-sS', '-o', '/dev/null', '-D', '-', + '--resolve', `${DOMAIN}:${server.proxyPort}:127.0.0.1`, + '-H', `${KEY_HEADER}: sequence`, + `http://${DOMAIN}:${server.proxyPort}/`, + ], { encoding: 'utf-8' }); + expect(headers).toMatch(/^HTTP\/1\.1 429/); + expect(headers).toMatch(/^Retry-After: 60\r?$/mi); + + // Another key has its own bucket. + expect(await hit('other-client')).toBe(200); }); - test('rate limit recovers after window elapses', async () => { - // Trigger 429 again. - for (let i = 0; i < REQUESTS * 2; i++) { - await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - } - const blocked = await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - expect( - blocked.status, - `expected 429 after exhausting bucket, got ${blocked.status}`, - ).toBe(429); - - // Wait for window to elapse so the bucket resets. - await new Promise((r) => setTimeout(r, WINDOW_MS + 1000)); + test('blocked client recovers once the window elapses, not before', async () => { + const start = Date.now(); + for (let i = 0; i < REQUESTS; i++) expect(await hit('recovery')).toBe(200); + expect(await hit('recovery')).toBe(429); - const recovered = await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - expect( - recovered.status, - `expected 200 after window, got ${recovered.status}`, - ).toBe(200); - }); - - test('429 response sets Retry-After header via Caddy handler', async () => { - // The proxy helper returns only status/body, so re-fetch via curl to - // inspect headers. Fire enough requests to trigger a block, then curl -D. - await new Promise((r) => setTimeout(r, WINDOW_MS + 500)); - for (let i = 0; i < REQUESTS * 2; i++) { - await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); + // 429s do not consume tokens, so polling does not extend the block. + let recoveredAt = 0; + while (Date.now() - start < WINDOW_MS + 2_000) { + if (await hit('recovery') === 200) { recoveredAt = Date.now(); break; } + await new Promise((r) => setTimeout(r, 250)); } - // One more request that should be 429. - const res = await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - expect(res.status).toBe(429); - // Retry-After is checked implicitly by handler tests; here we just ensure - // the 429 shows up (headers not exposed by fetchViaProxy). + expect(recoveredAt, 'still blocked after the window').toBeGreaterThan(0); + expect(recoveredAt - start).toBeGreaterThanOrEqual(WINDOW_MS); }); }); - -// NOTE: per-endpoint rate-limit labels are NOT supported by the current -// compose parser (internal/compose/parser.go). Rate-limit labels are parsed at -// the app level (first-seen across services wins). If per-endpoint overrides -// are added later, add a dedicated test here. diff --git a/e2e/tests/29b-private-registry.spec.js b/e2e/tests/29b-private-registry.spec.js index 139e6ddd..78cdba95 100644 --- a/e2e/tests/29b-private-registry.spec.js +++ b/e2e/tests/29b-private-registry.spec.js @@ -1,211 +1,101 @@ // 29b-private-registry.spec.js // -// Verifies that simpledeploy can pull + deploy an app from a private registry -// using credentials stored via the /api/registries endpoint. Spins up a local -// registry:2 container with htpasswd auth, pushes a test image to it, then -// registers credentials and deploys an app referencing the private image. -// -// Requires: docker daemon reachable, and localhost treated as insecure -// registry (default on Docker Desktop — 127.0.0.0/8 is allowed). -// -// Uses the binary already built by global-setup (via getBinaryPath). +// Deploys from a private registry using credentials stored via +// /api/registries. A local registry:2 with htpasswd auth serves a pushed +// test image; the app references it through simpledeploy.registries. +// Requires localhost to be an insecure registry (Docker default for +// 127.0.0.0/8). import { test, expect } from '@playwright/test'; -import { execFileSync } from 'child_process'; -import { existsSync } from 'fs'; -import { startServer, stopServer, getBinaryPath } from '../helpers/server.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; -import { apiRequestAt } from '../helpers/api.js'; -import { TEST_ADMIN } from '../helpers/auth.js'; +import { waitForProxyStatus } from '../helpers/proxy.js'; import { startRegistry, pushImage, rmiLocal } from '../helpers/registry.js'; -import { listAppContainers } from '../helpers/docker.js'; +import { findServiceContainer, containerImage } from '../helpers/docker.js'; +import { startIsolatedServer, stopIsolatedServer, deployAt, waitForAppStatusAt } from '../helpers/isolated.js'; test.describe.configure({ mode: 'serial' }); -function haveDocker() { - try { - execFileSync('docker', ['info'], { stdio: ['ignore', 'pipe', 'pipe'] }); - return true; - } catch { return false; } -} - -async function setupAdminAndLogin(baseURL) { - const setup = await apiRequestAt(baseURL, 'POST', '/api/setup', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - display_name: TEST_ADMIN.displayName, - email: TEST_ADMIN.email, - }); - expect(setup.status, 'initial setup should succeed').toBe(201); +const APP = 'e2e-priv-registry'; +const DOMAIN = 'priv-registry.local'; +const REGISTRY_NAME = 'e2e-local-priv'; - const login = await apiRequestAt(baseURL, 'POST', '/api/auth/login', { - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - }); - expect(login.status).toBe(200); - expect(login.setCookie).toBeTruthy(); - return login.setCookie; -} - -async function waitForAppStatus(baseURL, cookie, slug, wantStatus, timeoutMs = 180_000) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); - last = res; - const status = res.data && (res.data.Status || res.data.status); - if (res.ok && status === wantStatus) return res.data; - await new Promise((r) => setTimeout(r, 1500)); - } - throw new Error(`app ${slug} did not reach status=${wantStatus}. last: ${JSON.stringify(last && last.data)}`); -} - -async function waitForAppStatusAny(baseURL, cookie, slug, wantedStatuses, timeoutMs = 180_000) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequestAt(baseURL, 'GET', `/api/apps/${slug}`, null, cookie); - last = res; - const status = res.data && (res.data.Status || res.data.status); - if (res.ok && wantedStatuses.includes(status)) return { data: res.data, status }; - await new Promise((r) => setTimeout(r, 1500)); - } - throw new Error(`app ${slug} did not reach any of ${wantedStatuses.join(',')}. last: ${JSON.stringify(last && last.data)}`); -} - -function composeWithImage(image, domain, hostPort, registryName) { - const lines = [ +function composeWithImage(image) { + return [ 'services:', ' web:', ` image: ${image}`, ' ports:', - ` - "${hostPort}:80"`, + ' - "8391:80"', ' labels:', - ` simpledeploy.endpoints.0.domain: "${domain}"`, + ` simpledeploy.endpoints.0.domain: "${DOMAIN}"`, ' simpledeploy.endpoints.0.port: "80"', - ' simpledeploy.endpoints.0.service: "web"', ' simpledeploy.endpoints.0.tls: "off"', - ]; - if (registryName) { - lines.push(` simpledeploy.registries: "${registryName}"`); - } - return lines.join('\n'); + ` simpledeploy.registries: "${REGISTRY_NAME}"`, + ].join('\n'); } test.describe('Private container registry', () => { let server = null; let cookie = null; + let api = null; let registry = null; let imageRef = null; let registryID = null; - const appName = 'e2e-priv-registry'; - const domain = 'priv-registry.local'; - const registryName = 'e2e-local-priv'; - test.beforeAll(async () => { test.setTimeout(300_000); - if (!haveDocker()) test.skip(true, 'docker daemon unavailable'); - const bin = getBinaryPath(); - if (!existsSync(bin)) test.skip(true, `binary not found at ${bin}`); - - // 1. Start a local registry with htpasswd auth. registry = await startRegistry(); - - // 2. Push a test image to it. - // Use nginx:alpine since the e2e environment typically already has it. imageRef = pushImage(registry, 'nginx:alpine', 'e2e/nginx:priv'); - // 3. Start simpledeploy and login. - server = await startServer(bin); - cookie = await setupAdminAndLogin(server.baseURL); - - // 4. Register registry credentials via the API. - const reg = await apiRequestAt(server.baseURL, 'POST', '/api/registries', { - name: registryName, + ({ server, cookie, api } = await startIsolatedServer()); + const reg = await api('POST', '/api/registries', { + name: REGISTRY_NAME, url: registry.host, username: registry.user, password: registry.pass, - }, cookie); - expect(reg.status, `create registry failed: ${JSON.stringify(reg.data)}`).toBe(201); + }); + expect(reg.status, JSON.stringify(reg.data)).toBe(201); registryID = reg.data.id; - expect(registryID).toBeTruthy(); }); test.afterAll(async () => { if (server) { - try { await apiRequestAt(server.baseURL, 'DELETE', `/api/apps/${appName}`, null, cookie); } catch {} - try { - if (registryID) { - await apiRequestAt(server.baseURL, 'DELETE', `/api/registries/${registryID}`, null, cookie); - } - } catch {} - stopServer(server); + await api('DELETE', `/api/apps/${APP}`); + await stopIsolatedServer(server, '29b-private-registry'); } if (registry) registry.stop(); - // Clean up local image tag so subsequent runs re-pull cleanly. if (imageRef) rmiLocal(imageRef); }); - test('deploy app from private registry succeeds with creds configured', async () => { + test('deploy pulls the private image with the stored credentials', async () => { test.setTimeout(300_000); - // Remove any previously-cached copy locally so the pull must actually go - // through the private registry. + // No local copy, so the image must come from the registry. rmiLocal(imageRef); - const yaml = composeWithImage(imageRef, domain, 8391, registryName); - const deploy = await apiRequestAt(server.baseURL, 'POST', '/api/apps/deploy', { - name: appName, - compose: Buffer.from(yaml).toString('base64'), - }, cookie); - expect(deploy.status, `deploy failed: ${JSON.stringify(deploy.data)}`).toBe(202); + await deployAt(api, APP, composeWithImage(imageRef)); + await waitForAppStatusAt(server.baseURL, cookie, APP, 'running'); - await waitForAppStatus(server.baseURL, cookie, appName, 'running'); - - const containers = listAppContainers(appName); - expect(containers.length, 'expected at least one container').toBeGreaterThan(0); - - // Verify the proxy actually serves the app (pull + start succeeded). - // Give Caddy a moment to register the route. - await new Promise((r) => setTimeout(r, 2000)); - const res = await fetchViaProxy(domain, '/', { proxyURL: server.proxyURL }); - expect(res.status, `expected 200 via proxy, got ${res.status}`).toBe(200); - const body = await res.text(); - expect(body).toContain('Welcome to nginx'); + expect(containerImage(findServiceContainer(APP, 'web'))).toBe(imageRef); + await waitForProxyStatus(DOMAIN, 200, { proxyURL: server.proxyURL, bodyContains: 'Welcome to nginx', timeoutMs: 20_000 }); }); - test('deploy fails when registry credentials are removed', async () => { + test('deploy fails on registry auth once the credentials are removed', async () => { test.setTimeout(240_000); - // Remove app + local image so the next deploy must pull fresh. - await apiRequestAt(server.baseURL, 'DELETE', `/api/apps/${appName}`, null, cookie); - await new Promise((r) => setTimeout(r, 2000)); - - // Delete the registry credentials. - const del = await apiRequestAt( - server.baseURL, 'DELETE', `/api/registries/${registryID}`, null, cookie, - ); + const del = await api('DELETE', `/api/apps/${APP}`); expect(del.status).toBe(200); - registryID = null; - - // Wipe the local image copy so docker cannot short-circuit the pull. + const delReg = await api('DELETE', `/api/registries/${registryID}`); + expect(delReg.status).toBe(200); rmiLocal(imageRef); - // Re-deploy referencing the same private image + (now-missing) registry. - const yaml = composeWithImage(imageRef, domain, 8391, registryName); - const deploy = await apiRequestAt(server.baseURL, 'POST', '/api/apps/deploy', { - name: appName, - compose: Buffer.from(yaml).toString('base64'), - }, cookie); - // Deploy endpoint itself returns 202 (async); failure shows up as app - // status = "error". - expect(deploy.status).toBe(202); - - const got = await waitForAppStatusAny( - server.baseURL, cookie, appName, ['error', 'running'], 120_000, - ); - expect( - got.status, - 'deploy should fail with status=error when private image has no creds', - ).toBe('error'); + await deployAt(api, APP, composeWithImage(imageRef)); + const app = await waitForAppStatusAt(server.baseURL, cookie, APP, ['error', 'running'], 120_000); + expect(app.Status, 'deploy without credentials should fail').toBe('error'); + + // The failure must be the registry refusing the anonymous pull, not some + // unrelated error (port clash, compose error, timeout). + const events = await api('GET', `/api/apps/${APP}/events`); + expect(events.status).toBe(200); + const failed = events.data.find((e) => e.action === 'deploy_failed'); + expect(failed, JSON.stringify(events.data)).toBeTruthy(); + expect(failed.detail).toMatch(/no basic auth credentials|unauthorized|authentication required/i); }); }); From a42a7834b5a13e1c247b8b447e5a8def268d63ab Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:07:16 -0700 Subject: [PATCH 06/15] test(e2e): template checks via API and docker compose, service templates as vitest, stronger archive/cleanup - 30: one API loop over all app templates (validate-compose + docker compose config) plus Quick test mode for multi-endpoint templates; drops 21 UI tests - 31 and slow-app-templates-deploy removed (vitest / templates matrix cover them) - 95/99: assert teardown, proxy route removal, purge and delete via API state - templates-deploy-all: no empty-200 probe passes, container check for probe:null templates, cleanup in finally, exact Valid compose locator --- e2e/helpers/template-probes.js | 14 +- e2e/tests/30-app-templates-ui.spec.js | 144 ++++++-------- e2e/tests/31-service-templates-ui.spec.js | 60 ------ e2e/tests/95-archive.spec.js | 76 +++----- e2e/tests/99-cleanup.spec.js | 95 ++++----- e2e/tests/slow-app-templates-deploy.spec.js | 84 -------- e2e/tests/templates-deploy-all.spec.js | 183 ++++++++++-------- .../components/__tests__/VisualEditor.test.js | 54 ++++++ 8 files changed, 283 insertions(+), 427 deletions(-) delete mode 100644 e2e/tests/31-service-templates-ui.spec.js delete mode 100644 e2e/tests/slow-app-templates-deploy.spec.js diff --git a/e2e/helpers/template-probes.js b/e2e/helpers/template-probes.js index 28a6a4da..e8c57329 100644 --- a/e2e/helpers/template-probes.js +++ b/e2e/helpers/template-probes.js @@ -14,17 +14,17 @@ // asserted via the status pill. // // `hostVar` is the key of the template variable that holds the domain to -// route by. Defaults to 'domain' if omitted. +// route by. Defaults to 'domain' if omitted. A probe without bodyIncludes +// also needs a non-empty body (or a Location on a redirect), since Caddy +// answers hosts without a route with an empty 200. export const templateProbes = { 'nginx-static': { - // Raw `docker compose up` on this template serves nginx's default - // welcome page because Docker auto-populates a new named volume from - // the image content on first mount. SimpleDeploy's deployer pre-creates - // the volume, which suppresses that copy, so the deployed app serves - // an empty 200. We only assert reachability here, not body content. + // Docker populates the new, empty web-content volume from the image on + // first mount (also when compose created it beforehand), so nginx serves + // its welcome page. probes: [ - { path: '/', statusMin: 200, statusMax: 404 }, + { path: '/', statusMin: 200, statusMax: 200, bodyIncludes: 'Welcome to nginx' }, ], }, diff --git a/e2e/tests/30-app-templates-ui.spec.js b/e2e/tests/30-app-templates-ui.spec.js index 87dbd8dd..d340b7a1 100644 --- a/e2e/tests/30-app-templates-ui.spec.js +++ b/e2e/tests/30-app-templates-ui.spec.js @@ -1,104 +1,74 @@ -// App template UI coverage. -// Iterates every app template in ui/src/lib/appTemplates.js. For each: -// 1. open the deploy wizard -// 2. click the template card -// 3. fill any required primary vars (secrets auto-generate) -// 4. click Apply -// 5. verify the wizard advances to step 1 and reports a valid compose -// 6. Back to picker for the next template -// No actual deployment happens here — that's covered by the slow -// deploy-each spec. This test is fast and runs in lite mode. +// App templates (ui/src/lib/appTemplates.js) must render to compose files +// that both SimpleDeploy's validator and docker compose accept, or the +// wizard deploys something that fails. The full deploy matrix lives in +// templates-deploy-all.spec.js (E2E_TEMPLATES=1). import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; -import { appTemplates } from '../../ui/src/lib/appTemplates.js'; +import { execFileSync } from 'child_process'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +import { appTemplates, applyVars, applyAccessMode, countEndpoints, generateSecret } from '../../ui/src/lib/appTemplates.js'; -test.describe('App templates - UI validation', () => { - test.describe.configure({ mode: 'serial' }); - - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - // Fill a single variable field based on its rendered input type. - async function fillVariable(dialog, v, templateId) { - const input = dialog.locator(`#tpl-var-${v.key}`); - if (!(await input.isVisible().catch(() => false))) return; - - if (v.type === 'enum') { - const opts = v.options || []; - const def = v.default || opts[0]?.value; - if (def) await input.selectOption(String(def)); - return; - } - - // Skip if already filled (defaults / auto-generated secrets). - const existing = await input.inputValue().catch(() => ''); - if (existing && existing.length > 0) return; - - let value = ''; - switch (v.type) { - case 'domain': - value = `${templateId}.e2e.test`; - break; - case 'email': - value = 'e2e@example.com'; - break; - case 'number': - value = String(v.default ?? 8080); - break; - case 'secret': - // Picker auto-fills on open; only reach here if generation failed. - value = 'E2eTestSecretValue12345'; - break; - default: - value = v.default != null ? String(v.default) : `e2e-${v.key}`; - } - await input.fill(value); +// Values as the template picker would fill them in Custom domain mode, with +// a distinct domain per domain variable. +function varsFor(tpl) { + const vars = {}; + for (const v of tpl.variables || []) { + if (v.type === 'domain') vars[v.key] = `${v.key.replace(/_/g, '-')}.${tpl.id}.e2e.test`; + else if (v.default != null) vars[v.key] = v.default; + else if (v.type === 'secret') vars[v.key] = generateSecret(v.generate?.length || 24, v.generate?.charset || 'base58'); + else if (v.type === 'email') vars[v.key] = 'e2e@example.com'; + else vars[v.key] = `e2e-${v.key}`; } + return vars; +} - test(`renders ${appTemplates.length} templates in grid`, async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); +test.describe('App templates', () => { + test(`all ${appTemplates.length} templates render to a compose file the server and docker compose accept`, async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + const failures = []; for (const tpl of appTemplates) { - await expect( - dialog.getByRole('button', { name: `Use template ${tpl.name}` }), - ).toBeVisible(); + const compose = applyAccessMode(applyVars(tpl.compose, varsFor(tpl)), 'custom'); + // JSON is valid YAML, so the rendered tree is sent as-is. + const text = JSON.stringify(compose); + const res = await apiRequest('POST', '/api/apps/validate-compose', { compose: Buffer.from(text).toString('base64') }); + if (res.status !== 200 || res.data.valid !== true) { + failures.push(`${tpl.id}: validate-compose ${res.status} ${JSON.stringify(res.data)}`); + } + // SimpleDeploy's parser skips compose consistency checks (e.g. a + // service using an undeclared named volume); docker compose up does not. + try { + execFileSync('docker', ['compose', '-p', 'sd-e2e-template-check', '-f', '-', 'config', '--quiet'], { + input: text, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', + }); + } catch (e) { + failures.push(`${tpl.id}: docker compose config: ${(e.stderr || e.message).trim()}`); + } } + expect(failures).toEqual([]); }); - // One test per template keeps failures isolated and the report legible. - for (const tpl of appTemplates) { - test(`template "${tpl.name}" applies to a valid compose`, async ({ page }) => { - const state = getState(); + // Quick test (the default access mode) gives every domain variable the + // same sslip.io name; multi-endpoint templates must still deploy. + test('multi-endpoint templates apply to a valid compose in the default Quick test mode', async ({ page }) => { + const multi = appTemplates.filter((t) => countEndpoints(t.compose) > 1); + expect(multi.length).toBeGreaterThan(0); + await loginAsAdmin(page); + const state = getState(); + for (const tpl of multi) { await page.goto(`${state.baseURL}/#/`); + await page.reload(); await page.getByRole('button', { name: 'Deploy App' }).first().click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: `Use template ${tpl.name}` }).click(); - // Expand advanced/secrets accordion if the template has hidden vars so - // we can verify auto-generated secrets are populated. - const hasHidden = (tpl.variables || []).some((v) => v.hidden); - if (hasHidden) { - await dialog.getByText(/advanced \/ secrets/i).click(); - } - - for (const v of tpl.variables || []) { - await fillVariable(dialog, v, tpl.id); - } - + const validated = page.waitForResponse((r) => r.url().endsWith('/api/apps/validate-compose')); await dialog.getByRole('button', { name: /^apply/i }).click(); - - // Step 1: expect the valid-compose indicator to appear. Validation - // happens server-side, so allow room for the round-trip. - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 15_000 }); - }); - } + const res = await validated; + expect(res.status()).toBe(200); + expect(await res.json(), `${tpl.id} in Quick test mode`).toEqual({ valid: true }); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible(); + } + }); }); diff --git a/e2e/tests/31-service-templates-ui.spec.js b/e2e/tests/31-service-templates-ui.spec.js deleted file mode 100644 index 2030aff0..00000000 --- a/e2e/tests/31-service-templates-ui.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -// Service template UI coverage. -// Iterates every service template from ui/src/lib/serviceTemplates.js. -// For each, opens the deploy wizard in blank-compose mode, clicks -// "Add Service", picks the template, then confirms the rendered YAML -// contains the template's image reference. -// No deployment happens — this is a fast validator that lives in lite -// mode. - -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; -import { serviceTemplates } from '../../ui/src/lib/serviceTemplates.js'; - -test.describe('Service templates - UI validation', () => { - test.describe.configure({ mode: 'serial' }); - - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - async function openBlankWizard(page) { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /build it yourself/i }).click(); - return dialog; - } - - for (const tpl of serviceTemplates) { - test(`service template "${tpl.name}" adds a service with the expected image`, async ({ page }) => { - const dialog = await openBlankWizard(page); - - // Wizard opens in visual mode by default; open the service templates - // picker and pick this template. The button's accessible name is the - // concatenation of its icon emoji and the template name, so partial - // match on name suffices. - await dialog.getByRole('button', { name: 'Add Service' }).click(); - // Expand the "Quick start from a template" disclosure before picking. - await dialog.getByText(/quick start from a template/i).click(); - await dialog.getByRole('button', { name: tpl.name }).first().click(); - - // Switch to YAML and verify the template's image made it in (blank - // template renders no image, so only assert when one is configured). - await dialog.getByRole('button', { name: 'YAML' }).click(); - const editor = dialog.locator('textarea').last(); - const yaml = await editor.inputValue(); - if (tpl.config?.image) { - expect(yaml, `expected ${tpl.config.image} in compose YAML`).toContain(tpl.config.image); - } - // The new service's derived name comes from tpl.id (hyphens -> _); - // blank is 'service' and can collide with the default, so only check - // non-blank templates. - if (tpl.id !== 'blank') { - const derived = tpl.id.replace(/-/g, '_'); - expect(yaml).toContain(derived); - } - }); - } -}); diff --git a/e2e/tests/95-archive.spec.js b/e2e/tests/95-archive.spec.js index 7a5ec93d..c44ad543 100644 --- a/e2e/tests/95-archive.spec.js +++ b/e2e/tests/95-archive.spec.js @@ -3,69 +3,53 @@ import { rmSync } from 'fs'; import { join } from 'path'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; +import { listAppContainers } from '../helpers/docker.js'; +import { fetchViaProxy } from '../helpers/proxy.js'; -// Verifies the archive lifecycle: removing an app's directory on disk causes -// the reconciler to archive it (tombstone), the Archive page lists it, and -// "Clean up" purges the row. -test.describe('Archive lifecycle', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); +const archivedSlugs = async () => (await apiRequest('GET', '/api/apps/archived')).data.map((a) => a.Slug); +// Archive lifecycle: removing an app's directory on disk makes the reconciler +// tear the app down and archive it (tombstone); the Archive page lists it and +// "Clean up" purges it. +test.describe('Archive lifecycle', () => { test('archives e2e-nginx after dir removal and purges via Clean up', async ({ page }) => { const state = getState(); const slug = 'e2e-nginx'; + await loginAsAdmin(page); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - // Sanity: app currently visible on dashboard. - await page.goto(`${state.baseURL}/#/`); - await expect( - page.locator('main').getByRole('heading', { name: slug, exact: true }) - ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('main').getByRole('heading', { name: slug, exact: true })).toBeVisible(); - // Remove the app dir on disk to trigger reconciler archive. rmSync(join(state.appsDir, slug), { recursive: true, force: true }); - // Poll the API until the reconciler archives the app (debounce + teardown - // can take several seconds). - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - const deadline = Date.now() + 60_000; - let archived = false; - while (Date.now() < deadline) { - const res = await apiRequest('GET', '/api/apps/archived'); - const list = (res.data || []); - if (list.some((a) => (a.slug || a.Slug) === slug)) { archived = true; break; } - await page.waitForTimeout(1_000); - } - expect(archived, 'app should be archived after dir removal').toBe(true); + await expect.poll(archivedSlugs, { message: 'app archived after dir removal', timeout: 60_000 }).toContain(slug); + const live = await apiRequest('GET', '/api/apps'); + expect(live.data.map((a) => a.Slug)).not.toContain(slug); - // Confirm the active app list also excludes it. - const liveList = await apiRequest('GET', '/api/apps'); - const liveSlugs = (liveList.data || []).map((a) => a.slug || a.Slug); - expect(liveSlugs).not.toContain(slug); + // Archiving tears the stack down before marking the row archived. + expect(listAppContainers(slug)).toEqual([]); + // Caddy answers hosts without a route with an empty 200; a stale route + // to the removed container would give 502. + await expect.poll(async () => { + const r = await fetchViaProxy('nginx-test.local'); + return [r.status, await r.text()]; + }, { message: 'proxy route removed', timeout: 15_000 }).toEqual([200, '']); - // Dashboard should no longer list it as an app card. Use the heading - // selector to avoid matching activity-feed links to the same slug. - await page.goto(`${state.baseURL}/#/`); await page.reload(); - await page.waitForLoadState('networkidle').catch(() => {}); - await expect( - page.locator('main').getByRole('heading', { name: slug, exact: true }) - ).toHaveCount(0, { timeout: 20_000 }); + await expect(page.locator('main').getByRole('heading', { name: slug, exact: true })).toHaveCount(0); - // Archive page should list it. await page.goto(`${state.baseURL}/#/archive`); const main = page.locator('main'); - await expect(main.getByText(slug, { exact: true }).first()).toBeVisible({ timeout: 15_000 }); - - // Click Clean up on the row, confirm in the modal. - const row = main.locator('tr', { hasText: slug }).first(); - await row.getByRole('button', { name: /clean up/i }).click(); + const row = main.locator('tr', { hasText: slug }); + await expect(row).toHaveCount(1); + await row.getByRole('button', { name: 'Clean up' }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /confirm/i }).click(); + const purged = page.waitForResponse((r) => r.url().endsWith(`/api/apps/${slug}/purge`)); + await dialog.getByRole('button', { name: 'Confirm' }).click(); + expect((await purged).status()).toBe(200); - // Row should disappear from the archive page. - await expect(main.getByText(slug, { exact: true })).toHaveCount(0, { timeout: 15_000 }); + await expect(main.locator('tr', { hasText: slug })).toHaveCount(0); + expect(await archivedSlugs()).not.toContain(slug); }); }); diff --git a/e2e/tests/99-cleanup.spec.js b/e2e/tests/99-cleanup.spec.js index 13791f15..2b052df3 100644 --- a/e2e/tests/99-cleanup.spec.js +++ b/e2e/tests/99-cleanup.spec.js @@ -1,70 +1,47 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; - +import { existsSync } from 'fs'; +import { join } from 'path'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +import { listAppContainers } from '../helpers/docker.js'; + +// Removes the apps 03-deploy created (e2e-nginx is purged by 95-archive), +// checking that a delete really tears the app down. test.describe('Cleanup', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('remove postgres app', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.locator('button').filter({ hasText: 'settings' }).click(); - - // Expand Danger Zone section - const dangerBtn = page.locator('button').filter({ hasText: 'Danger Zone' }); - await dangerBtn.click(); - - const deleteBtn = page.getByRole('button', { name: /delete app/i }); - await deleteBtn.click(); - - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 3_000 }); - const confirmInput = dialog.locator('input'); - if (await confirmInput.isVisible({ timeout: 2_000 }).catch(() => false)) { - await confirmInput.fill('e2e-postgres'); - } - await dialog.getByRole('button', { name: /delete|confirm|remove/i }).click(); - - // After delete, redirects to dashboard which shows "Applications" heading - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible({ timeout: 15_000 }); + test.beforeEach(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); }); - test('remove multi app', async ({ page }) => { + test('Delete App in Settings removes the app, its containers and its folder', async ({ page }) => { const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-multi`); - await page.locator('button').filter({ hasText: 'settings' }).click(); - - // Expand Danger Zone section - const dangerBtn = page.locator('button').filter({ hasText: 'Danger Zone' }); - await dangerBtn.click(); - - const deleteBtn = page.getByRole('button', { name: /delete app/i }); - await deleteBtn.click(); + const slug = 'e2e-postgres'; + expect(listAppContainers(slug).length).toBeGreaterThan(0); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 3_000 }); - const confirmInput = dialog.locator('input'); - if (await confirmInput.isVisible({ timeout: 2_000 }).catch(() => false)) { - await confirmInput.fill('e2e-multi'); - } - await dialog.getByRole('button', { name: /delete|confirm|remove/i }).click(); - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible({ timeout: 15_000 }); + await loginAsAdmin(page); + await page.goto(`${state.baseURL}/#/apps/${slug}`); + await page.locator('main').getByRole('button', { name: 'settings', exact: true }).click(); + await page.getByRole('button', { name: 'Danger Zone' }).click(); + await page.getByRole('button', { name: 'Delete App' }).click(); + + const removed = page.waitForResponse((r) => r.url().endsWith(`/api/apps/${slug}`) && r.request().method() === 'DELETE'); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + expect((await removed).status()).toBe(200); + await expect(page).toHaveURL(`${state.baseURL}/#/`); + await expect(page.locator('main').getByRole('heading', { name: slug, exact: true })).toHaveCount(0); + + expect((await apiRequest('GET', `/api/apps/${slug}`)).status).toBe(404); + expect(listAppContainers(slug)).toEqual([]); + expect(existsSync(join(state.appsDir, slug))).toBe(false); }); - test('remove nginx app', async ({ page }) => { - // Use API to remove since UI may hang if app is in deploying state - const { apiLogin: login, apiRequest: req } = await import('../helpers/api.js'); - await login('e2eadmin', 'E2eTestPass123!'); - const res = await req('DELETE', '/api/apps/e2e-nginx'); - // Accept any result (app may already be gone from prior test failures) - expect(res.status === 200 || res.status === 404).toBeTruthy(); - }); + test('DELETE removes every service of a multi-service app', async () => { + const slug = 'e2e-multi'; + const services = () => listAppContainers(slug).map((n) => n.replace(/^simpledeploy-e2e-multi-(\w+)-\d+$/, '$1')).sort(); + expect([...new Set(services())]).toEqual(['cache', 'web']); - test('dashboard reflects app removals', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - // Verify dashboard loads and shows Applications section - await expect(page.getByText('Applications')).toBeVisible({ timeout: 10_000 }); + const res = await apiRequest('DELETE', `/api/apps/${slug}`); + expect(res.status).toBe(200); + expect(listAppContainers(slug)).toEqual([]); + expect((await apiRequest('GET', `/api/apps/${slug}`)).status).toBe(404); }); }); diff --git a/e2e/tests/slow-app-templates-deploy.spec.js b/e2e/tests/slow-app-templates-deploy.spec.js deleted file mode 100644 index d6ac3168..00000000 --- a/e2e/tests/slow-app-templates-deploy.spec.js +++ /dev/null @@ -1,84 +0,0 @@ -// Slow end-to-end template deploy coverage. -// Picks a few lightweight templates, drives them through the deploy -// wizard to completion, verifies the container is running, then cleans -// up. Excluded from e2e-lite because each template pulls images. -// -// Kept deliberately narrow: covers the template -> wizard -> deploy -// round-trip. Full matrix is redundant with 30-app-templates-ui which -// already validates every template's compose. - -import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; -import { findServiceContainer, containerRunning } from '../helpers/docker.js'; - -// Templates chosen to minimise pull + boot time. Each lists its expected -// primary service name (as derived by the wizard from template id) and -// the domain to use. -const CASES = [ - { id: 'nginx-static', slug: 'e2e-tpl-nginx', service: 'web', domain: 'e2e-tpl-nginx.local' }, - { id: 'uptime-kuma', slug: 'e2e-tpl-uptime', service: 'kuma', domain: 'e2e-tpl-uptime.local' }, -]; - -async function removeAppIfExists(slug) { - try { await apiLogin(); } catch {} - const res = await apiRequest('DELETE', `/api/apps/${slug}`); - return res.ok; -} - -test.describe('App templates - slow deploy', () => { - test.describe.configure({ mode: 'serial' }); - - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - for (const c of CASES) { - test(`deploy template "${c.id}" via wizard`, async ({ page }) => { - test.setTimeout(600_000); - - // Ensure clean state in case a previous run left the app around. - await removeAppIfExists(c.slug); - - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - - await dialog.getByRole('button', { name: new RegExp(`use template .+`, 'i') }).first().waitFor(); - // Templates display by name; find the card for this template id by - // matching its aria-label prefix. - const card = dialog.locator(`button[aria-label^="Use template "]`).filter({ hasText: new RegExp(c.id.replace(/-/g, '.'), 'i') }); - await card.first().click(); - - // Wizard defaults to quick-test access mode, which hides the domain - // input and auto-fills a sslip.io domain. Switch to Custom to expose - // #tpl-var-domain so we can deploy against the fixture hostname. - await dialog.getByRole('button', { name: 'Custom domain', exact: true }).click(); - await dialog.locator('#tpl-var-domain').fill(c.domain); - await dialog.getByRole('button', { name: /^apply/i }).click(); - - // Step 1: name it - await dialog.getByPlaceholder('my-app').fill(c.slug); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 15_000 }); - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - - // Deploy can be slow on first pull; accept any terminal state. - // With the e2e 10s stabilize window some slow-to-healthy services - // (e.g. uptime-kuma) land on "unstable" even though the container - // is running, which the subsequent check confirms. - const statusBadge = dialog.getByTestId('deploy-status'); - await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable|failed)$/, { timeout: 540_000 }); - - // Verify the container actually exists and is running. - const container = findServiceContainer(c.slug, c.service); - expect(container, `expected ${c.slug}/${c.service} container`).toBeTruthy(); - expect(containerRunning(container)).toBe(true); - - await removeAppIfExists(c.slug); - }); - } -}); diff --git a/e2e/tests/templates-deploy-all.spec.js b/e2e/tests/templates-deploy-all.spec.js index a02c52fd..17212822 100644 --- a/e2e/tests/templates-deploy-all.spec.js +++ b/e2e/tests/templates-deploy-all.spec.js @@ -10,20 +10,24 @@ // `domain`-typed variable gets a UNIQUE subdomain keyed off the var // name so multi-endpoint templates (minio, mailpit, docker-registry) // don't collide in Caddy's routing table. -// 2. Deploy through the wizard. Assert terminal "Deployed" (or "Unstable" -// for `probe: null` templates where the stack can't serve without -// external setup by design). +// 2. Deploy through the wizard. Probed templates must end "Deployed" or +// "Unstable"; `probe: null` templates (cannot serve without external +// setup by design) may also end "Failed" but every compose service must +// have a container. // 3. If a probe spec exists (see `helpers/template-probes.js`), probe // each endpoint via `http://127.0.0.1:${proxyPort}` with the // template's filled domain as the `Host:` header and assert the -// expected status/body. This is the real reachability check. -// 4. Delete the app via API before the next template. +// expected status/body. Without bodyIncludes a probe needs a non-empty +// body or a redirect with a Location, because Caddy answers hosts it +// has no route for with an empty 200. +// 4. Delete the app via API, also when the test fails. import { test, expect } from '@playwright/test'; import { getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; import { appTemplates } from '../../ui/src/lib/appTemplates.js'; import { templateProbes } from '../helpers/template-probes.js'; +import { findServiceContainer } from '../helpers/docker.js'; import http from 'node:http'; async function removeAppIfExists(slug) { @@ -90,7 +94,7 @@ function httpGet({ proxyPort, host, path }) { (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); - res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') })); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') })); res.on('error', reject); } ); @@ -128,6 +132,12 @@ async function probeEndpoint({ proxyPort, host, path, statusMin, statusMax, body await new Promise((r) => setTimeout(r, 2000)); continue; } + const redirect = res.status >= 300 && res.status < 400; + if (!bodyIncludes && (redirect ? !res.headers.location : body.trim() === '')) { + lastErr = redirect ? 'redirect without Location' : 'empty body (no route?)'; + await new Promise((r) => setTimeout(r, 2000)); + continue; + } return { ok: true, status: res.status }; } catch (err) { lastErr = String(err); @@ -155,85 +165,90 @@ test.describe('Deploy every template (E2E_TEMPLATES=1)', () => { for (const v of tpl.variables || []) filledVars[v.key] = valueForVar(tpl, v); await removeAppIfExists(slug); - - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /^browse templates$/i }).click(); - await dialog.getByRole('button', { name: `Use template ${tpl.name}` }).click(); - - // Custom domain mode so `domain` vars render as editable inputs - // (Quick test mode hides them behind an sslip.io helper). - await dialog.getByRole('button', { name: /^custom domain$/i }).click(); - - const hasHidden = (tpl.variables || []).some((v) => v.hidden); - if (hasHidden) await dialog.getByText(/advanced \/ secrets/i).click(); - - for (const v of tpl.variables || []) await fillVariable(dialog, v, tpl); - - await dialog.getByRole('button', { name: /^apply/i }).click(); - - await dialog.getByPlaceholder('my-app').fill(slug); - await expect(dialog.getByText(/valid compose/i)).toBeVisible({ timeout: 20_000 }); - - await dialog.getByRole('button', { name: 'Next' }).click(); - await dialog.getByRole('button', { name: 'Deploy' }).click(); - - // Terminal state. For probe: null templates we accept Unstable/Failed - // because the stack can't serve HTTP without external setup. For - // templates with a probe spec we require Deployed (the HTTP probe - // below is the real reachability check). - const statusBadge = dialog.locator('[data-testid="deploy-status"]'); - if (probeSpec.probe === null) { - await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable|failed)$/, { timeout: 540_000 }); - // No HTTP probe: document why. - console.log(`[templates] ${tpl.id}: probe skipped - ${probeSpec.reason}`); - } else { - await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable)$/, { timeout: 540_000 }); - - // Close the wizard so the next interaction path is clean. (The - // probe doesn't depend on UI state, but leaving dialogs open - // across tests has caused flakes in other specs.) - await page.keyboard.press('Escape').catch(() => {}); - - // Give the reconciler one tick (status refresh interval is 5s in - // e2e) to push routes into Caddy after the container is healthy. - await new Promise((r) => setTimeout(r, 6000)); - - for (const p of probeSpec.probes) { - const hostVar = p.hostVar || 'domain'; - const host = filledVars[hostVar]; - if (!host) { - throw new Error(`Template ${tpl.id} probe references unknown hostVar "${hostVar}"`); - } - const result = await probeEndpoint({ - proxyPort: state.proxyPort, - host, - path: p.path, - statusMin: p.statusMin, - statusMax: p.statusMax, - bodyIncludes: p.bodyIncludes, - timeoutMs: p.timeoutMs || 120_000, - }); - if (!result.ok) { - let diag = ''; - try { - const statusRes = await apiRequest('GET', `/api/apps/${slug}/services`); - diag += `\nservices=${JSON.stringify(statusRes).slice(0, 300)}`; - } catch (e) { diag += `\nservices-fetch-err=${e.message}`; } - throw new Error( - `Probe failed for ${tpl.id} (host=${host} path=${p.path}): ${result.lastErr}\n` + - `last status=${result.lastStatus} body-prefix=${(result.lastBody || '').slice(0, 200)}` + - diag - ); - } - console.log(`[templates] ${tpl.id}: probe ok (host=${host} path=${p.path} status=${result.status})`); - } + try { + await deployAndProbe(page, tpl, slug, probeSpec, filledVars); + } finally { + await removeAppIfExists(slug); } - - await removeAppIfExists(slug); }); } }); + +async function deployAndProbe(page, tpl, slug, probeSpec, filledVars) { + const state = getState(); + await page.goto(`${state.baseURL}/#/`); + await page.getByRole('button', { name: 'Deploy App' }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /^browse templates$/i }).click(); + await dialog.getByRole('button', { name: `Use template ${tpl.name}` }).click(); + + // Custom domain mode so `domain` vars render as editable inputs + // (Quick test mode hides them behind an sslip.io helper). + await dialog.getByRole('button', { name: /^custom domain$/i }).click(); + + const hasHidden = (tpl.variables || []).some((v) => v.hidden); + if (hasHidden) await dialog.getByText(/advanced \/ secrets/i).click(); + + for (const v of tpl.variables || []) await fillVariable(dialog, v, tpl); + + await dialog.getByRole('button', { name: /^apply/i }).click(); + + await dialog.getByPlaceholder('my-app').fill(slug); + await expect(dialog.getByText('Valid compose file', { exact: true })).toBeVisible({ timeout: 20_000 }); + + await dialog.getByRole('button', { name: 'Next' }).click(); + await dialog.getByRole('button', { name: 'Deploy' }).click(); + + // Terminal state. probe: null templates cannot serve HTTP without + // external setup; probed templates must deploy and then pass the probe. + const statusBadge = dialog.locator('[data-testid="deploy-status"]'); + if (probeSpec.probe === null) { + await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable|failed)$/, { timeout: 540_000 }); + // "failed" is tolerated (e.g. a dependency never turns healthy), but + // compose must have created every service: a pull or compose error + // would leave some without a container. + const missing = Object.keys(tpl.compose.services).filter((svc) => !findServiceContainer(slug, svc)); + expect(missing, `${tpl.id}: services without a container`).toEqual([]); + console.log(`[templates] ${tpl.id}: probe skipped - ${probeSpec.reason}`); + } else { + await expect(statusBadge).toHaveAttribute('data-deploy-status', /^(success|unstable)$/, { timeout: 540_000 }); + + // Close the wizard so the next interaction path is clean. (The + // probe doesn't depend on UI state, but leaving dialogs open + // across tests has caused flakes in other specs.) + await page.keyboard.press('Escape'); + + // probeEndpoint polls, so no wait for the route to appear. + + for (const p of probeSpec.probes) { + const hostVar = p.hostVar || 'domain'; + const host = filledVars[hostVar]; + if (!host) { + throw new Error(`Template ${tpl.id} probe references unknown hostVar "${hostVar}"`); + } + const result = await probeEndpoint({ + proxyPort: state.proxyPort, + host, + path: p.path, + statusMin: p.statusMin, + statusMax: p.statusMax, + bodyIncludes: p.bodyIncludes, + timeoutMs: p.timeoutMs || 120_000, + }); + if (!result.ok) { + let diag = ''; + try { + const statusRes = await apiRequest('GET', `/api/apps/${slug}/services`); + diag += `\nservices=${JSON.stringify(statusRes).slice(0, 300)}`; + } catch (e) { diag += `\nservices-fetch-err=${e.message}`; } + throw new Error( + `Probe failed for ${tpl.id} (host=${host} path=${p.path}): ${result.lastErr}\n` + + `last status=${result.lastStatus} body-prefix=${(result.lastBody || '').slice(0, 200)}` + + diag + ); + } + console.log(`[templates] ${tpl.id}: probe ok (host=${host} path=${p.path} status=${result.status})`); + } + } +} diff --git a/ui/src/components/__tests__/VisualEditor.test.js b/ui/src/components/__tests__/VisualEditor.test.js index 07344684..52c7ddca 100644 --- a/ui/src/components/__tests__/VisualEditor.test.js +++ b/ui/src/components/__tests__/VisualEditor.test.js @@ -8,6 +8,7 @@ vi.mock('../../lib/api.js', async () => { import VisualEditor from '../VisualEditor.svelte'; import { api } from '../../lib/api.js'; +import { serviceTemplates } from '../../lib/serviceTemplates.js'; describe('VisualEditor (smoke)', () => { it('renders with an empty compose without crashing', () => { @@ -166,3 +167,56 @@ describe('VisualEditor add service from template', () => { expect(next.volumes).toEqual({ pgdata: { driver: 'local' }, pgdata2: {} }); }); }); + +describe('VisualEditor service templates', () => { + async function addTemplate(r, tpl) { + await fireEvent.click(r.getByRole('button', { name: 'Add Service' })); + const picker = within(r.getByText('Quick start from a template').closest('details')); + await fireEvent.click(picker.getByText(tpl.name, { exact: true }).closest('button')); + } + + // Named volume sources a template mounts (bind mounts start with / . or ~). + function namedVolumes(config) { + return (config.volumes || []) + .map((v) => (typeof v === 'string' ? v.split(':')[0] : v.source)) + .filter((src) => src && !/^[/.~]/.test(src)); + } + + it('adds every template as a copy of its config, named after its id', async () => { + const onchange = vi.fn(); + const r = render(VisualEditor, { compose: { services: { web: { image: 'nginx' } } }, slug: '', onchange }); + for (const tpl of serviceTemplates) { + await addTemplate(r, tpl); + const out = onchange.mock.calls.at(-1)[0]; + const name = tpl.id.replace(/-/g, '_'); + expect(Object.keys(out.services), tpl.id).toEqual(['web', name]); + expect(out.services[name], tpl.id).toEqual(tpl.config); + expect(out.services[name], `${tpl.id} must not share the template object`).not.toBe(tpl.config); + } + expect(onchange).toHaveBeenCalledTimes(serviceTemplates.length); + }); + + it('suffixes the name when a service with that name exists', async () => { + const onchange = vi.fn(); + const compose = { services: { postgresql: { image: 'postgres:15' }, postgresql2: { image: 'postgres:14' } } }; + const r = render(VisualEditor, { compose, slug: '', onchange }); + await addTemplate(r, serviceTemplates.find((t) => t.id === 'postgresql')); + const out = onchange.mock.calls.at(-1)[0]; + expect(Object.keys(out.services)).toEqual(['postgresql', 'postgresql2', 'postgresql3']); + expect(out.services.postgresql).toEqual({ image: 'postgres:15' }); + }); + + it('declares the named volumes a template mounts', async () => { + const withVolumes = serviceTemplates.filter((t) => namedVolumes(t.config).length > 0); + expect(withVolumes.length).toBeGreaterThan(0); + const onchange = vi.fn(); + const r = render(VisualEditor, { compose: { services: {} }, slug: '', onchange }); + for (const tpl of withVolumes) { + await addTemplate(r, tpl); + const out = onchange.mock.calls.at(-1)[0]; + for (const vol of namedVolumes(tpl.config)) { + expect(Object.keys(out.volumes || {}), `${tpl.id} mounts ${vol}`).toContain(vol); + } + } + }); +}); From 84e115cceda92e77887a124a5f4dd666f394a813 Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:19:05 -0700 Subject: [PATCH 07/15] test(e2e): copy registry htpasswd into the container; purge returns 204 The bind-mounted temp dir is not visible to the colima VM, so registry:2 generated its own htpasswd and every login got 401. --- e2e/helpers/registry.js | 9 ++++++--- e2e/tests/95-archive.spec.js | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/helpers/registry.js b/e2e/helpers/registry.js index 48fbee74..96d8b5c7 100644 --- a/e2e/helpers/registry.js +++ b/e2e/helpers/registry.js @@ -58,17 +58,20 @@ export async function startRegistry(opts = {}) { : docker(['run', '--rm', '--entrypoint', 'htpasswd', 'httpd:2', '-Bbn', user, pass]).trim(); writeFileSync(join(authDir, 'htpasswd'), htpasswdLine + '\n'); - // Run registry:2 with htpasswd auth. + // Run registry:2 with htpasswd auth. The file goes in with docker cp, not + // a bind mount: the temp dir is not shared with the Docker VM on colima, + // and registry:2 then generates its own htpasswd (every login 401s). docker([ - 'run', '-d', + 'create', '--name', container, '-p', `127.0.0.1:${port}:5000`, - '-v', `${authDir}:/auth`, '-e', 'REGISTRY_AUTH=htpasswd', '-e', 'REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm', '-e', 'REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd', mirrored('registry:2'), ]); + docker(['cp', authDir, `${container}:/auth`]); + docker(['start', container]); const host = `localhost:${port}`; diff --git a/e2e/tests/95-archive.spec.js b/e2e/tests/95-archive.spec.js index c44ad543..c69943fc 100644 --- a/e2e/tests/95-archive.spec.js +++ b/e2e/tests/95-archive.spec.js @@ -47,7 +47,7 @@ test.describe('Archive lifecycle', () => { const dialog = page.getByRole('dialog'); const purged = page.waitForResponse((r) => r.url().endsWith(`/api/apps/${slug}/purge`)); await dialog.getByRole('button', { name: 'Confirm' }).click(); - expect((await purged).status()).toBe(200); + expect((await purged).status()).toBe(204); await expect(main.locator('tr', { hasText: slug })).toHaveCount(0); expect(await archivedSlugs()).not.toContain(slug); From ffdeadcda5267114e93148bbd996a98c2294793f Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:06:31 -0700 Subject: [PATCH 08/15] test(e2e): tighten specs 14-25 (assert server state, drop static and duplicate tests, poll instead of sleep) --- e2e/helpers/users.js | 54 ++++ e2e/tests/14-users.spec.js | 335 ++++++++-------------- e2e/tests/15-registries.spec.js | 59 ++-- e2e/tests/16-docker.spec.js | 68 ++--- e2e/tests/17-system.spec.js | 215 +++++--------- e2e/tests/18-profile.spec.js | 90 +++--- e2e/tests/18a-realtime.spec.js | 52 +--- e2e/tests/20-activity.spec.js | 244 +++++----------- e2e/tests/20-export-import.spec.js | 208 ++++---------- e2e/tests/20-security-validation.spec.js | 247 ++++------------ e2e/tests/21-user-validation.spec.js | 113 +++----- e2e/tests/22-env-and-endpoints.spec.js | 173 +++-------- e2e/tests/23-rollback-and-restore.spec.js | 185 +++--------- e2e/tests/24-multi-user-isolation.spec.js | 121 ++------ e2e/tests/25-edge-cases.spec.js | 204 ++----------- 15 files changed, 719 insertions(+), 1649 deletions(-) create mode 100644 e2e/helpers/users.js diff --git a/e2e/helpers/users.js b/e2e/helpers/users.js new file mode 100644 index 00000000..00ef326f --- /dev/null +++ b/e2e/helpers/users.js @@ -0,0 +1,54 @@ +import { createHash } from 'crypto'; +import { expect } from '@playwright/test'; +import { apiRequest } from './api.js'; + +export function b64(s) { + return Buffer.from(s).toString('base64'); +} + +// deleteUserByName removes a user left over from an earlier failed run. +export async function deleteUserByName(username) { + const list = await apiRequest('GET', '/api/users'); + expect(list.status).toBe(200); + const u = list.data.find((x) => x.username === username); + if (u) expect((await apiRequest('DELETE', `/api/users/${u.id}`)).status).toBe(200); +} + +export async function findUser(username) { + const list = await apiRequest('GET', '/api/users'); + expect(list.status).toBe(200); + return list.data.find((u) => u.username === username); +} + +// latestDeployEventId returns the newest deploy event id for an app (0 if none). +export async function latestDeployEventId(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + expect(res.status).toBe(200); + return res.data.reduce((max, e) => Math.max(max, e.id), 0); +} + +// waitForDeployEvent polls until the app records a deploy event newer than +// afterId (deploy, restart, rollback, ...) and returns it. Background work +// started by a 202 or an import ends with such an event. +export async function waitForDeployEvent(slug, afterId, timeoutMs = 90_000) { + let ev; + await expect.poll(async () => { + const res = await apiRequest('GET', `/api/apps/${slug}/events`); + ev = (res.data || []).filter((e) => e.id > afterId).sort((a, b) => b.id - a.id)[0]; + return ev ? ev.action : null; + }, { timeout: timeoutMs, intervals: [500] }).not.toBeNull(); + return ev; +} + +// waitForAppSettled polls until no deploy is in flight, the app runs, and the +// stored compose hash matches the file on disk. Editing compose labels (IP +// allowlist, endpoints) makes the watcher redeploy about a second later; a +// rollback or deploy started meanwhile is skipped. +export async function waitForAppSettled(slug, timeoutMs = 90_000) { + await expect.poll(async () => { + const app = await apiRequest('GET', `/api/apps/${slug}`); + const compose = await apiRequest('GET', `/api/apps/${slug}/compose`); + const hash = createHash('sha256').update(String(compose.data)).digest('hex'); + return { deploying: app.data.deploying, status: app.data.Status, hashMatches: app.data.ComposeHash === hash }; + }, { timeout: timeoutMs, intervals: [500] }).toEqual({ deploying: false, status: 'running', hashMatches: true }); +} diff --git a/e2e/tests/14-users.spec.js b/e2e/tests/14-users.spec.js index 1fb34013..f181ceef 100644 --- a/e2e/tests/14-users.spec.js +++ b/e2e/tests/14-users.spec.js @@ -1,92 +1,94 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest, apiRequestWithKey } from '../helpers/api.js'; +import { deleteUserByName, findUser, latestDeployEventId, waitForDeployEvent } from '../helpers/users.js'; test.describe('User Management', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/users`); - }); - - test('users page loads and shows current admin', async ({ page }) => { - // Username may appear in sidebar + page; use .first() - await expect(page.locator('main').getByText('e2eadmin').first()).toBeVisible({ timeout: 5_000 }); - }); - - test('create new viewer user', async ({ page }) => { - await page.getByRole('button', { name: /add user/i }).click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Placeholders from Users.svelte FormModal - use exact matching - await dialog.getByPlaceholder('e.g. Jane Doe', { exact: true }).fill('Test Viewer'); - await dialog.getByPlaceholder('jane@example.com', { exact: true }).fill('viewer@test.local'); - await dialog.getByPlaceholder('e.g. jane', { exact: true }).fill('testviewer'); - await dialog.getByPlaceholder('Min 8 characters', { exact: true }).fill('ViewerPass123!'); + test.describe.configure({ mode: 'serial' }); - // Select Viewer role (it's a button, not a radio) - await dialog.locator('button').filter({ hasText: 'Viewer' }).first().click(); + const UI_USER = 'e2e-ui-user'; + const UI_KEY = 'e2e-test-key'; - await dialog.getByRole('button', { name: /create user/i }).click(); - await expect(page.locator('main').getByText('testviewer').first()).toBeVisible({ timeout: 5_000 }); + test.beforeAll(async () => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + await deleteUserByName(UI_USER); }); - test('edit user display name', async ({ page }) => { - const row = page.locator('tr, [class*="card"]').filter({ hasText: 'testviewer' }); - const editBtn = row.getByRole('button', { name: /edit/i }); - if (await editBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await editBtn.click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - // Display Name placeholder is "e.g. Jane Doe" - const nameInput = dialog.getByPlaceholder('e.g. Jane Doe'); - await nameInput.fill('Updated Viewer'); - await dialog.getByRole('button', { name: /save/i }).click(); - await expect(page.locator('main').getByText('Updated Viewer').first()).toBeVisible({ timeout: 5_000 }); - } + test.afterAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + await deleteUserByName(UI_USER); }); - test('API keys section visible', async ({ page }) => { - await expect(page.locator('h3').getByText('API Keys')).toBeVisible({ timeout: 5_000 }); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/users`); }); - test('create API key', async ({ page }) => { - await page.getByRole('button', { name: /create key/i }).first().click(); + test('create user with the Manage role', async ({ page }) => { + await page.getByRole('button', { name: 'Add User' }).first().click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - // Placeholder is "e.g. ci-deploy" - await dialog.getByPlaceholder('e.g. ci-deploy').fill('e2e-test-key'); - await dialog.getByRole('button', { name: /create key/i }).click(); + await dialog.getByPlaceholder('e.g. Jane Doe', { exact: true }).fill('Test User'); + await dialog.getByPlaceholder('jane@example.com', { exact: true }).fill('ui-user@test.local'); + await dialog.getByPlaceholder('e.g. jane', { exact: true }).fill(UI_USER); + await dialog.getByPlaceholder('Min 8 characters', { exact: true }).fill('UiUserPass123!'); + // Viewer is the default, so pick Manage to prove the choice is sent. + await dialog.getByRole('button', { name: /^Manage/ }).click(); + await dialog.getByRole('button', { name: 'Create User' }).click(); + + await expect(dialog).toBeHidden(); + await expect(page.locator('main table tr').filter({ hasText: `@${UI_USER}` })).toContainText('manage'); + const u = await findUser(UI_USER); + expect(u).toMatchObject({ role: 'manage', display_name: 'Test User', email: 'ui-user@test.local' }); + }); - // After creation, key is shown (starts with "sd_" or similar prefix) - // The key display section shows the key in a element - await expect(page.locator('code').first()).toBeVisible({ timeout: 5_000 }); + test('edit display name and role', async ({ page }) => { + const row = page.locator('main table tr').filter({ hasText: `@${UI_USER}` }); + await row.getByRole('button', { name: 'Edit' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByPlaceholder('e.g. Jane Doe').fill('Updated User'); + await dialog.getByRole('button', { name: /^Viewer/ }).click(); + await dialog.getByRole('button', { name: 'Save Changes' }).click(); + + await expect(dialog).toBeHidden(); + await expect(row).toContainText('Updated User'); + await expect(row).toContainText('viewer'); + const u = await findUser(UI_USER); + expect(u).toMatchObject({ role: 'viewer', display_name: 'Updated User', email: 'ui-user@test.local' }); }); - test('delete API key', async ({ page }) => { - const revokeBtn = page.getByRole('button', { name: /revoke/i }).first(); - if (await revokeBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await revokeBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /revoke|delete|confirm/i }).click(); - } - } + test('create API key, use it, then revoke it', async ({ page }) => { + await page.getByRole('button', { name: 'Create Key' }).first().click(); + const dialog = page.getByRole('dialog'); + await dialog.getByPlaceholder('e.g. ci-deploy').fill(UI_KEY); + await dialog.getByRole('button', { name: 'Create Key' }).click(); + await expect(dialog).toBeHidden(); + + const code = page.locator('main code').filter({ hasText: /^sd_/ }); + await expect(code).toHaveText(/^sd_[0-9a-f]{64}$/); + const key = (await code.textContent()).trim(); + const keyRow = page.locator('main table tr').filter({ hasText: UI_KEY }); + await expect(keyRow).toBeVisible(); + + // The shown key is the real one: it authenticates as the admin. + const me = await apiRequestWithKey('GET', '/api/me', null, key); + expect(me.status).toBe(200); + expect(me.data.username).toBe(TEST_ADMIN.username); + + await keyRow.getByRole('button', { name: 'Revoke' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + await expect(keyRow).toHaveCount(0); + const keys = await apiRequest('GET', '/api/apikeys'); + expect(keys.status).toBe(200); + expect(keys.data.map((k) => k.name)).not.toContain(UI_KEY); + expect((await apiRequestWithKey('GET', '/api/me', null, key)).status).toBe(401); }); - test('delete viewer user', async ({ page }) => { - const row = page.locator('tr, [class*="card"]').filter({ hasText: 'testviewer' }); - const deleteBtn = row.getByRole('button', { name: /delete/i }); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } - await expect(page.locator('main').getByText('testviewer').first()).not.toBeVisible({ timeout: 5_000 }); - } + test('delete user', async ({ page }) => { + const row = page.locator('main table tr').filter({ hasText: `@${UI_USER}` }); + await row.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + await expect(row).toHaveCount(0); + expect(await findUser(UI_USER)).toBeUndefined(); }); }); @@ -100,68 +102,53 @@ test.describe('Users - Functional RBAC', () => { email: 'rbac-viewer@test.local', role: 'viewer', }; + const MANAGE_USER = { + username: 'e2erbac_manage', + password: 'ManagePass123!', + display_name: 'E2E RBAC Manage', + email: 'rbac-manage@test.local', + role: 'manage', + }; let viewerId = null; let viewerKey = null; - let viewerKeyId = null; test.beforeAll(async () => { - // Authenticate as super_admin - const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(login.ok).toBeTruthy(); - - // Cleanup any leftover from prior runs - const listRes = await apiRequest('GET', '/api/users'); - if (listRes.ok && Array.isArray(listRes.data)) { - const existing = listRes.data.find((u) => u.username === VIEWER_USER.username); - if (existing) { - await apiRequest('DELETE', `/api/users/${existing.id}`); - } - } + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + await deleteUserByName(VIEWER_USER.username); + await deleteUserByName(MANAGE_USER.username); }); test.afterAll(async () => { - // Always try to cleanup so 99-cleanup remains clean - try { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - if (viewerKeyId) { - await apiRequest('DELETE', `/api/apikeys/${viewerKeyId}`); - } - if (viewerId) { - await apiRequest('DELETE', `/api/users/${viewerId}`); - } - } catch {} + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + await deleteUserByName(VIEWER_USER.username); + await deleteUserByName(MANAGE_USER.username); }); test('create restricted viewer user and grant app-nginx access', async () => { const createRes = await apiRequest('POST', '/api/users', VIEWER_USER); expect(createRes.status).toBe(201); - expect(createRes.data.id).toBeTruthy(); expect(createRes.data.role).toBe('viewer'); viewerId = createRes.data.id; - // Grant access to e2e-nginx only (endpoint: POST /api/users/:id/access {app_slug}) const grantRes = await apiRequest('POST', `/api/users/${viewerId}/access`, { app_slug: 'e2e-nginx', }); - expect(grantRes.ok).toBeTruthy(); + expect(grantRes.status).toBe(200); }); test('create API key by logging in as the viewer', async () => { // API keys are owned by the authenticated caller, so log in as viewer const vLogin = await apiLogin(VIEWER_USER.username, VIEWER_USER.password); - expect(vLogin.ok).toBeTruthy(); + expect(vLogin.status).toBe(200); const keyRes = await apiRequest('POST', '/api/apikeys', { name: 'e2e-rbac-key' }); expect(keyRes.status).toBe(201); - expect(typeof keyRes.data.key).toBe('string'); - expect(keyRes.data.key.length).toBeGreaterThan(10); + expect(keyRes.data.key).toMatch(/^sd_[0-9a-f]{64}$/); viewerKey = keyRes.data.key; - viewerKeyId = keyRes.data.id; }); test('viewer API key can access allowed app', async () => { - expect(viewerKey).toBeTruthy(); const res = await apiRequestWithKey('GET', '/api/apps/e2e-nginx', null, viewerKey); expect(res.status).toBe(200); // App struct has no JSON tags; fields are PascalCase. @@ -169,33 +156,21 @@ test.describe('Users - Functional RBAC', () => { }); test('viewer API key cannot access restricted app (404 via appAccessMiddleware)', async () => { - expect(viewerKey).toBeTruthy(); const res = await apiRequestWithKey('GET', '/api/apps/e2e-multi', null, viewerKey); - // appAccessMiddleware returns 404 for denied apps expect(res.status).toBe(404); }); - test('viewer cannot perform super_admin actions (create user -> 403)', async () => { - expect(viewerKey).toBeTruthy(); - const res = await apiRequestWithKey('POST', '/api/users', { + test('viewer cannot create or delete users or run maintenance (403)', async () => { + const create = await apiRequestWithKey('POST', '/api/users', { username: 'should_not_create', password: 'somepass12345', role: 'viewer', }, viewerKey); - expect(res.status).toBe(403); - }); - - test('viewer cannot delete users (403)', async () => { - expect(viewerKey).toBeTruthy(); - // attempt to delete self or admin - should be forbidden by role check - const res = await apiRequestWithKey('DELETE', `/api/users/${viewerId}`, null, viewerKey); - expect(res.status).toBe(403); - }); - - test('viewer cannot access system maintenance endpoints (403)', async () => { - expect(viewerKey).toBeTruthy(); - const res = await apiRequestWithKey('POST', '/api/system/vacuum', null, viewerKey); - expect(res.status).toBe(403); + expect(create.status).toBe(403); + const del = await apiRequestWithKey('DELETE', `/api/users/${viewerId}`, null, viewerKey); + expect(del.status).toBe(403); + const vacuum = await apiRequestWithKey('POST', '/api/system/vacuum', null, viewerKey); + expect(vacuum.status).toBe(403); }); test('invalid API key is rejected (401)', async () => { @@ -204,11 +179,8 @@ test.describe('Users - Functional RBAC', () => { }); test('dashboard list filters apps for restricted viewer', async () => { - expect(viewerKey).toBeTruthy(); - // Viewer was granted access only to e2e-nginx earlier in this suite. const res = await apiRequestWithKey('GET', '/api/apps', null, viewerKey); expect(res.status).toBe(200); - expect(Array.isArray(res.data)).toBeTruthy(); const slugs = res.data.map((a) => a.Slug); expect(slugs).toContain('e2e-nginx'); expect(slugs).not.toContain('e2e-multi'); @@ -216,61 +188,37 @@ test.describe('Users - Functional RBAC', () => { }); test('GET /api/users/:id/access lists granted slugs (super_admin only)', async () => { - const adm = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(adm.ok).toBeTruthy(); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); const res = await apiRequest('GET', `/api/users/${viewerId}/access`); expect(res.status).toBe(200); expect(res.data).toEqual(['e2e-nginx']); - // Forbidden for the viewer's own API key. const forbidden = await apiRequestWithKey('GET', `/api/users/${viewerId}/access`, null, viewerKey); expect(forbidden.status).toBe(403); }); test('manage user: granted app can mutate; ungranted app and platform endpoints denied', async () => { - // Re-login as super_admin to provision a manage user. - const adm = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(adm.ok).toBeTruthy(); - - const manageUser = { - username: 'e2erbac_manage', - password: 'ManagePass123!', - display_name: 'E2E RBAC Manage', - email: 'rbac-manage@test.local', - role: 'manage', - }; - - // Cleanup any leftover. - const list = await apiRequest('GET', '/api/users'); - if (list.ok && Array.isArray(list.data)) { - const existing = list.data.find((u) => u.username === manageUser.username); - if (existing) await apiRequest('DELETE', `/api/users/${existing.id}`); - } - - const created = await apiRequest('POST', '/api/users', manageUser); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + const created = await apiRequest('POST', '/api/users', MANAGE_USER); expect(created.status).toBe(201); expect(created.data.role).toBe('manage'); - const mid = created.data.id; - - // Grant access only to e2e-nginx. - const grant = await apiRequest('POST', `/api/users/${mid}/access`, { app_slug: 'e2e-nginx' }); - expect(grant.ok).toBeTruthy(); + const grant = await apiRequest('POST', `/api/users/${created.data.id}/access`, { app_slug: 'e2e-nginx' }); + expect(grant.status).toBe(200); - // Login as the manage user and create an API key. - const mLogin = await apiLogin(manageUser.username, manageUser.password); - expect(mLogin.ok).toBeTruthy(); + expect((await apiLogin(MANAGE_USER.username, MANAGE_USER.password)).status).toBe(200); const keyRes = await apiRequest('POST', '/api/apikeys', { name: 'e2e-rbac-manage' }); expect(keyRes.status).toBe(201); const mKey = keyRes.data.key; - const mKeyId = keyRes.data.id; + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); - // Manage can read the granted app. const readOk = await apiRequestWithKey('GET', '/api/apps/e2e-nginx', null, mKey); expect(readOk.status).toBe(200); - // Manage can mutate the granted app (restart). + // Restart runs in the background; wait for it so later specs get a settled app. + const before = await latestDeployEventId('e2e-nginx'); const restartOk = await apiRequestWithKey('POST', '/api/apps/e2e-nginx/restart', null, mKey); - expect([200, 202]).toContain(restartOk.status); + expect(restartOk.status).toBe(202); + expect((await waitForDeployEvent('e2e-nginx', before)).action).toBe('restart'); // Cannot read or mutate an ungranted app (404 from access middleware). const otherRead = await apiRequestWithKey('GET', '/api/apps/e2e-multi', null, mKey); @@ -278,91 +226,48 @@ test.describe('Users - Functional RBAC', () => { const otherRestart = await apiRequestWithKey('POST', '/api/apps/e2e-multi/restart', null, mKey); expect(otherRestart.status).toBe(404); - // Cannot deploy a new app (super_admin only). + // super_admin only: deploy, delete app, users, system, registries. const deployRes = await apiRequestWithKey('POST', '/api/apps/deploy', { name: 'should-not-exist', compose: 'services: {}\n' }, mKey); expect(deployRes.status).toBe(403); - - // Cannot delete an app. const delApp = await apiRequestWithKey('DELETE', '/api/apps/e2e-nginx', null, mKey); expect(delApp.status).toBe(403); - - // Cannot list users. const listUsers = await apiRequestWithKey('GET', '/api/users', null, mKey); expect(listUsers.status).toBe(403); - - // Cannot touch system endpoints. const sysVac = await apiRequestWithKey('POST', '/api/system/vacuum', null, mKey); expect(sysVac.status).toBe(403); const regs = await apiRequestWithKey('GET', '/api/registries', null, mKey); expect(regs.status).toBe(403); - - // Cleanup: re-login as super_admin and delete the manage user (cascades key). - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - if (mKeyId) await apiRequest('DELETE', `/api/apikeys/${mKeyId}`); - await apiRequest('DELETE', `/api/users/${mid}`); }); test('UI: grant additional app access via Users edit modal', async ({ page }) => { - // Log in as super_admin in the browser. await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/users`); - await expect(page.locator('main').getByText(VIEWER_USER.username).first()).toBeVisible({ timeout: 5_000 }); + await page.goto(`${getState().baseURL}/#/users`); - // Open edit modal for the RBAC viewer row. - const row = page.locator('tr, [class*="card"]').filter({ hasText: VIEWER_USER.username }); - await row.getByRole('button', { name: /edit/i }).first().click(); + const row = page.locator('main table tr').filter({ hasText: `@${VIEWER_USER.username}` }); + await row.getByRole('button', { name: 'Edit' }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText(/app access/i)).toBeVisible({ timeout: 5_000 }); - - // Find e2e-multi row checkbox (currently unchecked) and toggle on. - const multiLabel = dialog.locator('label').filter({ hasText: 'e2e-multi' }); - await expect(multiLabel).toBeVisible({ timeout: 5_000 }); - const multiCheckbox = multiLabel.locator('input[type="checkbox"]'); - expect(await multiCheckbox.isChecked()).toBe(false); - await multiCheckbox.check(); - - // Wait for the API call to settle then close modal. - await page.waitForTimeout(500); + const multiCheckbox = dialog.locator('label').filter({ hasText: 'e2e-multi' }).locator('input[type="checkbox"]'); + await expect(multiCheckbox).not.toBeChecked(); - // Verify via API: viewer now has both slugs granted. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + const grantP = page.waitForResponse((r) => r.url().endsWith(`/api/users/${viewerId}/access`) && r.request().method() === 'POST'); + await multiCheckbox.check(); + expect((await grantP).status()).toBe(200); const accessRes = await apiRequest('GET', `/api/users/${viewerId}/access`); - expect(accessRes.status).toBe(200); expect(accessRes.data.sort()).toEqual(['e2e-multi', 'e2e-nginx']); - // Revoke via UI by unchecking again, to leave viewer in original state. + const revokeP = page.waitForResponse((r) => r.url().endsWith(`/api/users/${viewerId}/access/e2e-multi`) && r.request().method() === 'DELETE'); await multiCheckbox.uncheck(); - await page.waitForTimeout(500); - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await revokeP).status()).toBe(200); const after = await apiRequest('GET', `/api/users/${viewerId}/access`); expect(after.data).toEqual(['e2e-nginx']); }); - test('cleanup: revoke app access, delete API key and viewer user', async () => { - // Re-login as super_admin to delete the key and user - const adm = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(adm.ok).toBeTruthy(); - - // Revoke access - await apiRequest('DELETE', `/api/users/${viewerId}/access/e2e-nginx`); - - // Delete API key (super_admin can delete any) - if (viewerKeyId) { - const delKey = await apiRequest('DELETE', `/api/apikeys/${viewerKeyId}`); - expect([200, 404].includes(delKey.status)).toBeTruthy(); - viewerKeyId = null; - } - - // Delete viewer user + test('deleting the viewer revokes their API key', async () => { const delUser = await apiRequest('DELETE', `/api/users/${viewerId}`); - expect([200, 404].includes(delUser.status)).toBeTruthy(); + expect(delUser.status).toBe(200); viewerId = null; - - // Verify API key no longer works (either 401 unauthorized or 404 not-found for app) const verify = await apiRequestWithKey('GET', '/api/apps/e2e-nginx', null, viewerKey); - expect([401, 404].includes(verify.status)).toBeTruthy(); + expect(verify.status).toBe(401); }); }); diff --git a/e2e/tests/15-registries.spec.js b/e2e/tests/15-registries.spec.js index 63abf900..a59c9226 100644 --- a/e2e/tests/15-registries.spec.js +++ b/e2e/tests/15-registries.spec.js @@ -1,43 +1,36 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +// UI add/delete only; 29b covers a real authenticated pull. test.describe('Registries', () => { - test.beforeEach(async ({ page }) => { + test('add a registry, then delete it', async ({ page }) => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/registries`); - }); - - test('registries page loads', async ({ page }) => { - await expect(page.getByText(/registr/i).first()).toBeVisible({ timeout: 5_000 }); - }); + await page.goto(`${getState().baseURL}/#/registries`); - test('add registry', async ({ page }) => { - await page.getByRole('button', { name: /add registry/i }).click(); - - // SlidePanel opens with role="dialog" + await page.getByRole('button', { name: 'Add Registry' }).click(); const panel = page.getByRole('dialog'); - await expect(panel).toBeVisible({ timeout: 5_000 }); - - // Form fields in order: Name (no placeholder), URL, Username, Password - const inputs = panel.locator('input'); - await inputs.nth(0).fill('GitHub CR'); // Name - await panel.getByPlaceholder('registry.example.com').fill('ghcr.io'); // URL - await inputs.nth(2).fill('testuser'); // Username - await inputs.nth(3).fill('testtoken'); // Password + const field = (label) => panel.locator(`div:has(> label:text-is("${label}")) > input`); + await field('Name').fill('GitHub CR'); + await field('URL').fill('ghcr.io'); + await field('Username').fill('testuser'); + await field('Password').fill('testtoken'); + await panel.getByRole('button', { name: 'Add Registry' }).click(); - await panel.getByRole('button', { name: /add registry/i }).click(); - await expect(page.getByText('ghcr.io')).toBeVisible({ timeout: 5_000 }); - }); + const row = page.locator('main table tr').filter({ hasText: 'ghcr.io' }); + await expect(row).toContainText('GitHub CR'); + await expect(row).toContainText('testuser'); + const list = await apiRequest('GET', '/api/registries'); + expect(list.status).toBe(200); + const reg = list.data.find((r) => r.url === 'ghcr.io'); + expect(reg).toMatchObject({ name: 'GitHub CR', username: 'testuser' }); + // Credentials are write-only. + expect(JSON.stringify(list.data)).not.toContain('testtoken'); - test('delete registry', async ({ page }) => { - const deleteBtn = page.getByRole('button', { name: /delete/i }).first(); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } - } + await row.getByRole('button', { name: 'Delete' }).click(); + await expect(row).toHaveCount(0); + const after = await apiRequest('GET', '/api/registries'); + expect(after.data.find((r) => r.url === 'ghcr.io')).toBeUndefined(); }); }); diff --git a/e2e/tests/16-docker.spec.js b/e2e/tests/16-docker.spec.js index 7d5d20e3..2ebc9e79 100644 --- a/e2e/tests/16-docker.spec.js +++ b/e2e/tests/16-docker.spec.js @@ -1,51 +1,37 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; +import { findServiceContainer, dockerInspect } from '../helpers/docker.js'; +// The only E2E coverage of the real Docker SDK calls behind /api/docker/*. test.describe('Docker Management', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/docker`); - }); - - test('Docker info loads', async ({ page }) => { - await expect(page.getByText(/version/i).first()).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(/containers/i).first()).toBeVisible(); - }); - - test('disk cleanup tab shows usage', async ({ page }) => { - // Disk Cleanup is the default tab, should already be visible - await page.locator('button').filter({ hasText: 'Disk Cleanup' }).click(); - await expect(page.getByText(/Containers|Images|Volumes|Build Cache/i).first()).toBeVisible({ timeout: 5_000 }); - }); + test('info, images, networks and volumes reflect the real daemon', async ({ page }) => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + const info = await apiRequest('GET', '/api/docker/info'); + expect(info.status).toBe(200); + expect(info.data.containers_running).toBeGreaterThan(0); - test('images tab lists images', async ({ page }) => { - await page.locator('button').filter({ hasText: /^Images$/ }).click(); - await expect(page.getByText(/nginx/i).first()).toBeVisible({ timeout: 10_000 }); - }); + const nginx = dockerInspect(findServiceContainer('e2e-nginx', 'web')); + const pg = dockerInspect(findServiceContainer('e2e-postgres', 'db')); + const imageShortId = nginx.Image.replace('sha256:', '').slice(0, 12); + const networkNames = Object.keys(nginx.NetworkSettings.Networks); + const pgVolume = pg.Mounts.find((m) => m.Type === 'volume').Name; - test('networks tab lists networks', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Networks & Volumes' }).click(); - await expect(page.getByText(/bridge/i).first()).toBeVisible({ timeout: 5_000 }); - }); + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/docker`); + const main = page.locator('main'); + const card = (label) => main.locator(`div:has(> div:text-is("${label}")) > div:nth-child(2)`); + await expect(card('Version')).toHaveText(info.data.server_version); - test('volumes tab lists volumes', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Networks & Volumes' }).click(); - // Networks & Volumes tab shows both - await expect(page.getByText(/Volumes/i).first()).toBeVisible({ timeout: 5_000 }); - }); + await main.getByRole('button', { name: 'Images', exact: true }).click(); + const imageRow = main.locator('table tr').filter({ hasText: imageShortId }); + await expect(imageRow).toHaveCount(1); + await expect(imageRow.locator('td').first()).toHaveText(/(^|\/)nginx$/); - test('prune containers', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Disk Cleanup' }).click(); - const pruneBtn = page.locator('button').filter({ hasText: /^Prune$/ }).first(); - if (await pruneBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await pruneBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /prune|confirm/i }).click(); - } - // Wait for toast - await page.waitForTimeout(3_000); + await main.getByRole('button', { name: 'Networks & Volumes' }).click(); + for (const name of networkNames) { + await expect(main.locator('table td').getByText(name, { exact: true })).toHaveCount(1); } + await expect(main.locator('table td').getByText(pgVolume, { exact: true })).toHaveCount(1); }); }); diff --git a/e2e/tests/17-system.spec.js b/e2e/tests/17-system.spec.js index 369b7856..65b37f99 100644 --- a/e2e/tests/17-system.spec.js +++ b/e2e/tests/17-system.spec.js @@ -1,193 +1,112 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; -import { readFileSync, writeFileSync, unlinkSync, mkdtempSync } from 'fs'; +import { apiLogin, apiRequest, getSessionCookie } from '../helpers/api.js'; +import { getAppId, sqliteExec } from '../helpers/db.js'; +import { readFileSync, writeFileSync, rmSync, mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { execFileSync } from 'child_process'; +const SQLITE_MAGIC = 'SQLite format 3\x00'; + test.describe('System Administration', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/system`); + test.beforeAll(async () => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - test('system overview loads', async ({ page }) => { - // Section heading "SimpleDeploy" in overview tab - await expect(page.locator('h2').filter({ hasText: /SimpleDeploy/i }).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText(/uptime/i).first()).toBeVisible(); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/system`); }); - test('shows Native deployment badge in StatusBar and Deployment card on overview', async ({ page }) => { - // beforeEach already navigated to /#/system and logged in - await expect(page.locator('a[href="#/docker"]').getByText('Native', { exact: true })).toBeVisible({ timeout: 5_000 }); + test('overview shows live system info and the Native deployment', async ({ page }) => { + const info = await apiRequest('GET', '/api/system/info'); + expect(info.status).toBe(200); + await expect(page.locator('a[href="#/docker"]').getByText('Native', { exact: true })).toBeVisible(); const main = page.locator('main'); - await expect(main.getByRole('heading', { name: 'Deployment', exact: true })).toBeVisible({ timeout: 5_000 }); + await expect(main.getByRole('heading', { name: 'Deployment', exact: true })).toBeVisible(); await expect(main.getByText('Native Binary', { exact: true })).toBeVisible(); await expect(main.getByText(/Values reflect/)).toHaveCount(0); - }); - - test('shows database info', async ({ page }) => { - await expect(page.getByText(/database|sqlite/i).first()).toBeVisible({ timeout: 5_000 }); - }); - test('shows system resources', async ({ page }) => { - await expect(page.getByText(/cpu|cores/i).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText(/ram|memory/i).first()).toBeVisible(); + const value = (label) => main.locator(`div:has(> div:text-is("${label}")) > div:nth-child(2)`); + await expect(value('Uptime')).toHaveText(/\d/); + await expect(value('CPU Cores')).toHaveText(String(info.data.resources.cpu_count)); + await expect(value('Path')).toHaveText(info.data.database.path); }); - test('maintenance tab - vacuum database', async ({ page }) => { - // Tab buttons are not role="button" by default in this UI; use text-based selector + test('maintenance: VACUUM succeeds and Download Now saves a SQLite file', async ({ page }) => { await page.locator('button').filter({ hasText: 'Maintenance' }).click(); - const vacuumBtn = page.getByRole('button', { name: /Run VACUUM/i }); - await expect(vacuumBtn).toBeVisible({ timeout: 5_000 }); - await vacuumBtn.click(); - // Wait for toast or success indication - await page.waitForTimeout(3_000); + await page.getByRole('button', { name: 'Run VACUUM' }).click(); + await expect(page.getByRole('alert').filter({ hasText: 'VACUUM completed' })).toBeVisible(); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Download Now' }).click(), + ]); + expect(download.suggestedFilename()).toMatch(/^simpledeploy-\d{8}-\d{6}\.db$/); + const buf = readFileSync(await download.path()); + expect(buf.subarray(0, 16).toString('binary')).toBe(SQLITE_MAGIC); + await expect(page.getByRole('alert').filter({ hasText: 'Backup downloaded' })).toBeVisible(); }); - test('maintenance tab - prune metrics', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Maintenance' }).click(); - // "Prune Metrics" section has a select for tiers - const tierSelect = page.locator('select').first(); - await expect(tierSelect).toBeVisible({ timeout: 5_000 }); - }); - - test('maintenance tab - download database backup', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Maintenance' }).click(); - const downloadBtn = page.getByRole('button', { name: /Download Now/i }); - await expect(downloadBtn).toBeVisible({ timeout: 5_000 }); - }); - - test('audit log tab loads', async ({ page }) => { - await page.locator('button').filter({ hasText: 'Audit Log' }).click(); - // Audit log section has heading "Audit Log" and table headers or empty state - await expect(page.getByText(/No activity yet|Load more|Retention/i).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('logs tab loads', async ({ page }) => { + test('logs tab renders process log lines', async ({ page }) => { await page.locator('button').filter({ hasText: /^Logs$/ }).click(); - // Logs tab has "Auto-scroll" checkbox label and "Refresh" button - await expect(page.getByText(/Auto-scroll/i).first()).toBeVisible({ timeout: 5_000 }); + const logs = page.locator('#logs-container'); + await expect(logs.locator('.font-mono > div').first()).toBeVisible(); + await expect(logs).toContainText(/[1-9]\d* entries/); }); }); test.describe('System DB Backup - Functional', () => { test.describe.configure({ mode: 'serial' }); - const SQLITE_MAGIC = 'SQLite format 3\x00'; const tmpDir = mkdtempSync(join(tmpdir(), 'sd-db-backup-')); - const tmpFiles = []; - - function downloadDBBackup(compact) { - const state = getState(); - const query = compact ? '?compact=true' : ''; - // Use curl to cleanly capture binary bytes and preserve auth cookie from apiRequest. - // Simpler: use fetch with Cookie header relayed from apiRequest's session. - // We re-login via fetch to capture cookie directly. - return fetchWithAdminCookie('POST', `/api/system/backup/download${query}`); - } + const SEED_LATENCY = 4242.5; + let fullAppsCount = 0; - async function fetchWithAdminCookie(method, path) { - const state = getState(); - // Log in directly to grab cookie - const loginRes = await fetch(`${state.baseURL}/api/auth/login`, { + async function downloadDB(compact) { + const res = await fetch(`${getState().baseURL}/api/system/backup/download${compact ? '?compact=true' : ''}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: TEST_ADMIN.username, - password: TEST_ADMIN.password, - }), + headers: { Cookie: getSessionCookie() }, }); - expect(loginRes.ok).toBeTruthy(); - const setCookie = loginRes.headers.get('set-cookie') || ''; - const cookie = setCookie.split(';')[0]; - - const res = await fetch(`${state.baseURL}${path}`, { - method, - headers: { Cookie: cookie }, - }); - return res; + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 16).toString('binary')).toBe(SQLITE_MAGIC); + const dest = join(tmpDir, compact ? 'compact.db' : 'full.db'); + writeFileSync(dest, buf); + return (sql) => execFileSync('sqlite3', [dest, sql], { encoding: 'utf-8' }).trim(); } test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + // Seed one row in each table compact mode strips, so neither check passes vacuously. + const appId = getAppId('e2e-nginx'); + const now = Math.floor(Date.now() / 1000); + sqliteExec(`INSERT INTO metrics (app_id, container_id, ts, tier, cpu_pct, mem_bytes, mem_limit) VALUES (${appId}, 'e2e-backup-seed', ${now}, '1d', 1, 1, 4);`); + sqliteExec(`INSERT INTO request_metrics (app_id, ts, tier, count, max_latency) VALUES (${appId}, ${now}, '1d', 1, ${SEED_LATENCY});`); }); test.afterAll(() => { - for (const f of tmpFiles) { - try { unlinkSync(f); } catch {} - } - }); - - test('POST /api/system/backup/download returns valid sqlite bytes', async () => { - const res = await downloadDBBackup(false); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.length).toBeGreaterThan(0); - - // Magic header: "SQLite format 3\x00" = 16 bytes - const magic = buf.slice(0, 16).toString('binary'); - expect(magic).toBe(SQLITE_MAGIC); - - // Write to disk and open with sqlite3 CLI - const dest = join(tmpDir, 'backup-full.db'); - writeFileSync(dest, buf); - tmpFiles.push(dest); - - const appsCount = Number( - execFileSync('sqlite3', [dest, 'SELECT COUNT(*) FROM apps'], { encoding: 'utf-8' }).trim(), - ); - // We expect at least 1 app in DB; the three test apps (e2e-nginx, e2e-multi, - // e2e-postgres) should be present unless an earlier test already cleaned up. - expect(appsCount).toBeGreaterThanOrEqual(1); + sqliteExec(`DELETE FROM metrics WHERE container_id = 'e2e-backup-seed';`); + sqliteExec(`DELETE FROM request_metrics WHERE max_latency = ${SEED_LATENCY};`); + rmSync(tmpDir, { recursive: true, force: true }); }); - test('compact backup excludes metrics and request_metrics but preserves apps', async () => { - const res = await downloadDBBackup(true); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.length).toBeGreaterThan(0); - - const magic = buf.slice(0, 16).toString('binary'); - expect(magic).toBe(SQLITE_MAGIC); - - const dest = join(tmpDir, 'backup-compact.db'); - writeFileSync(dest, buf); - tmpFiles.push(dest); - - // apps table should still have rows - const appsCount = Number( - execFileSync('sqlite3', [dest, 'SELECT COUNT(*) FROM apps'], { encoding: 'utf-8' }).trim(), - ); - expect(appsCount).toBeGreaterThanOrEqual(1); - - // metrics table should be empty (stripped by compact mode) - const metricsCount = Number( - execFileSync('sqlite3', [dest, 'SELECT COUNT(*) FROM metrics'], { encoding: 'utf-8' }).trim(), - ); - expect(metricsCount).toBe(0); - - // request_metrics table should also be empty - const reqCount = Number( - execFileSync('sqlite3', [dest, 'SELECT COUNT(*) FROM request_metrics'], { encoding: 'utf-8' }).trim(), + test('full backup is a VACUUM INTO snapshot with apps and metrics', async () => { + const q = await downloadDB(false); + expect(q('SELECT slug FROM apps ORDER BY slug').split('\n')).toEqual( + expect.arrayContaining(['e2e-multi', 'e2e-nginx', 'e2e-postgres']), ); - expect(reqCount).toBe(0); + expect(q(`SELECT COUNT(*) FROM metrics WHERE container_id = 'e2e-backup-seed'`)).toBe('1'); + expect(q(`SELECT COUNT(*) FROM request_metrics WHERE max_latency = ${SEED_LATENCY}`)).toBe('1'); + fullAppsCount = Number(q('SELECT COUNT(*) FROM apps')); }); - test('GET /api/system/backup/runs returns an array', async () => { - const res = await apiRequest('GET', '/api/system/backup/runs'); - expect(res.ok).toBeTruthy(); - expect(Array.isArray(res.data)).toBe(true); - // Note: download endpoint does not record runs; only the cron job does. - // Each run record (if present from a configured schedule) must have expected shape. - for (const run of res.data) { - expect(typeof run.id).toBe('number'); - expect(typeof run.file_path).toBe('string'); - expect(typeof run.size_bytes).toBe('number'); - expect(typeof run.status).toBe('string'); - } + test('compact backup strips metrics and request_metrics but keeps apps', async () => { + const q = await downloadDB(true); + expect(Number(q('SELECT COUNT(*) FROM apps'))).toBe(fullAppsCount); + expect(q('SELECT COUNT(*) FROM metrics')).toBe('0'); + expect(q('SELECT COUNT(*) FROM request_metrics')).toBe('0'); }); }); diff --git a/e2e/tests/18-profile.spec.js b/e2e/tests/18-profile.spec.js index edd0a343..834f7f9b 100644 --- a/e2e/tests/18-profile.spec.js +++ b/e2e/tests/18-profile.spec.js @@ -1,65 +1,49 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState, TEST_ADMIN, login } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; test.describe('Profile', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/profile`); + await page.goto(`${getState().baseURL}/#/profile`); }); - test('profile page shows user info', async ({ page }) => { - // Username appears in sidebar + profile page; scope to main content - await expect(page.locator('main').getByText(TEST_ADMIN.username).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.locator('main').getByText(/super_admin/i).first()).toBeVisible(); + test('update display name persists', async ({ page }) => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + try { + await page.locator('#displayName').fill('Updated Admin Name'); + await page.getByRole('button', { name: 'Save Profile' }).click(); + await expect(page.getByRole('alert').filter({ hasText: 'Profile updated' })).toBeVisible(); + + const me = await apiRequest('GET', '/api/me'); + expect(me.data.display_name).toBe('Updated Admin Name'); + expect(me.data.email).toBe(TEST_ADMIN.email); + await page.reload(); + await expect(page.locator('#displayName')).toHaveValue('Updated Admin Name'); + } finally { + const restore = await apiRequest('PUT', '/api/me', { display_name: TEST_ADMIN.displayName, email: TEST_ADMIN.email }); + expect(restore.status).toBe(200); + } }); - test('update display name', async ({ page }) => { - const nameInput = page.locator('#displayName'); - await nameInput.fill('Updated Admin Name'); - await page.getByRole('button', { name: /save profile/i }).click(); - await expect(page.locator('[role="alert"]').first()).toBeVisible({ timeout: 5_000 }); - await nameInput.fill(TEST_ADMIN.displayName); - await page.getByRole('button', { name: /save profile/i }).click(); - }); - - test('change password and re-login', async ({ page }) => { + test('change password: old one stops working, new one signs in', async ({ page }) => { const newPassword = 'NewE2ePass456!'; - await page.locator('#currentPw').fill(TEST_ADMIN.password); - await page.locator('#newPw').fill(newPassword); - await page.locator('#confirmPw').fill(newPassword); - await page.getByRole('button', { name: /change password/i }).click(); - await expect(page.locator('[role="alert"]').first()).toBeVisible({ timeout: 5_000 }); - - const state = getState(); - await page.goto(`${state.baseURL}/#/profile`); - // Click the "Log out" button/link in profile page - await page.locator('button, a').filter({ hasText: /log out/i }).click(); - await page.waitForSelector('#username', { timeout: 5_000 }); - - await login(page, TEST_ADMIN.username, newPassword); - // After login, sidebar should be visible - await expect(page.locator('aside:not([data-testid="activity-sidebar"])')).toBeVisible({ timeout: 5_000 }); - - await page.goto(`${state.baseURL}/#/profile`); - await page.locator('#currentPw').fill(newPassword); - await page.locator('#newPw').fill(TEST_ADMIN.password); - await page.locator('#confirmPw').fill(TEST_ADMIN.password); - await page.getByRole('button', { name: /change password/i }).click(); - await expect(page.locator('[role="alert"]').first()).toBeVisible({ timeout: 5_000 }); - }); - - test('theme toggle works', async ({ page }) => { - const themeBtn = page.getByRole('button', { name: 'Toggle theme' }); - await expect(themeBtn).toBeVisible(); - // Get initial title (e.g., "Theme: system") - const titleBefore = await themeBtn.getAttribute('title'); - // Click to cycle theme - await themeBtn.click(); - // Title should change (system -> light -> dark) - const titleAfter = await themeBtn.getAttribute('title'); - expect(titleAfter).not.toBe(titleBefore); - // Click again to restore for other tests - await themeBtn.click(); + try { + await page.locator('#currentPw').fill(TEST_ADMIN.password); + await page.locator('#newPw').fill(newPassword); + await page.locator('#confirmPw').fill(newPassword); + await page.getByRole('button', { name: 'Change Password' }).click(); + await expect(page.getByRole('alert').filter({ hasText: 'Password changed' })).toBeVisible(); + + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(401); + expect((await apiLogin(TEST_ADMIN.username, newPassword)).status).toBe(200); + } finally { + // Later specs sign in with the original password: restore it whichever one is current. + if ((await apiLogin(TEST_ADMIN.username, newPassword)).status === 200) { + const res = await apiRequest('PUT', '/api/me/password', { current_password: newPassword, new_password: TEST_ADMIN.password }); + expect(res.status).toBe(200); + } + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + } }); }); diff --git a/e2e/tests/18a-realtime.spec.js b/e2e/tests/18a-realtime.spec.js index 79568abb..f12f759b 100644 --- a/e2e/tests/18a-realtime.spec.js +++ b/e2e/tests/18a-realtime.spec.js @@ -2,22 +2,15 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest } from '../helpers/api.js'; -// Realtime WS keeps the dashboard in sync without manual refresh. -// We mutate via a separate API call (different "session") and watch the -// open page reflect the change within a few seconds. +// Realtime WS keeps pages in sync without manual refresh. We mutate via a +// separate API session and watch the open page reflect the change. test.describe('Realtime updates', () => { - test.beforeEach(async ({ page }) => { - // Re-establish admin session for apiRequest (prior specs may have left a - // viewer or stale cookie on the module-level state). - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - await loginAsAdmin(page); - }); - test('Users page reflects new user without refresh', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/users`); - // Wait for users page to settle: the existing admin row should be present. - await expect(page.locator('main').getByText('e2eadmin').first()).toBeVisible({ timeout: 5_000 }); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/users`); + // Users page has settled once the admin row is present. + await expect(page.locator('main table').getByText(`@${TEST_ADMIN.username}`)).toBeVisible(); const username = `rt-${Date.now()}`; const res = await apiRequest('POST', '/api/users', { @@ -27,31 +20,12 @@ test.describe('Realtime updates', () => { display_name: 'RT User', email: 'rt@test.local', }); - expect(res.ok).toBe(true); - - // No reload: the realtime store should have refetched the users list. - await expect(page.locator('main').getByText(username).first()).toBeVisible({ timeout: 8_000 }); - - // Cleanup: delete the user via API. - if (res.data?.id) { - await apiRequest('DELETE', `/api/users/${res.data.id}`); + expect(res.status).toBe(201); + try { + // No reload: the realtime store should have refetched the users list. + await expect(page.locator('main table').getByText(`@${username}`)).toBeVisible({ timeout: 8_000 }); + } finally { + expect((await apiRequest('DELETE', `/api/users/${res.data.id}`)).status).toBe(200); } }); - - test('Dashboard refreshes when an app is renamed via API', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible({ timeout: 5_000 }); - - // Touch the app via a no-op env update so an audit/env event fires. - const slug = 'e2e-nginx'; - const envRes = await apiRequest('PUT', `/api/apps/${slug}/env`, [{ key: 'RT_PING', value: '1' }]); - expect(envRes.ok).toBe(true); - - // The dashboard should still show the app card, possibly refreshed. - await expect(page.getByRole('heading', { name: 'e2e-nginx', exact: true })).toBeVisible({ timeout: 5_000 }); - - // Clean up env var. - await apiRequest('PUT', `/api/apps/${slug}/env`, []); - }); }); diff --git a/e2e/tests/20-activity.spec.js b/e2e/tests/20-activity.spec.js index f182a69d..5afe9089 100644 --- a/e2e/tests/20-activity.spec.js +++ b/e2e/tests/20-activity.spec.js @@ -1,195 +1,101 @@ import { test, expect } from '@playwright/test'; -import { loginAsAdmin, getState } from '../helpers/auth.js'; +import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; +import { b64 } from '../helpers/users.js'; -test.describe('Activity changelog', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - }); - - test('per-app activity tab shows entries with summaries', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); +async function openAppActivity(page, slug) { + await page.goto(`${getState().baseURL}/#/apps/${slug}`); + await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); +} - // Tab label is raw lowercase 'activity' rendered as text - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); +const rows = (page) => page.locator('main [data-testid="activity-row"]'); - // Wait for at least one activity row to appear - await expect(page.locator('[data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); +test.describe('Activity changelog', () => { + test.describe.configure({ mode: 'serial' }); - // Expect deploy-related content - await expect(page.locator('main')).toContainText(/deploy|created|compose/i); + test.beforeAll(async () => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - test('per-app activity tab filter chips are present', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); - - // Filter chips for known categories should be visible - await expect(page.locator('[data-testid="activity-filter-deploy"]')).toBeVisible({ timeout: 5_000 }); - await expect(page.locator('[data-testid="activity-filter-compose"]')).toBeVisible(); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); }); - test('category filter narrows entries on app activity tab', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); - - // Wait for rows to load - await expect(page.locator('[data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); - const initialCount = await page.locator('[data-testid="activity-row"]').count(); - - // Toggle the 'compose' filter chip - await page.locator('[data-testid="activity-filter-compose"]').click(); - await page.waitForTimeout(600); - - // Page should still render without error - await expect(page.locator('main')).toBeVisible(); - - // Toggle it back off; count should return to at least initialCount - await page.locator('[data-testid="activity-filter-compose"]').click(); - await page.waitForTimeout(600); - - const finalCount = await page.locator('[data-testid="activity-row"]').count(); - expect(finalCount).toBeGreaterThanOrEqual(Math.max(1, initialCount - 1)); + test('per-app deploy entries name the deployed version', async ({ page }) => { + await openAppActivity(page, 'e2e-nginx'); + await page.locator('[data-testid="activity-filter-deploy"]').click(); + await expect(rows(page).filter({ hasText: 'deploy_succeeded' }).first()).toBeVisible(); + await expect(rows(page).filter({ hasText: /Deploy succeeded \(version [1-9]\d*\)/ }).first()).toBeVisible(); }); test('global audit (system page) shows entries from multiple apps', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/system`); - - // Click the "Audit Log" tab + await page.goto(`${getState().baseURL}/#/system`); await page.locator('button').filter({ hasText: 'Audit Log' }).click(); + await expect(rows(page).first()).toBeVisible(); - // Wait for at least one row - await expect(page.locator('[data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); - - // Filter to non-auth categories so app-scoped rows surface above the - // login noise on page one. + // Filter to app categories so app-scoped rows surface above the login noise. await page.locator('[data-testid="activity-filter-lifecycle"]').click(); await page.locator('[data-testid="activity-filter-compose"]').click(); - - // After filtering, at least one row should reference a deployed app slug. - const rows = page.locator('[data-testid="activity-row"]'); - await expect(rows.first()).toBeVisible({ timeout: 10_000 }); - await expect(rows.filter({ hasText: /e2e-nginx|e2e-multi|e2e-postgres/ }).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('global audit filter chips are present', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/system`); - - await page.locator('button').filter({ hasText: 'Audit Log' }).click(); - - await expect(page.locator('[data-testid="activity-filter-deploy"]')).toBeVisible({ timeout: 5_000 }); - await expect(page.locator('[data-testid="activity-filter-compose"]')).toBeVisible(); - }); - - test('dashboard recent activity card renders', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - - // Card heading is "Recent Activity" - await expect(page.locator('[data-testid="recent-activity-card"]')).toBeVisible({ timeout: 5_000 }); - await expect(page.getByRole('heading', { name: /Recent Activity/i })).toBeVisible(); - - // At least one activity row should appear since deploys have happened - await expect(page.locator('[data-testid="recent-activity-card"] [data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); + await expect.poll(async () => { + const slugs = await rows(page).locator('a[href^="#/apps/"]').allTextContents(); + return new Set(slugs.map((s) => s.trim()).filter((s) => /^e2e-(nginx|multi|postgres)$/.test(s))).size; + }).toBeGreaterThanOrEqual(2); }); test('failed deploy entry shows error inline', async ({ page }) => { - // Trigger requires API setup to inject a bad compose; covered by Go unit tests in deployer - test.skip(true, 'Failed-deploy trigger requires nontrivial API plumbing; covered by Go unit tests in deployer'); - }); - - test('expand chevron loads full entry detail', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); - - // Find the first row that has expandable detail (compose entries have After JSON). - const composeRow = page.locator('[data-testid="activity-row"]').filter({ hasText: /compose/i }).first(); - await expect(composeRow).toBeVisible({ timeout: 10_000 }); - await composeRow.locator('button[aria-label="Show details"]').click(); - - // After expand, an "After" label should appear within the activity panel. - await expect(page.locator('main').getByText(/^After$/i).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('sync status badge appears on sync-eligible entries', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); - - // At least one row exists - await expect(page.locator('[data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); - - // Compose/lifecycle entries are sync-eligible. Locate a row with a sync badge - // (text or aria-label containing "synced" or "pending"). - const rowsWithBadge = page.locator('[data-testid="activity-row"]').filter({ hasText: /pending|synced/i }); - await expect(rowsWithBadge.first()).toBeVisible({ timeout: 5_000 }); - }); - - test('env vars edit appears as env/changed row in per-app feed', async ({ page, request }) => { - const state = getState(); - const cookies = await page.context().cookies(); - const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; '); - - // Edit env vars via API to create an env/changed audit row - const r = await request.put(`${state.baseURL}/api/apps/e2e-nginx/env`, { - data: [{ key: 'AUDIT_TEST', value: 'hello' }], - headers: { Cookie: cookieHeader, 'Content-Type': 'application/json' }, - ignoreHTTPSErrors: true, - }); - expect(r.status()).toBeLessThan(300); - - // Navigate to activity tab and confirm the env entry surfaces - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.locator('button').filter({ hasText: /^activity$/ }).first().click(); - - const envRows = page.locator('[data-testid="activity-row"]').filter({ hasText: /env/i }); - await expect(envRows.first()).toBeVisible({ timeout: 10_000 }); + const slug = 'e2e-bad-image'; + // Unreachable local registry: fails fast, no network, not rewritten by the mirror. + const compose = 'services:\n web:\n image: 127.0.0.1:1/e2e/missing:nope\n'; + try { + const res = await apiRequest('POST', '/api/apps/deploy', { name: slug, compose: b64(compose) }); + expect(res.status).toBe(202); + await waitForAppStatus(slug, 'error', 90_000); + + let failed; + await expect.poll(async () => { + const act = await apiRequest('GET', `/api/apps/${slug}/activity`); + failed = act.data.entries.find((e) => e.action === 'deploy_failed'); + return failed?.error || ''; + }).not.toBe(''); + + await openAppActivity(page, slug); + const row = rows(page).filter({ hasText: 'deploy_failed' }); + await expect(row).toHaveCount(1); + await expect(row).toContainText(`Deploy failed: ${failed.error}`); + await expect(row.locator('.text-red-400, details summary').filter({ hasText: failed.error.slice(0, 100) })).toBeVisible(); + } finally { + expect((await apiRequest('DELETE', `/api/apps/${slug}`)).status).toBe(200); + } }); - test('audit retention config form is visible to super-admin', async ({ page }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/system`); - await page.locator('button').filter({ hasText: 'Audit Log' }).click(); - - // The retention input + save button should be present (e2eadmin is super-admin in setup) - const retentionInput = page.locator('input[type="number"]').first(); - await expect(retentionInput).toBeVisible({ timeout: 5_000 }); - await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible(); + test('env vars edit appears as an env row naming the variable', async ({ page }) => { + const original = await apiRequest('GET', '/api/apps/e2e-nginx/env'); + expect(original.status).toBe(200); + try { + const r = await apiRequest('PUT', '/api/apps/e2e-nginx/env', [...original.data, { key: 'AUDIT_TEST', value: 'hello' }]); + expect(r.status).toBe(200); + await openAppActivity(page, 'e2e-nginx'); + await expect(rows(page).filter({ hasText: /Environment variables: added AUDIT_TEST/ }).first()).toBeVisible(); + } finally { + expect((await apiRequest('PUT', '/api/apps/e2e-nginx/env', original.data)).status).toBe(200); + } }); - test('super-admin can purge all activity', async ({ page, request }) => { - const state = getState(); - await page.goto(`${state.baseURL}/#/system`); + test('super-admin can purge all activity', async ({ page }) => { + await page.goto(`${getState().baseURL}/#/system`); await page.locator('button').filter({ hasText: 'Audit Log' }).click(); - - // Activity should exist before purge - await expect(page.locator('[data-testid="activity-row"]').first()).toBeVisible({ timeout: 10_000 }); - - // Click "Purge all activity" then confirm - await page.getByRole('button', { name: /Purge all activity/i }).click(); - await page.getByRole('button', { name: /Yes, purge all/i }).click(); - - // After purge, the API directly should report empty (a fresh login event may be - // recorded by subsequent traffic, so don't rely on the UI showing zero rows). - const cookies = await page.context().cookies(); - const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; '); - // Wait briefly for the purge to settle. - await page.waitForTimeout(500); - const r = await request.get(`${state.baseURL}/api/activity?limit=200`, { - headers: { Cookie: cookieHeader }, - ignoreHTTPSErrors: true, - }); - expect(r.status()).toBe(200); - const body = await r.json(); - // After a successful purge there should be far fewer entries than the pre-purge state. - // A few new auth/system rows may have been recorded during the purge round-trip. - expect((body.entries || []).length).toBeLessThan(5); + await expect(rows(page).first()).toBeVisible(); + + await page.getByRole('button', { name: 'Purge all activity' }).click(); + const purgeP = page.waitForResponse((r) => r.request().method() === 'DELETE' && r.url().endsWith('/api/activity')); + await page.getByRole('button', { name: 'Yes, purge all' }).click(); + expect((await purgeP).status()).toBe(204); + + const after = await apiRequest('GET', '/api/activity?limit=200'); + expect(after.status).toBe(200); + const entries = after.data.entries; + expect(entries.filter((e) => ['deploy', 'compose', 'lifecycle', 'env'].includes(e.category))).toEqual([]); + // The purge leaves a sentinel row naming who did it. + expect(entries.find((e) => e.action === 'audit_purged')).toMatchObject({ actor_name: TEST_ADMIN.username }); }); }); diff --git a/e2e/tests/20-export-import.spec.js b/e2e/tests/20-export-import.spec.js index 036fdf83..5a432ec8 100644 --- a/e2e/tests/20-export-import.spec.js +++ b/e2e/tests/20-export-import.spec.js @@ -1,180 +1,98 @@ import { test, expect } from '@playwright/test'; -import { readFileSync, existsSync } from 'fs'; +import { readFileSync } from 'fs'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; +import { dockerCLI } from '../helpers/docker.js'; +import { latestDeployEventId, waitForDeployEvent } from '../helpers/users.js'; test.describe.configure({ mode: 'serial' }); +// e2e-postgres publishes no host port and claims no domain, so a clone of it +// can run next to the original. +const SOURCE = 'e2e-postgres'; +const CLONE = 'e2e-postgres-clone'; + +async function openImportModal(page, bundlePath) { + await page.goto(`${getState().baseURL}/#/`); + await page.getByRole('button', { name: 'Deploy App' }).first().click(); + await expect(page.getByRole('dialog').first()).toBeVisible(); + await page.getByTestId('wizard-import-btn').click(); + const importModal = page.locator('[role="dialog"][aria-modal="true"]').last(); + await page.getByTestId('import-file').setInputFiles(bundlePath); + return importModal; +} + test.describe('App Export/Import', () => { + let bundlePath; + + test.beforeAll(async () => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + }); + + test.afterAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + const res = await apiRequest('DELETE', `/api/apps/${CLONE}`); + expect([200, 404]).toContain(res.status); + dockerCLI(['volume', 'rm', '-f', `simpledeploy-${CLONE}_pgdata`]); + }); + test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); - test('export e2e-nginx config and re-import as a new app', async ({ page }) => { - const state = getState(); - - // 1) Navigate to the e2e-nginx app and open Settings tab. - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); + test('export an app and re-import it as a new running app', async ({ page }) => { + await page.goto(`${getState().baseURL}/#/apps/${SOURCE}`); await page.getByRole('button', { name: /settings/i }).click(); - - // 2) Trigger the Export button in the "Export config" section and capture the download. const exportSection = page .locator('main') .locator('div', { hasText: 'Export config' }) .filter({ has: page.getByRole('button', { name: /^export$/i }) }) .last(); - const exportBtn = exportSection.getByRole('button', { name: /^export$/i }); - await expect(exportBtn).toBeVisible({ timeout: 10_000 }); - const [download] = await Promise.all([ page.waitForEvent('download'), - exportBtn.click(), + exportSection.getByRole('button', { name: /^export$/i }).click(), ]); - - expect(download.suggestedFilename()).toBe('e2e-nginx.simpledeploy.zip'); - const downloadPath = await download.path(); - expect(downloadPath).toBeTruthy(); - expect(existsSync(downloadPath)).toBe(true); - const buf = readFileSync(downloadPath); - expect(buf.length).toBeGreaterThanOrEqual(4); - // ZIP magic bytes: 'PK\x03\x04'. - expect(buf[0]).toBe(0x50); // P - expect(buf[1]).toBe(0x4b); // K - - // 3) Open Deploy wizard and choose Import from file. - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - const wizard = page.getByRole('dialog').first(); - await expect(wizard).toBeVisible(); - await page.getByTestId('wizard-import-btn').click(); - - // The ImportAppModal opens (separate role=dialog). - const importModal = page.locator('[role="dialog"][aria-modal="true"]').last(); - await expect(importModal).toBeVisible(); - - // Upload the zip and fill new slug. - await page.getByTestId('import-file').setInputFiles(downloadPath); - await page.getByTestId('import-slug').fill('e2e-nginx-clone'); - - // Mode defaults to "new" - click Import. + expect(download.suggestedFilename()).toBe(`${SOURCE}.simpledeploy.zip`); + bundlePath = await download.path(); + const buf = readFileSync(bundlePath); + expect(buf.subarray(0, 4)).toEqual(Buffer.from([0x50, 0x4b, 0x03, 0x04])); + + const importModal = await openImportModal(page, bundlePath); + await page.getByTestId('import-slug').fill(CLONE); + // Mode defaults to "new". await importModal.getByRole('button', { name: /^import$/i }).click(); - - // 4) Wait for navigation to the new app page. - await page.waitForURL(/#\/apps\/e2e-nginx-clone/, { timeout: 60_000 }); - - // 5) Confirm the new app appears on the dashboard listing. - await page.goto(`${state.baseURL}/#/`); - await expect(page.locator('main').getByText('e2e-nginx-clone').first()).toBeVisible({ - timeout: 15_000, - }); + await page.waitForURL(new RegExp(`#/apps/${CLONE}`), { timeout: 60_000 }); + + // The clone runs the same compose as its source. + const ev = await waitForDeployEvent(CLONE, 0); + expect(ev.action).toBe('deploy'); + await waitForAppStatus(CLONE, 'running'); + const src = await apiRequest('GET', `/api/apps/${SOURCE}/compose`); + const clone = await apiRequest('GET', `/api/apps/${CLONE}/compose`); + expect(clone.status).toBe(200); + expect(clone.data).toBe(src.data); }); - test('overwrite-mode import shows preview and applies on confirm', async ({ page }) => { - const state = getState(); - - // 1) Export the existing e2e-nginx app to get a fresh bundle. - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /settings/i }).click(); - const exportSection = page - .locator('main') - .locator('div', { hasText: 'Export config' }) - .filter({ has: page.getByRole('button', { name: /^export$/i }) }) - .last(); - const exportBtn = exportSection.getByRole('button', { name: /^export$/i }); - await expect(exportBtn).toBeVisible({ timeout: 10_000 }); - const [download] = await Promise.all([ - page.waitForEvent('download'), - exportBtn.click(), - ]); - const downloadPath = await download.path(); - expect(downloadPath).toBeTruthy(); - - // 2) Open Deploy wizard, choose Import from file. - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - await expect(page.getByRole('dialog').first()).toBeVisible(); - await page.getByTestId('wizard-import-btn').click(); - - const importModal = page.locator('[role="dialog"][aria-modal="true"]').last(); - await expect(importModal).toBeVisible(); - - // 3) Upload, set overwrite mode, target slug = e2e-nginx. - await page.getByTestId('import-file').setInputFiles(downloadPath); + test('overwrite-mode import shows preview and redeploys on confirm', async ({ page }) => { + const importModal = await openImportModal(page, bundlePath); await importModal.getByLabel('Overwrite existing app').check(); - await page.getByTestId('import-slug').fill('e2e-nginx'); - - // 4) Click Import -> preview panel appears. + await page.getByTestId('import-slug').fill(SOURCE); await importModal.getByRole('button', { name: /^import$/i }).click(); - const preview = page.getByTestId('import-preview'); - await expect(preview).toBeVisible({ timeout: 15_000 }); - // Re-export of unchanged app -> Compose Unchanged, Sidecar Unchanged. + // Re-import of an unchanged export -> Compose Unchanged, Sidecar Unchanged. + await expect(page.getByTestId('import-preview')).toBeVisible(); await expect(page.getByTestId('import-preview-compose')).toContainText('Unchanged'); await expect(page.getByTestId('import-preview-sidecar')).toContainText('Unchanged'); await expect(page.getByTestId('import-preview-alerts')).toBeVisible(); await expect(page.getByTestId('import-preview-backups')).toBeVisible(); - // 5) Confirm overwrite. + const before = await latestDeployEventId(SOURCE); await importModal.getByRole('button', { name: /confirm overwrite/i }).click(); - - // Modal should close and we should land on the app page. - await page.waitForURL(/#\/apps\/e2e-nginx(\?|$|\/)/, { timeout: 30_000 }); + await page.waitForURL(new RegExp(`#/apps/${SOURCE}(\\?|$|/)`), { timeout: 30_000 }); await expect(importModal).toBeHidden(); - // 6) Dashboard still shows e2e-nginx. - await page.goto(`${state.baseURL}/#/`); - await expect(page.locator('main').getByText('e2e-nginx', { exact: true }).first()).toBeVisible({ - timeout: 15_000, - }); - }); - - test('overwrite-mode import against missing slug surfaces an error', async ({ page }) => { - const state = getState(); - - // Re-export e2e-nginx to get a bundle. - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: /settings/i }).click(); - const exportSection = page - .locator('main') - .locator('div', { hasText: 'Export config' }) - .filter({ has: page.getByRole('button', { name: /^export$/i }) }) - .last(); - const [download] = await Promise.all([ - page.waitForEvent('download'), - exportSection.getByRole('button', { name: /^export$/i }).click(), - ]); - const downloadPath = await download.path(); - - await page.goto(`${state.baseURL}/#/`); - await page.getByRole('button', { name: 'Deploy App' }).first().click(); - await expect(page.getByRole('dialog').first()).toBeVisible(); - await page.getByTestId('wizard-import-btn').click(); - - const importModal = page.locator('[role="dialog"][aria-modal="true"]').last(); - await expect(importModal).toBeVisible(); - - await page.getByTestId('import-file').setInputFiles(downloadPath); - await importModal.getByLabel('Overwrite existing app').check(); - await page.getByTestId('import-slug').fill('e2e-nginx-does-not-exist'); - await importModal.getByRole('button', { name: /^import$/i }).click(); - - // Either the preview returned an error (modal stays on form with error) - // or the preview opened with all-changed rows. Implementation returns 404, - // so the error is surfaced and the preview panel does NOT render. - const errorMsg = page.getByTestId('import-error'); - await expect(errorMsg).toBeVisible({ timeout: 15_000 }); - await expect(page.getByTestId('import-preview')).toBeHidden(); - - // Close modal cleanly. Cancel closes ImportAppModal; verify its file input is gone. - await importModal.getByRole('button', { name: /cancel/i }).click(); - await expect(page.getByTestId('import-file')).toBeHidden(); - }); - - test('cleanup imported clone app', async () => { - // Delete via API so 19/99-cleanup specs see a stable state. - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - const res = await apiRequest('DELETE', '/api/apps/e2e-nginx-clone'); - // Accept ok or 404 (in case earlier test failed before creating it). - expect(res.ok || res.status === 404).toBe(true); + // Later specs use e2e-postgres: wait for the redeploy to finish. + expect((await waitForDeployEvent(SOURCE, before)).action).toBe('deploy'); + await waitForAppStatus(SOURCE, 'running'); }); }); diff --git a/e2e/tests/20-security-validation.spec.js b/e2e/tests/20-security-validation.spec.js index 055af6fa..554f9e76 100644 --- a/e2e/tests/20-security-validation.spec.js +++ b/e2e/tests/20-security-validation.spec.js @@ -1,215 +1,90 @@ import { test, expect } from '@playwright/test'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { apiLogin, apiRequest } from '../helpers/api.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; import { TEST_ADMIN } from '../helpers/auth.js'; - -const SECURITY_FIXTURES = join(import.meta.dirname, '..', 'fixtures', 'security'); - -function readAndEncode(filename) { - const content = readFileSync(join(SECURITY_FIXTURES, filename), 'utf-8'); - return Buffer.from(content).toString('base64'); -} - -function encodeCompose(yaml) { - return Buffer.from(yaml).toString('base64'); +import { b64 } from '../helpers/users.js'; + +const VALID_COMPOSE = b64('services:\n web:\n image: nginx:alpine\n'); +// Each rule is unit-tested in internal/compose; this proves the real binary +// wires the checks into deploy and validate-compose. +const UNSAFE_COMPOSE = b64('services:\n web:\n image: nginx:alpine\n privileged: true\n network_mode: host\n'); + +// A rejected deploy may have started anyway (expected-fail runs): let it +// settle, then remove it so later specs see a clean app list. +async function removeIfStarted(res, name) { + if (res.status !== 202) return; + await waitForAppStatus(name, 'error', 90_000); + expect((await apiRequest('DELETE', `/api/apps/${encodeURIComponent(name)}`)).status).toBe(200); } -const VALID_COMPOSE = encodeCompose('services:\n web:\n image: nginx:alpine\n'); - test.describe('Security Validation', () => { test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - // Compose security tests - each fixture should be rejected test.describe('Compose Security', () => { - test('rejects privileged containers', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-priv', compose: readAndEncode('compose-privileged.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations).toBeDefined(); - expect(res.data.violations.some(v => v.includes('privileged'))).toBeTruthy(); - }); - - test('rejects host network mode', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-net', compose: readAndEncode('compose-host-network.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('network_mode'))).toBeTruthy(); - }); - - test('rejects host pid mode', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-pid', compose: readAndEncode('compose-host-pid.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('pid'))).toBeTruthy(); - }); - - test('rejects host ipc mode', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-ipc', compose: readAndEncode('compose-host-ipc.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('ipc'))).toBeTruthy(); - }); - - test('rejects dangerous capabilities', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-caps', compose: readAndEncode('compose-dangerous-caps.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('SYS_ADMIN'))).toBeTruthy(); - }); - - test('rejects cap_add ALL', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n cap_add:\n - ALL\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-capall', compose - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('ALL'))).toBeTruthy(); - }); - - test('rejects cap_add NET_ADMIN', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n cap_add:\n - NET_ADMIN\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-netadm', compose - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('NET_ADMIN'))).toBeTruthy(); - }); - - test('rejects dangerous volume mounts', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-vol', compose: readAndEncode('compose-dangerous-volumes.yml') - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('docker.sock'))).toBeTruthy(); - }); - - test('rejects bind mount of /etc', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n volumes:\n - /etc:/host-etc\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-etc', compose - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('/etc'))).toBeTruthy(); - }); - - test('rejects bind mount of /proc', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n volumes:\n - /proc:/host-proc\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-proc', compose - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('/proc'))).toBeTruthy(); - }); - - test('rejects bind mount of /root', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n volumes:\n - /root:/host-root\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-root', compose - }); - expect(res.status).toBe(400); - expect(res.data.violations.some(v => v.includes('/root'))).toBeTruthy(); - }); - - test('reports multiple violations together', async () => { - const compose = encodeCompose('services:\n web:\n image: nginx:alpine\n privileged: true\n network_mode: host\n'); - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-multi', compose - }); + test('deploy rejects unsafe compose with every violation and persists nothing', async () => { + const res = await apiRequest('POST', '/api/apps/deploy', { name: 'sec-test-multi', compose: UNSAFE_COMPOSE }); expect(res.status).toBe(400); - expect(res.data.violations.length).toBeGreaterThanOrEqual(2); + expect(res.data.violations.some((v) => v.includes('privileged'))).toBe(true); + expect(res.data.violations.some((v) => v.includes('network_mode'))).toBe(true); + expect((await apiRequest('GET', '/api/apps/sec-test-multi')).status).toBe(404); + expect((await apiRequest('GET', '/api/apps/sec-test-multi/compose')).status).toBe(404); }); - test('accepts valid compose without violations', async () => { - // Valid compose should not be rejected for security (may deploy or 500 without docker, but not 400) - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'sec-test-valid', compose: VALID_COMPOSE - }); - expect(res.status).not.toBe(400); + test('validate-compose reports the violations deploy would reject', async () => { + const res = await apiRequest('POST', '/api/apps/validate-compose', { compose: UNSAFE_COMPOSE }); + expect(res.status).toBe(200); + expect(res.data.valid).toBe(false); + expect(JSON.stringify(res.data.errors)).toContain('privileged'); }); }); - // App name validation tests test.describe('App Name Validation', () => { - test('rejects empty name', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: '', compose: VALID_COMPOSE - }); - expect(res.status).toBe(400); - }); - - test('rejects name starting with dash', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: '-invalid', compose: VALID_COMPOSE - }); - expect(res.status).toBe(400); - expect(res.data).toContain('invalid app name'); - }); - - test('rejects name starting with dot', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: '.invalid', compose: VALID_COMPOSE - }); - expect(res.status).toBe(400); - }); - - test('rejects name with spaces', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'my app', compose: VALID_COMPOSE - }); - expect(res.status).toBe(400); - }); - - test('rejects name exceeding 63 chars', async () => { - const name = 'a' + 'b'.repeat(63); // 64 chars - const res = await apiRequest('POST', '/api/apps/deploy', { - name, compose: VALID_COMPOSE - }); - expect(res.status).toBe(400); + test('rejects invalid app names with 400', async () => { + const cases = [ + ['', 'name and compose are required'], + ['-invalid', 'invalid app name'], + ['.invalid', 'invalid app name'], + ['..', 'invalid app name'], + ['../evil', 'invalid app name'], + ['my app', 'invalid app name'], + ['a' + 'b'.repeat(63), 'invalid app name'], + ]; + for (const [name, msg] of cases) { + const res = await apiRequest('POST', '/api/apps/deploy', { name, compose: VALID_COMPOSE }); + expect(res.status, `name ${JSON.stringify(name)}`).toBe(400); + expect(res.data, `name ${JSON.stringify(name)}`).toContain(msg); + } }); - test('accepts name at 63 char boundary', async () => { - const name = 'a' + 'b'.repeat(62); // 63 chars - const res = await apiRequest('POST', '/api/apps/deploy', { - name, compose: VALID_COMPOSE - }); - // Should not get 400 for name validation (may get other errors) - if (res.status === 400) { - expect(res.data).not.toContain('invalid app name'); + test('rejects names docker compose cannot use as a project name (dots, uppercase)', async () => { + for (const name of ['my-app_v1.0', 'MyApp']) { + const res = await apiRequest('POST', '/api/apps/deploy', { name, compose: VALID_COMPOSE }); + try { + expect(res.status, `name ${name}`).toBe(400); + expect(res.data, `name ${name}`).toContain('invalid app name'); + } finally { + await removeIfStarted(res, name); + } } }); - test('accepts name with dots dashes underscores', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'my-app_v1.0', compose: VALID_COMPOSE - }); - if (res.status === 400) { - expect(res.data).not.toContain('invalid app name'); + test('a 63-char name with dashes and underscores deploys and runs', async () => { + const name = 'e2e-long_name-'.padEnd(63, 'x'); + expect(name).toHaveLength(63); + const res = await apiRequest('POST', '/api/apps/deploy', { name, compose: VALID_COMPOSE }); + expect(res.status).toBe(202); + try { + await waitForAppStatus(name, 'running', 90_000); + } finally { + expect((await apiRequest('DELETE', `/api/apps/${name}`)).status).toBe(200); } }); test('rejects missing compose field', async () => { - const res = await apiRequest('POST', '/api/apps/deploy', { - name: 'test-app', compose: '' - }); + const res = await apiRequest('POST', '/api/apps/deploy', { name: 'test-app', compose: '' }); expect(res.status).toBe(400); + expect(res.data).toContain('name and compose are required'); }); }); - - // Cleanup any apps that may have been created during valid compose test - test('cleanup security test apps', async () => { - for (const name of ['sec-test-valid', 'my-app_v1.0']) { - await apiRequest('DELETE', `/api/apps/${name}`); - } - // Also clean up the 63-char app name - const name63 = 'a' + 'b'.repeat(62); - await apiRequest('DELETE', `/api/apps/${name63}`); - }); }); diff --git a/e2e/tests/21-user-validation.spec.js b/e2e/tests/21-user-validation.spec.js index 19df47fd..84eb8bad 100644 --- a/e2e/tests/21-user-validation.spec.js +++ b/e2e/tests/21-user-validation.spec.js @@ -1,102 +1,79 @@ import { test, expect } from '@playwright/test'; import { apiLogin, apiRequest } from '../helpers/api.js'; import { TEST_ADMIN } from '../helpers/auth.js'; +import { deleteUserByName, findUser } from '../helpers/users.js'; +// Handler rules with no Go coverage in internal/api/users_test.go yet. test.describe('User Validation', () => { test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); test.describe('User Creation Validation', () => { test('rejects duplicate username', async () => { - // First create a user - const res1 = await apiRequest('POST', '/api/users', { - username: 'duptest', password: 'TestPass123!', role: 'viewer', - }); - expect(res1.status).toBe(201); - - // Try creating with same username - const res2 = await apiRequest('POST', '/api/users', { - username: 'duptest', password: 'TestPass123!', role: 'viewer', - }); - expect(res2.status).toBe(409); - - // Cleanup - if (res1.ok && res1.data?.id) { - await apiRequest('DELETE', `/api/users/${res1.data.id}`); + try { + const res1 = await apiRequest('POST', '/api/users', { username: 'duptest', password: 'TestPass123!', role: 'viewer' }); + expect(res1.status).toBe(201); + const res2 = await apiRequest('POST', '/api/users', { username: 'duptest', password: 'TestPass123!', role: 'viewer' }); + expect(res2.status).toBe(409); + } finally { + await deleteUserByName('duptest'); } }); test('rejects duplicate email', async () => { - const res1 = await apiRequest('POST', '/api/users', { - username: 'emailtest1', password: 'TestPass123!', role: 'viewer', - email: 'dup@test.local', - }); - expect(res1.status).toBe(201); - - const res2 = await apiRequest('POST', '/api/users', { - username: 'emailtest2', password: 'TestPass123!', role: 'viewer', - email: 'dup@test.local', - }); - expect(res2.status).toBe(409); - - // Cleanup - if (res1.ok && res1.data?.id) { - await apiRequest('DELETE', `/api/users/${res1.data.id}`); + try { + const res1 = await apiRequest('POST', '/api/users', { + username: 'emailtest1', password: 'TestPass123!', role: 'viewer', email: 'dup@test.local', + }); + expect(res1.status).toBe(201); + const res2 = await apiRequest('POST', '/api/users', { + username: 'emailtest2', password: 'TestPass123!', role: 'viewer', email: 'dup@test.local', + }); + expect(res2.status).toBe(409); + expect(await findUser('emailtest2')).toBeUndefined(); + } finally { + await deleteUserByName('emailtest1'); + await deleteUserByName('emailtest2'); } }); test('rejects short password', async () => { - const res = await apiRequest('POST', '/api/users', { - username: 'shortpw', password: 'short', role: 'viewer', - }); + const res = await apiRequest('POST', '/api/users', { username: 'shortpw', password: 'short', role: 'viewer' }); expect(res.status).toBe(400); - }); - - test('creates valid user successfully', async () => { - const res = await apiRequest('POST', '/api/users', { - username: 'validuser', password: 'ValidPass123!', role: 'viewer', - display_name: 'Valid User', email: 'valid@test.local', - }); - expect(res.status).toBe(201); - expect(res.data.username).toBe('validuser'); - - // Cleanup - if (res.ok && res.data?.id) { - await apiRequest('DELETE', `/api/users/${res.data.id}`); - } + expect(await findUser('shortpw')).toBeUndefined(); }); }); test.describe('User Modification Guards', () => { - test('admin cannot change own role', async () => { - // Get current user ID + test('admin cannot change own role, and the rejected request changes nothing', async () => { const me = await apiRequest('GET', '/api/me'); - expect(me.ok).toBeTruthy(); + expect(me.status).toBe(200); + expect(typeof me.data.id).toBe('number'); + try { + const res = await apiRequest('PUT', `/api/users/${me.data.id}`, { role: 'viewer' }); + expect(res.status).toBe(400); + expect(res.data).toContain('cannot change your own role'); - const res = await apiRequest('PUT', `/api/users/${me.data.id}`, { - role: 'viewer', - }); - expect(res.status).toBe(400); + const after = await apiRequest('GET', '/api/me'); + expect(after.data).toMatchObject({ + role: 'super_admin', + display_name: TEST_ADMIN.displayName, + email: TEST_ADMIN.email, + }); + } finally { + const restore = await apiRequest('PUT', '/api/me', { display_name: TEST_ADMIN.displayName, email: TEST_ADMIN.email }); + expect(restore.status).toBe(200); + } }); test('admin cannot delete self', async () => { const me = await apiRequest('GET', '/api/me'); - expect(me.ok).toBeTruthy(); - + expect(typeof me.data.id).toBe('number'); const res = await apiRequest('DELETE', `/api/users/${me.data.id}`); expect(res.status).toBe(400); - }); - }); - - test.describe('Login Lockout', () => { - test('wrong password returns 401', async () => { - // Verify failed login returns 401 (not testing full lockout to avoid - // locking the IP and breaking subsequent tests) - const res = await apiRequest('POST', '/api/auth/login', { - username: 'nonexistent', password: 'wrong-password', - }); - expect(res.status).toBe(401); + expect(res.data).toContain('cannot delete yourself'); + expect((await apiRequest('GET', '/api/me')).status).toBe(200); }); }); }); diff --git a/e2e/tests/22-env-and-endpoints.spec.js b/e2e/tests/22-env-and-endpoints.spec.js index 8e5e33d1..ea863768 100644 --- a/e2e/tests/22-env-and-endpoints.spec.js +++ b/e2e/tests/22-env-and-endpoints.spec.js @@ -1,142 +1,55 @@ import { test, expect } from '@playwright/test'; import { apiRequest, apiLogin } from '../helpers/api.js'; -import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { TEST_ADMIN } from '../helpers/auth.js'; +import { waitForProxyStatus } from '../helpers/proxy.js'; +import { waitForAppSettled } from '../helpers/users.js'; -test.describe('Env Vars, Endpoints, and IP Access', () => { - test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - }); - - test.describe('Env Variables (API)', () => { - test('GET env returns array', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/env'); - expect(res.status).toBe(200); - expect(Array.isArray(res.data)).toBe(true); - }); +const HOST = 'nginx-test.local'; - test('PUT env with vars succeeds', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/env', [ - { key: 'E2E_TEST', value: 'hello' }, - ]); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); - - test('GET env confirms persistence', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/env'); - expect(res.status).toBe(200); - expect(Array.isArray(res.data)).toBe(true); - const found = res.data.find((v) => v.key === 'E2E_TEST'); - expect(found).toBeDefined(); - expect(found.value).toBe('hello'); - }); - - test('PUT env with empty array clears vars', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/env', []); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); - - test('GET env confirms cleared', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/env'); - expect(res.status).toBe(200); - expect(Array.isArray(res.data)).toBe(true); - expect(res.data.length).toBe(0); - }); +test.describe('Env Vars and IP Access', () => { + test.beforeAll(async () => { + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - test.describe('Env Variables (UI)', () => { - test('settings page has Environment Variables heading', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: 'Settings' }).click(); - await expect( - page.getByText('Environment Variables', { exact: false }).first() - ).toBeVisible({ timeout: 10_000 }); - }); - - test('settings page has Add Variable button', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-nginx`); - await page.getByRole('button', { name: 'Settings' }).click(); - await expect( - page.getByRole('button', { name: /Add Variable/i }).first() - ).toBeVisible({ timeout: 10_000 }); - }); + test('env vars round-trip through the app .env and clear', async () => { + const put = await apiRequest('PUT', '/api/apps/e2e-nginx/env', [{ key: 'E2E_TEST', value: 'hello' }]); + expect(put.status).toBe(200); + expect(put.data.status).toBe('ok'); + const got = await apiRequest('GET', '/api/apps/e2e-nginx/env'); + expect(got.status).toBe(200); + expect(got.data).toEqual([expect.objectContaining({ key: 'E2E_TEST', value: 'hello' })]); + + const clear = await apiRequest('PUT', '/api/apps/e2e-nginx/env', []); + expect(clear.status).toBe(200); + const cleared = await apiRequest('GET', '/api/apps/e2e-nginx/env'); + expect(cleared.data).toEqual([]); }); - test.describe('Endpoints (API)', () => { - let originalEndpoints; - - test.beforeAll(async () => { - // Capture current app endpoints to restore later - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - if (res.ok && res.data?.endpoints) { - originalEndpoints = res.data.endpoints; - } else { - originalEndpoints = [{ domain: 'nginx-test.local', port: '80', tls: 'off', service: 'nginx' }]; - } - }); - - test('PUT endpoints with new endpoint succeeds', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', [ - { domain: 'nginx-e2e-updated.local', port: '80', tls: 'off', service: 'nginx' }, - ]); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - expect(Array.isArray(res.data.endpoints)).toBe(true); - }); - - test('PUT endpoints restores original', async () => { - const endpoints = originalEndpoints.length > 0 - ? originalEndpoints - : [{ domain: 'nginx-test.local', port: '80', tls: 'off', service: 'nginx' }]; - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/endpoints', endpoints); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); + test('invalid CIDR in the IP allowlist is rejected', async () => { + const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { allow: '10.0.0.0/33' }); + expect(res.status).toBe(400); }); - test.describe('IP Access (API)', () => { - test('PUT access with single IP returns 200', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { - allow: '192.168.1.1', - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); - - test('PUT access with CIDR returns 200', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { - allow: '10.0.0.0/8', - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); - - test('PUT access with comma-separated IPs and CIDR returns 200', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { - allow: '192.168.1.1, 10.0.0.0/8', - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); - - test('PUT access with invalid CIDR returns 400', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { - allow: '10.0.0.0/33', - }); - expect(res.status).toBe(400); - }); - - test('PUT access with empty string clears and returns 200', async () => { - const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { - allow: '', - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - }); + test('IP allowlist is enforced by the proxy: deny, allow, then cleared', async () => { + // Each change rewrites the compose label; the watcher then redeploys the + // app, so wait for that before the next change. + const setAllow = async (allow) => { + const res = await apiRequest('PUT', '/api/apps/e2e-nginx/access', { allow }); + expect(res.status).toBe(200); + await waitForAppSettled('e2e-nginx'); + }; + try { + // Requests arrive from 127.0.0.1, which this list does not cover: 404. + await setAllow('192.168.1.1, 10.0.0.0/8'); + await waitForProxyStatus(HOST, 404); + await setAllow('127.0.0.0/8'); + await waitForProxyStatus(HOST, 200, { bodyContains: 'Welcome to nginx' }); + } finally { + // Later specs need nginx-test.local reachable without an allowlist. + await setAllow(''); + const compose = await apiRequest('GET', '/api/apps/e2e-nginx/compose'); + expect(compose.data).not.toMatch(/simpledeploy\.access\.allow:\s*["']?[0-9]/); + await waitForProxyStatus(HOST, 200, { bodyContains: 'Welcome to nginx' }); + } }); }); diff --git a/e2e/tests/23-rollback-and-restore.spec.js b/e2e/tests/23-rollback-and-restore.spec.js index 4811bb90..7629e646 100644 --- a/e2e/tests/23-rollback-and-restore.spec.js +++ b/e2e/tests/23-rollback-and-restore.spec.js @@ -1,161 +1,46 @@ import { test, expect } from '@playwright/test'; -import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; import { TEST_ADMIN } from '../helpers/auth.js'; import { findServiceContainer, containerImage } from '../helpers/docker.js'; -import { fetchViaProxy } from '../helpers/proxy.js'; +import { waitForProxyStatus } from '../helpers/proxy.js'; +import { waitForAppSettled } from '../helpers/users.js'; -test.describe('Rollback and Restore', () => { +// Restore from backup is covered with data checks by 12-backups. +test.describe('Rollback', () => { test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - test.describe('Deploy Versions', () => { - test('GET versions returns array with at least 1 entry', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(res.ok).toBeTruthy(); - expect(Array.isArray(res.data)).toBe(true); - expect(res.data.length).toBeGreaterThanOrEqual(1); - }); - - test('each version entry has id and created_at fields', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(res.ok).toBeTruthy(); - expect(res.data.length).toBeGreaterThanOrEqual(1); - for (const v of res.data) { - expect(typeof v.id).toBe('number'); - expect(v.created_at).toBeTruthy(); - } - }); - }); - - test.describe('Rollback', () => { - test('rollback to oldest version succeeds', async () => { - const versionsRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(versionsRes.ok).toBeTruthy(); - expect(versionsRes.data.length).toBeGreaterThanOrEqual(1); - - // versions are newest-first; oldest is last - const oldest = versionsRes.data[versionsRes.data.length - 1]; - expect(typeof oldest.id).toBe('number'); - - const rollbackRes = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { - version_id: oldest.id, - }); - expect(rollbackRes.ok).toBeTruthy(); - - // Wait briefly for rollback to start, then verify app exists - await new Promise(r => setTimeout(r, 5_000)); - const appRes = await apiRequest('GET', '/api/apps/e2e-nginx'); - expect(appRes.ok).toBeTruthy(); - }); - - test('GET events shows at least one entry after rollback', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx/events'); - expect(res.ok).toBeTruthy(); - expect(Array.isArray(res.data)).toBe(true); - expect(res.data.length).toBeGreaterThanOrEqual(1); - }); - }); - - test.describe('Rollback Validation', () => { - test('rollback with version_id 0 returns 400', async () => { - const res = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { - version_id: 0, - }); - expect(res.status).toBe(400); - }); - - test('rollback with nonexistent version_id returns error', async () => { - const res = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { - version_id: 999999, - }); - expect(res.ok).toBe(false); - }); - }); - - test.describe('Backup Restore (conditional)', () => { - test('restore succeeds if a successful backup run exists, otherwise skips', async () => { - const runsRes = await apiRequest('GET', '/api/apps/e2e-postgres/backups/runs'); - expect(runsRes.ok).toBeTruthy(); - expect(Array.isArray(runsRes.data)).toBe(true); - - const successRun = runsRes.data.find((r) => r.status === 'success'); - if (!successRun) { - // No successful backup run available - skip gracefully - return; - } - - const restoreRes = await apiRequest('POST', `/api/backups/restore/${successRun.id}`); - expect(restoreRes.status).toBe(202); - }); + test('rollback with version_id 0 returns 400', async () => { + const res = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { version_id: 0 }); + expect(res.status).toBe(400); }); - test.describe('Rollback - Functional', () => { - test.describe.configure({ mode: 'serial' }); - - test('rollback to oldest version restores original image', async () => { - // Ensure fresh session (prior tests may have muted cookies) - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - - const versionsRes = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(versionsRes.ok).toBeTruthy(); - expect(versionsRes.data.length).toBeGreaterThanOrEqual(2); - // newest-first; oldest is last - const oldest = versionsRes.data[versionsRes.data.length - 1]; - const latest = versionsRes.data[0]; - expect(oldest.id).not.toBe(latest.id); - - // Oldest version should be the original nginx:alpine (no 1.27) - expect(oldest.content).toContain('nginx:alpine'); - expect(oldest.content).not.toContain('nginx:1.27-alpine'); - - const rbRes = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { - version_id: oldest.id, - }); - expect(rbRes.ok).toBeTruthy(); - - // Poll container image for up to 60s for the old tag to return - let image = ''; - let container = null; - const deadline = Date.now() + 60_000; - while (Date.now() < deadline) { - container = findServiceContainer('e2e-nginx', 'web'); - if (container) { - try { - image = containerImage(container); - if (image === 'nginx:alpine' || (image.includes('nginx:alpine') && !image.includes('1.27'))) break; - } catch {} - } - await new Promise((r) => setTimeout(r, 2_000)); - } - expect(image).toContain('nginx:alpine'); - expect(image).not.toContain('1.27'); - - // Extra settle for proxy routes to refresh - await new Promise((r) => setTimeout(r, 3_000)); - - // App still serves via proxy - try { - const proxied = await fetchViaProxy('nginx-test.local', '/'); - expect([200, 301, 302, 308].includes(proxied.status)).toBeTruthy(); - } catch { - // Proxy reachability can flake immediately after rollback; soft-skip - } - - // Roll forward to most recent (pre-rollback latest) so downstream tests - // still see the v1.27 image where they expect it. - const afterRollback = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); - expect(afterRollback.ok).toBeTruthy(); - // the previously-latest version should still be present; find it by id - const target = afterRollback.data.find((v) => v.id === latest.id) || afterRollback.data[0]; - if (target && target.id !== oldest.id) { - const forwardRes = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { - version_id: target.id, - }); - expect(forwardRes.ok).toBeTruthy(); - // Let redeploy settle - await new Promise((r) => setTimeout(r, 5_000)); - } - }); + test('rollback swaps the running image and roll-forward restores it', async () => { + // A rollback while a deploy is in flight is skipped, so start settled. + await waitForAppSettled('e2e-nginx'); + const versions = await apiRequest('GET', '/api/apps/e2e-nginx/versions'); + expect(versions.status).toBe(200); + // Newest first; 11-app-versions deployed nginx:1.27-alpine over nginx:alpine. + const latest = versions.data[0]; + expect(latest.content).toContain('nginx:1.27-alpine'); + const older = versions.data.find((v) => v.content.includes('nginx:alpine')); + expect(older, 'an nginx:alpine version older than the 1.27 deploy').toBeTruthy(); + + const rollbackTo = async (version, imageTail) => { + const res = await apiRequest('POST', '/api/apps/e2e-nginx/rollback', { version_id: version.id }); + expect(res.status).toBe(200); + // RollbackOne redeploys before responding. + expect(containerImage(findServiceContainer('e2e-nginx', 'web'))).toMatch(new RegExp(`(^|/)${imageTail}$`)); + const events = await apiRequest('GET', '/api/apps/e2e-nginx/events'); + expect(events.data[0].action).toBe('rollback'); + expect(events.data[0].detail).toContain(`rollback to version ${version.version}`); + await waitForProxyStatus('nginx-test.local', 200, { bodyContains: 'Welcome to nginx' }); + }; + + await rollbackTo(older, 'nginx:alpine'); + // Later specs expect the 1.27 image back. + await rollbackTo(latest, 'nginx:1.27-alpine'); + await waitForAppSettled('e2e-nginx'); }); }); diff --git a/e2e/tests/24-multi-user-isolation.spec.js b/e2e/tests/24-multi-user-isolation.spec.js index 27a55eb2..a4e155ca 100644 --- a/e2e/tests/24-multi-user-isolation.spec.js +++ b/e2e/tests/24-multi-user-isolation.spec.js @@ -1,114 +1,49 @@ import { test, expect } from '@playwright/test'; -import { apiLogin, apiRequest, apiRequestWithKey } from '../helpers/api.js'; +import { apiLogin, apiRequest } from '../helpers/api.js'; import { TEST_ADMIN } from '../helpers/auth.js'; +import { deleteUserByName } from '../helpers/users.js'; +// Viewer read isolation, API keys and super_admin guards live in 14-users. +// This spec checks the viewer role through a cookie session: it can never +// mutate, not even an app it was granted. test.describe('Multi-User Isolation', () => { - let viewerUserId = null; + const VIEWER = 'e2e-viewer'; test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - - // Create viewer user + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + await deleteUserByName(VIEWER); const createRes = await apiRequest('POST', '/api/users', { - username: 'e2e-viewer', + username: VIEWER, password: 'ViewerPass123!', role: 'viewer', display_name: 'E2E Viewer', email: 'e2eviewer@test.local', }); expect(createRes.status).toBe(201); - viewerUserId = createRes.data.id; - - // Grant access to e2e-nginx only - const accessRes = await apiRequest('POST', `/api/users/${viewerUserId}/access`, { - app_slug: 'e2e-nginx', - }); - expect(accessRes.ok).toBeTruthy(); + const grant = await apiRequest('POST', `/api/users/${createRes.data.id}/access`, { app_slug: 'e2e-nginx' }); + expect(grant.status).toBe(200); }); test.afterAll(async () => { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - - if (viewerUserId != null) { - await apiRequest('DELETE', `/api/users/${viewerUserId}/access/e2e-nginx`); - await apiRequest('DELETE', `/api/users/${viewerUserId}`); - } - }); - - test.describe('App Isolation (as viewer)', () => { - test.beforeAll(async () => { - await apiLogin('e2e-viewer', 'ViewerPass123!'); - }); - - test('GET /api/apps/e2e-nginx returns 200 (granted)', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-nginx'); - expect(res.status).toBe(200); - }); - - test('GET /api/apps/e2e-multi returns 404 (not granted)', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-multi'); - expect(res.status).toBe(404); - }); - - test('GET /api/apps/e2e-postgres returns 404 (not granted)', async () => { - const res = await apiRequest('GET', '/api/apps/e2e-postgres'); - expect(res.status).toBe(404); - }); - }); - - test.describe('Role Restrictions (as viewer)', () => { - test.beforeAll(async () => { - await apiLogin('e2e-viewer', 'ViewerPass123!'); - }); - - test('POST /api/users returns 403 (admin-only)', async () => { - const res = await apiRequest('POST', '/api/users', { - username: 'should-fail', - password: 'ShouldFail123!', - role: 'viewer', - }); - expect(res.status).toBe(403); - }); - - test('DELETE /api/apps/e2e-multi returns 403 (viewer cannot mutate)', async () => { - // Viewers are blocked by mutatingAppMiddleware before app access is - // checked, so they always see 403 (not 404) on mutating routes. - const res = await apiRequest('DELETE', '/api/apps/e2e-multi'); - expect(res.status).toBe(403); - }); + await deleteUserByName(VIEWER); }); - test.describe('API Key Auth', () => { - let keyId = null; - let plainTextKey = null; - - test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - }); - - test('create API key', async () => { - const res = await apiRequest('POST', '/api/apikeys', { name: 'e2e-key-test' }); - expect(res.ok).toBeTruthy(); - keyId = res.data.id; - plainTextKey = res.data.key; - expect(plainTextKey).toBeTruthy(); - }); - - test('API key can list apps', async () => { - expect(plainTextKey).toBeTruthy(); - const res = await apiRequestWithKey('GET', '/api/apps', null, plainTextKey); - expect(res.status).toBe(200); - expect(Array.isArray(res.data)).toBe(true); - }); - - test('revoked key returns 401', async () => { - expect(keyId).toBeTruthy(); - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - const revokeRes = await apiRequest('DELETE', `/api/apikeys/${keyId}`); - expect(revokeRes.ok).toBeTruthy(); - - const res = await apiRequestWithKey('GET', '/api/apps', null, plainTextKey); - expect(res.status).toBe(401); - }); + test('viewer session gets 403 on mutating routes, granted app or not', async () => { + const envBefore = (await apiRequest('GET', '/api/apps/e2e-nginx/env')).data; + expect((await apiLogin(VIEWER, 'ViewerPass123!')).status).toBe(200); + expect((await apiRequest('GET', '/api/apps/e2e-nginx')).status).toBe(200); + + // Role checks run before the access check, so an ungranted app answers + // 403, not 404. + expect((await apiRequest('DELETE', '/api/apps/e2e-multi')).status).toBe(403); + expect((await apiRequest('POST', '/api/apps/e2e-nginx/restart')).status).toBe(403); + expect((await apiRequest('PUT', '/api/apps/e2e-nginx/env', [{ key: 'X', value: '1' }])).status).toBe(403); + expect((await apiRequest('PUT', '/api/apps/e2e-nginx/access', { allow: '10.0.0.1' })).status).toBe(403); + + // Nothing changed. + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); + expect((await apiRequest('GET', '/api/apps/e2e-multi')).status).toBe(200); + expect((await apiRequest('GET', '/api/apps/e2e-nginx/env')).data).toEqual(envBefore); }); }); diff --git a/e2e/tests/25-edge-cases.spec.js b/e2e/tests/25-edge-cases.spec.js index 18fdffdb..79bf7098 100644 --- a/e2e/tests/25-edge-cases.spec.js +++ b/e2e/tests/25-edge-cases.spec.js @@ -1,190 +1,36 @@ import { test, expect } from '@playwright/test'; import { apiLogin, apiRequest } from '../helpers/api.js'; -import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { TEST_ADMIN } from '../helpers/auth.js'; +import { getAppId, sqliteExec, sqliteQuery } from '../helpers/db.js'; test.describe('Edge Cases', () => { test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); - test.describe('Audit Log', () => { - test('GET /api/activity returns entries array', async () => { - const res = await apiRequest('GET', '/api/activity'); - expect(res.status).toBe(200); - expect(Array.isArray(res.data?.entries)).toBe(true); - }); - - test('activity log has at least one login event', async () => { - const res = await apiRequest('GET', '/api/activity'); - expect(res.status).toBe(200); - const loginEvents = res.data.entries.filter((e) => e.action === 'login_succeeded'); - expect(loginEvents.length).toBeGreaterThan(0); - }); - - test('activity log events have required fields', async () => { - const res = await apiRequest('GET', '/api/activity'); - expect(res.status).toBe(200); - expect(res.data.entries.length).toBeGreaterThan(0); - const event = res.data.entries[0]; - expect(event).toHaveProperty('created_at'); - expect(event).toHaveProperty('action'); - expect(event).toHaveProperty('actor_name'); - }); - }); - - test.describe('Alert Rule CRUD', () => { - let webhookId = null; - let ruleId = null; - - test('create webhook for alert rule tests', async () => { - const res = await apiRequest('POST', '/api/webhooks', { - name: 'e2e-edge-webhook', - type: 'custom', - url: 'https://example.com/webhook', - }); - expect(res.status).toBe(201); - expect(res.data.id).toBeTruthy(); - webhookId = res.data.id; - }); - - test('create alert rule', async () => { - expect(webhookId).toBeTruthy(); - const res = await apiRequest('POST', '/api/alerts/rules', { - metric: 'cpu_pct', - operator: '>', - threshold: 80, - duration_sec: 60, - webhook_id: webhookId, - enabled: true, - }); - expect(res.status).toBe(201); - expect(res.data.id).toBeTruthy(); - expect(res.data.threshold).toBe(80); - ruleId = res.data.id; - }); - - test('update alert rule threshold', async () => { - expect(ruleId).toBeTruthy(); - const res = await apiRequest('PUT', `/api/alerts/rules/${ruleId}`, { - metric: 'cpu_pct', - operator: '>', - threshold: 90, - duration_sec: 60, - webhook_id: webhookId, - enabled: true, - }); - expect(res.status).toBe(200); - expect(res.data.threshold).toBe(90); - }); - - test('GET /api/alerts/rules reflects updated threshold', async () => { - expect(ruleId).toBeTruthy(); - const res = await apiRequest('GET', '/api/alerts/rules'); - expect(res.status).toBe(200); - const rule = res.data.find((r) => r.id === ruleId); - expect(rule).toBeTruthy(); - expect(rule.threshold).toBe(90); - }); - - test('delete alert rule', async () => { - expect(ruleId).toBeTruthy(); - const res = await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - ruleId = null; - }); - - test('delete webhook', async () => { - expect(webhookId).toBeTruthy(); - const res = await apiRequest('DELETE', `/api/webhooks/${webhookId}`); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - webhookId = null; - }); + test('a real login is recorded in the activity log', async () => { + const res = await apiRequest('GET', '/api/activity'); + expect(res.status).toBe(200); + const login = res.data.entries.find((e) => e.action === 'login_succeeded'); + expect(login).toMatchObject({ category: 'auth', actor_name: TEST_ADMIN.username }); }); - test.describe('Webhook CRUD', () => { - let webhookId = null; - - test('create webhook', async () => { - const res = await apiRequest('POST', '/api/webhooks', { - name: 'e2e-webhook-edit', - type: 'custom', - url: 'https://example.com/original', - }); - expect(res.status).toBe(201); - webhookId = res.data.id; - }); - - test('update webhook URL', async () => { - expect(webhookId).toBeTruthy(); - const res = await apiRequest('PUT', `/api/webhooks/${webhookId}`, { - name: 'e2e-webhook-edit', - type: 'custom', - url: 'https://example.com/updated', - }); - expect(res.status).toBe(200); - expect(res.data.url).toBe('https://example.com/updated'); - }); - - test('GET /api/webhooks reflects updated URL', async () => { - expect(webhookId).toBeTruthy(); - const res = await apiRequest('GET', '/api/webhooks'); - expect(res.status).toBe(200); - const wh = res.data.find((w) => w.id === webhookId); - expect(wh).toBeTruthy(); - expect(wh.url).toBe('https://example.com/updated'); - }); - - test('delete webhook', async () => { - expect(webhookId).toBeTruthy(); - const res = await apiRequest('DELETE', `/api/webhooks/${webhookId}`); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - webhookId = null; - }); - }); - - test.describe('Dashboard Filtering (UI)', () => { - test('dashboard has status filter, sort, and search controls', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/`); - await expect(page.getByText('Applications')).toBeVisible({ timeout: 10_000 }); - - // Status filter dropdown (All/Running/Stopped) - const statusFilter = page.locator('select').filter({ hasText: /all|running|stopped/i }).first(); - await expect(statusFilter).toBeVisible(); - - // Sort dropdown - const sortDropdown = page.locator('select').nth(1); - await expect(sortDropdown).toBeVisible(); - - // Search input - const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], input[placeholder*="filter" i]').first(); - await expect(searchInput).toBeVisible(); - }); - }); - - test.describe('System Prune', () => { - test('POST /api/system/prune/metrics returns 200', async () => { - const res = await apiRequest('POST', '/api/system/prune/metrics', { - tier: 'raw', - days: 1, - }); - expect(res.status).toBe(200); - expect(res.data).toHaveProperty('deleted'); - expect(res.data).toHaveProperty('message'); - }); - - test('POST /api/system/prune/request-stats returns 200', async () => { - const res = await apiRequest('POST', '/api/system/prune/request-stats', { - tier: 'raw', - days: 1, - }); - expect(res.status).toBe(200); - expect(res.data).toHaveProperty('deleted'); - expect(res.data).toHaveProperty('message'); - }); + test('POST /api/system/prune/metrics deletes rows older than the cutoff only', async () => { + // 1d tier: its 400d retention keeps the background rollup away from the seed. + const appId = getAppId('e2e-nginx'); + const now = Math.floor(Date.now() / 1000); + const oldTs = now - 3 * 86400; + const newTs = now - 60; + sqliteExec(`INSERT INTO metrics (app_id, container_id, ts, tier, cpu_pct, mem_bytes, mem_limit) VALUES + (${appId}, 'e2e-prune', ${oldTs}, '1d', 1, 1, 4), (${appId}, 'e2e-prune', ${newTs}, '1d', 1, 1, 4);`); + try { + const res = await apiRequest('POST', '/api/system/prune/metrics', { tier: '1d', days: 1 }); + expect(res.status).toBe(200); + expect(res.data.deleted).toBeGreaterThanOrEqual(1); + const left = sqliteQuery(`SELECT ts FROM metrics WHERE container_id = 'e2e-prune' ORDER BY ts;`); + expect(left.map((r) => r.ts)).toEqual([newTs]); + } finally { + sqliteExec(`DELETE FROM metrics WHERE container_id = 'e2e-prune';`); + } }); }); From 5c28b989db50822a0dccd0ed21f44fa9b04e8b5e Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:22:04 -0700 Subject: [PATCH 09/15] test(e2e): keep export bundle across tests, unblock activity tests, require deploy error reason --- e2e/tests/20-activity.spec.js | 7 +++++-- e2e/tests/20-export-import.spec.js | 11 ++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/tests/20-activity.spec.js b/e2e/tests/20-activity.spec.js index 5afe9089..56050f0d 100644 --- a/e2e/tests/20-activity.spec.js +++ b/e2e/tests/20-activity.spec.js @@ -10,9 +10,9 @@ async function openAppActivity(page, slug) { const rows = (page) => page.locator('main [data-testid="activity-row"]'); +// Not serial: the version check is expected to fail until the deploy audit +// carries the version, and must not skip the rest. Purge stays last. test.describe('Activity changelog', () => { - test.describe.configure({ mode: 'serial' }); - test.beforeAll(async () => { expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); }); @@ -63,6 +63,9 @@ test.describe('Activity changelog', () => { await expect(row).toHaveCount(1); await expect(row).toContainText(`Deploy failed: ${failed.error}`); await expect(row.locator('.text-red-400, details summary').filter({ hasText: failed.error.slice(0, 100) })).toBeVisible(); + // The inline error is how users learn why a deploy failed, so it must + // carry docker's reason, not only "compose up: exit status 1". + expect(failed.error).toContain('127.0.0.1:1/e2e/missing'); } finally { expect((await apiRequest('DELETE', `/api/apps/${slug}`)).status).toBe(200); } diff --git a/e2e/tests/20-export-import.spec.js b/e2e/tests/20-export-import.spec.js index 5a432ec8..7aca19ec 100644 --- a/e2e/tests/20-export-import.spec.js +++ b/e2e/tests/20-export-import.spec.js @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; -import { readFileSync } from 'fs'; +import { readFileSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; import { dockerCLI } from '../helpers/docker.js'; @@ -23,7 +25,9 @@ async function openImportModal(page, bundlePath) { } test.describe('App Export/Import', () => { - let bundlePath; + // Downloads vanish with their browser context; keep the bundle for both tests. + const tmpDir = mkdtempSync(join(tmpdir(), 'sd-bundle-')); + const bundlePath = join(tmpDir, `${SOURCE}.simpledeploy.zip`); test.beforeAll(async () => { expect((await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password)).status).toBe(200); @@ -34,6 +38,7 @@ test.describe('App Export/Import', () => { const res = await apiRequest('DELETE', `/api/apps/${CLONE}`); expect([200, 404]).toContain(res.status); dockerCLI(['volume', 'rm', '-f', `simpledeploy-${CLONE}_pgdata`]); + rmSync(tmpDir, { recursive: true, force: true }); }); test.beforeEach(async ({ page }) => { @@ -53,7 +58,7 @@ test.describe('App Export/Import', () => { exportSection.getByRole('button', { name: /^export$/i }).click(), ]); expect(download.suggestedFilename()).toBe(`${SOURCE}.simpledeploy.zip`); - bundlePath = await download.path(); + await download.saveAs(bundlePath); const buf = readFileSync(bundlePath); expect(buf.subarray(0, 4)).toEqual(Buffer.from([0x50, 0x4b, 0x03, 0x04])); From bdcfa5cdee8b46cea798bddeac6afe841906fdb4 Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:05:33 -0700 Subject: [PATCH 10/15] test(e2e): rework backup, alert and db-strategy specs; shared backup/metrics helpers --- e2e/fixtures/compose-sqlite.yml | 25 +- e2e/helpers/backups.js | 108 +++++ e2e/helpers/metrics.js | 31 ++ e2e/helpers/minio.js | 62 +-- e2e/helpers/webhook.js | 11 +- e2e/tests/12-backups.spec.js | 624 +++++++------------------ e2e/tests/13-alerts.spec.js | 571 +++++++++-------------- e2e/tests/13b-webhook-formats.spec.js | 445 ------------------ e2e/tests/27-backup-s3.spec.js | 370 ++++----------- e2e/tests/28-db-strategies.spec.js | 625 +++++--------------------- 10 files changed, 748 insertions(+), 2124 deletions(-) create mode 100644 e2e/helpers/backups.js create mode 100644 e2e/helpers/metrics.js delete mode 100644 e2e/tests/13b-webhook-formats.spec.js diff --git a/e2e/fixtures/compose-sqlite.yml b/e2e/fixtures/compose-sqlite.yml index 2de9625b..f2e06ff8 100644 --- a/e2e/fixtures/compose-sqlite.yml +++ b/e2e/fixtures/compose-sqlite.yml @@ -1,20 +1,19 @@ services: - web: - image: nginx:alpine - ports: - - "8097:80" - command: > - sh -c " - apk add --no-cache sqlite && - mkdir -p /data && - sqlite3 /data/test.db \"CREATE TABLE IF NOT EXISTS e2e_items(id INTEGER PRIMARY KEY, v TEXT);\" && - nginx -g 'daemon off;' - " + app: + # Gitea itself does not run: the image is used because it ships sqlite3 + # and busybox (sh, tar, cp) and is already mirrored for the Gitea + # template, so the fixture needs no package download at test time. + image: gitea/gitea:1.26 + entrypoint: + - sh + - -c + - >- + sqlite3 /data/test.db "CREATE TABLE IF NOT EXISTS e2e_items(id INTEGER PRIMARY KEY, v TEXT);" && + trap 'exit 0' TERM INT && + while :; do sleep 1; done volumes: - sqlitedata:/data labels: simpledeploy.backup.strategy: "sqlite" - simpledeploy.endpoints.0.port: "80" - simpledeploy.endpoints.0.tls: "off" volumes: sqlitedata: diff --git a/e2e/helpers/backups.js b/e2e/helpers/backups.js new file mode 100644 index 00000000..ce3258b6 --- /dev/null +++ b/e2e/helpers/backups.js @@ -0,0 +1,108 @@ +import { expect } from '@playwright/test'; +import { existsSync } from 'fs'; +import { apiRequest } from './api.js'; +import { getState } from './auth.js'; + +// Backup config/run helpers shared by the backup specs (12, 13, 27, 28). + +const CONFIG_DEFAULTS = { + target: 'local', + schedule_cron: '0 0 1 1 *', // yearly; never fires during a run + target_config_json: '', + retention_mode: 'count', + retention_count: 5, + verify_upload: false, + pre_hooks: '', + post_hooks: '', + paths: '', +}; + +// createBackupConfig POSTs a config with test defaults and returns its id. +export async function createBackupConfig(slug, fields) { + const res = await apiRequest('POST', `/api/apps/${slug}/backups/configs`, { ...CONFIG_DEFAULTS, ...fields }); + expect(res.status, `create ${fields.strategy} config for ${slug}: ${JSON.stringify(res.data)}`).toBe(201); + expect(res.data.id).toBeGreaterThan(0); + return res.data.id; +} + +export async function listRuns(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); + expect(res.status, `list runs for ${slug}`).toBe(200); + return Array.isArray(res.data) ? res.data : []; +} + +export async function runsForConfig(slug, configId) { + return (await listRuns(slug)).filter((r) => r.backup_config_id === configId); +} + +export async function pollRunStatus(slug, runId, desired, timeoutMs) { + const want = Array.isArray(desired) ? desired : [desired]; + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = (await listRuns(slug)).find((r) => r.id === runId) || last; + if (last && want.includes(last.status)) return last; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`run ${runId} did not reach ${want.join('|')} within ${timeoutMs}ms (last=${JSON.stringify(last)})`); +} + +// waitForNewRunForConfig returns the first run of configId whose id is not +// in knownIds. Runs are listed per config, so data[0] is not the newest. +export async function waitForNewRunForConfig(slug, configId, knownIds, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const fresh = (await runsForConfig(slug, configId)).filter((r) => !knownIds.has(r.id)); + if (fresh.length) return fresh.sort((a, b) => b.id - a.id)[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); +} + +// triggerAndWait runs configId now and returns the finished run +// (status success or failed). +export async function triggerAndWait(slug, configId, timeoutMs = 180_000) { + const known = new Set((await runsForConfig(slug, configId)).map((r) => r.id)); + const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); + expect(trig.status, `trigger config ${configId}`).toBe(202); + const run = await waitForNewRunForConfig(slug, configId, known); + return pollRunStatus(slug, run.id, ['success', 'failed'], timeoutMs); +} + +export async function deleteAppConfigsFor(slug) { + const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); + if (!res.ok || !Array.isArray(res.data)) return; + for (const c of res.data) { + await apiRequest('DELETE', `/api/backups/configs/${c.id}`); + } +} + +// localBackupPath resolves a local-target run's file. run.file_path is the +// bare filename; the server stores it under /backups. +export function localBackupPath(run) { + return `${getState().dataDir}/backups/${String(run.file_path).split('/').pop()}`; +} + +export function localBackupExists(run) { + return !!run.file_path && existsSync(localBackupPath(run)); +} + +// waitForNextSecond resolves once the wall clock is past the second of ts. +// Backup filenames have 1s resolution, so a run triggered after this gets a +// distinct file. +export async function waitForNextSecond(ts) { + const sec = Math.floor(Date.parse(ts) / 1000); + while (Math.floor(Date.now() / 1000) <= sec) { + await new Promise((r) => setTimeout(r, 50)); + } +} + +// readOr returns fn() or fallback when it throws (e.g. a table not restored +// yet). For use inside expect.poll. +export function readOr(fn, fallback = '') { + try { + return fn(); + } catch { + return fallback; + } +} diff --git a/e2e/helpers/metrics.js b/e2e/helpers/metrics.js new file mode 100644 index 00000000..17be3223 --- /dev/null +++ b/e2e/helpers/metrics.js @@ -0,0 +1,31 @@ +import { sqliteExec, replaceMetricsAtomic } from './db.js'; + +// Synthetic metric helpers for driving the alert evaluator. + +export function deleteAppMetrics(slug) { + sqliteExec(`DELETE FROM metrics WHERE app_id = (SELECT id FROM apps WHERE slug='${slug}');`); +} + +// startMetricRefresher keeps a full window of CPU points for slug in the +// metrics table (replacing anything the collector wrote) every 5s, so the +// evaluator always sees a breaching (high) or healthy window. The first +// write throws on error; later errors are kept in lastError() so a waitFor +// timeout can report them. +export function startMetricRefresher(slug, { high }) { + let lastError = null; + const write = () => + replaceMetricsAtomic(slug, { cpu: high ? 95 : 5, memoryMb: high ? 100 : 50 }); + write(); + const handle = setInterval(() => { + try { + write(); + lastError = null; + } catch (e) { + lastError = e; + } + }, 5_000); + return { + stop: () => clearInterval(handle), + lastError: () => (lastError ? `metric refresher: ${lastError.message}` : ''), + }; +} diff --git a/e2e/helpers/minio.js b/e2e/helpers/minio.js index c0d30c64..881ff4dd 100644 --- a/e2e/helpers/minio.js +++ b/e2e/helpers/minio.js @@ -93,22 +93,16 @@ export async function startMinIO({ bucket = 'e2e-backups' } = {}) { const accessKey = MINIO_ROOT_USER; const secretKey = MINIO_ROOT_PASSWORD; - // mc runs the bundled client in a short-lived container, configured via env - // MC_HOST_local. Use host networking via host.docker.internal on mac/windows, - // or --add-host for linux. We pass the endpoint as http://host.docker.internal:PORT - // because mc runs inside docker. + // mc runs the client bundled in the MinIO container itself, against + // localhost:9000 inside that container. function mc(args, opts = {}) { - const mcHost = `http://${accessKey}:${secretKey}@host.docker.internal:${port}`; try { return execFileSync( 'docker', [ - 'run', '--rm', - '--add-host', 'host.docker.internal:host-gateway', - '-e', `MC_HOST_local=${mcHost}`, - '--entrypoint', 'mc', - MINIO_IMAGE, - ...args, + 'exec', + '-e', `MC_HOST_local=http://${accessKey}:${secretKey}@localhost:9000`, + containerName, 'mc', ...args, ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }, ); @@ -122,44 +116,16 @@ export async function startMinIO({ bucket = 'e2e-backups' } = {}) { // Create the bucket via mc. mc(['mb', '--ignore-existing', `local/${bucket}`]); + // listObjects returns every object under prefix, keys relative to it. + // An empty or missing prefix lists nothing; any other mc error throws. function listObjects(prefix = '') { - const target = prefix ? `local/${bucket}/${prefix}` : `local/${bucket}`; - // mc ls --json emits one JSON document per line. - let out; - try { - out = mc(['ls', '--json', target]); - } catch (e) { - // Empty listing can return non-zero on some mc versions; treat as empty. - if (/no such|not found/i.test(String(e.message))) return []; - // Fallback: try plain ls - try { - const plain = mc(['ls', target]); - return plain - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const parts = line.split(/\s+/); - return { key: parts[parts.length - 1], size: 0 }; - }); - } catch { - return []; - } - } - const entries = []; - for (const line of out.split('\n')) { - const s = line.trim(); - if (!s) continue; - try { - const obj = JSON.parse(s); - if (obj.key || obj.name) { - entries.push({ key: obj.key || obj.name, size: obj.size || 0 }); - } - } catch { - // ignore non-json lines - } - } - return entries; + const target = prefix ? `local/${bucket}/${prefix.replace(/\/?$/, '/')}` : `local/${bucket}`; + const out = mc(['ls', '--recursive', '--json', target]); + return out + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .map((obj) => ({ key: obj.key, size: obj.size || 0 })); } function stop() { diff --git a/e2e/helpers/webhook.js b/e2e/helpers/webhook.js index ee3ed6be..ad8495a6 100644 --- a/e2e/helpers/webhook.js +++ b/e2e/helpers/webhook.js @@ -39,15 +39,22 @@ export async function startWebhookReceiver() { setResponder: (fn) => (responder = fn), clear: () => (received.length = 0), stop: () => new Promise((r) => server.close(() => r())), - waitFor: async (predicate, timeoutMs = 20_000) => { + // waitFor resolves with the first request matching predicate. On + // timeout the error lists what did arrive, plus describe() if given. + waitFor: async (predicate, timeoutMs = 20_000, describe) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const match = received.find(predicate); if (match) return match; await new Promise((r) => setTimeout(r, 200)); } - throw new Error(`webhook not received matching predicate within ${timeoutMs}ms. got ${received.length} requests`); + const got = received.map((r) => `${r.path} ${r.rawBody.slice(0, 120)}`).join('; '); + const extra = describe ? describe() : ''; + throw new Error(`webhook not received matching predicate within ${timeoutMs}ms. got ${received.length} requests: [${got}] ${extra}`); }, + // onPath returns requests received on path (e.g. '/', '/probe'). + onPath: (path) => received.filter((r) => r.path === path), + countWhere: (predicate) => received.filter(predicate).length, }); }); }); diff --git a/e2e/tests/12-backups.spec.js b/e2e/tests/12-backups.spec.js index b16b9818..1df816b8 100644 --- a/e2e/tests/12-backups.spec.js +++ b/e2e/tests/12-backups.spec.js @@ -1,552 +1,252 @@ import { test, expect } from '@playwright/test'; import { createHash } from 'crypto'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; +import { execFileSync } from 'child_process'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest, apiDownload, apiUploadMultipart } from '../helpers/api.js'; +import { findServiceContainer, dockerExec, dockerInspect, psql, waitForHealthy } from '../helpers/docker.js'; import { - apiLogin, - apiRequest, - apiDownload, - apiUploadMultipart, - waitForAppStatus, -} from '../helpers/api.js'; -import { - findServiceContainer, - dockerExec, - psql, - containerRunning, - waitForContainerState, - waitForHealthy, -} from '../helpers/docker.js'; - -const FIXTURES = join(import.meta.dirname, '..', 'fixtures'); - -function readFixture(name) { - return readFileSync(join(FIXTURES, name), 'utf-8'); -} - -async function pollRunStatus(slug, runId, desired, timeoutMs) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const run = res.data.find((r) => r.id === runId); - if (run) { - last = run; - if (Array.isArray(desired) ? desired.includes(run.status) : run.status === desired) { - return run; - } - } - } - await new Promise((r) => setTimeout(r, 1_000)); - } - throw new Error(`run ${runId} did not reach status ${desired} within ${timeoutMs}ms (last=${JSON.stringify(last)})`); + createBackupConfig, + deleteAppConfigsFor, + listRuns, + localBackupExists, + localBackupPath, + pollRunStatus, + readOr, + triggerAndWait, + waitForNewRunForConfig, +} from '../helpers/backups.js'; + +const POSTGRES_SLUG = 'e2e-postgres'; + +async function openBackupsTab(page) { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/apps/${POSTGRES_SLUG}`); + await page.getByRole('button', { name: /^backups$/i }).click(); } -async function waitForNewRunForConfig(slug, configId, sinceCount, timeoutMs) { - // The API groups runs per-config; `data[0]` is NOT guaranteed to be the - // globally newest run. Filter to the target config explicitly. - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const forCfg = res.data.filter((r) => r.backup_config_id === configId); - if (forCfg.length > sinceCount) { - // Sort by started_at DESC just in case - forCfg.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))); - return forCfg[0]; - } - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); -} - -async function triggerAndWait(slug, configId, timeoutMs = 180_000) { - const before = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - const beforeCount = Array.isArray(before.data) - ? before.data.filter((r) => r.backup_config_id === configId).length - : 0; - const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); - expect([200, 202]).toContain(trig.status); - const newest = await waitForNewRunForConfig(slug, configId, beforeCount, 15_000); - return await pollRunStatus(slug, newest.id, ['success', 'failed'], timeoutMs); -} - -async function deleteAppConfigsFor(slug) { +async function listConfigs(slug) { const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); - if (!res.ok || !Array.isArray(res.data)) return; - for (const c of res.data) { - await apiRequest('DELETE', `/api/backups/configs/${c.id}`); - } + expect(res.status).toBe(200); + return res.data; } test.describe('Backups', () => { - test('navigate to postgres app backups tab', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - await expect(page.getByText(/backup|configure/i).first()).toBeVisible({ timeout: 5_000 }); - }); + test.describe.configure({ mode: 'serial' }); - test('detect strategies shows postgres and volume', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const configBtn = page.getByRole('button', { name: /configure backup|add config/i }); - await configBtn.click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 }); - - // Strategy detection should show PostgreSQL and a volume/snapshot strategy. - // Backend label is "Volume Snapshot"; legacy UI label is "Files & Volumes". - await expect(page.getByText(/postgresql/i)).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText(/(volume|files.*volumes)/i).first()).toBeVisible(); + let configId = null; + + test.beforeAll(async () => { + const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect(login.status).toBe(200); + await deleteAppConfigsFor(POSTGRES_SLUG); }); test('create backup config via wizard', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const configBtn = page.getByRole('button', { name: /configure backup|add config/i }); - await configBtn.click(); + await openBackupsTab(page); + await page.getByRole('button', { name: 'Configure Backup' }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toBeVisible(); - // Step 1: Select postgres strategy (auto-detected, may already be selected) - const pgBtn = dialog.getByText(/postgresql/i).first(); - if (await pgBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await pgBtn.click(); - } - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 1: strategy, detected from the compose file + const pgCard = dialog.getByRole('button', { name: /^PostgreSQL/ }); + await expect(pgCard).toContainText('Detected'); + await pgCard.click(); + await dialog.getByRole('button', { name: 'Next' }).click(); - // Step 2: Select local storage (default) - await dialog.getByText(/local storage/i).click(); - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 2: storage + await dialog.getByRole('button', { name: /Local Storage/ }).click(); + await dialog.getByRole('button', { name: 'Next' }).click(); - // Step 3: Schedule (accept defaults) - await dialog.getByRole('button', { name: /next/i }).click(); - - // Step 4: Hooks (skip) - await dialog.getByRole('button', { name: /next/i }).click(); + // Steps 3-5: schedule, hooks, retention (defaults) + for (let i = 0; i < 3; i++) { + await dialog.getByRole('button', { name: 'Next' }).click(); + } - // Step 5: Retention (accept defaults) - await dialog.getByRole('button', { name: /next/i }).click(); + // Step 6: review + await dialog.getByRole('button', { name: 'Create Backup' }).click(); + await expect(dialog).toBeHidden(); - // Step 6: Review and create - await dialog.getByRole('button', { name: /create backup/i }).click(); + const configs = await listConfigs(POSTGRES_SLUG); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ strategy: 'postgres', target: 'local' }); + configId = configs[0].id; - // Verify config appears in the table - await expect(page.getByText(/local/i).first()).toBeVisible({ timeout: 10_000 }); + const row = page.locator('main table').first().getByRole('row').nth(1); + await expect(row).toContainText('Database (PostgreSQL)'); + await expect(row).toContainText('Local storage'); }); test('trigger manual backup', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const backupBtn = page.getByRole('button', { name: /backup now/i }); - await expect(backupBtn).toBeVisible({ timeout: 10_000 }); - await backupBtn.click(); - - // Wait for backup to run, then reload to see result - await page.waitForTimeout(3_000); - await page.reload(); - await page.getByRole('button', { name: /backups/i }).click(); - await expect(page.getByText(/running|success|failed/i).first()).toBeVisible({ timeout: 15_000 }); + expect(configId, 'wizard config must exist').toBeTruthy(); + await openBackupsTab(page); + + const known = new Set((await listRuns(POSTGRES_SLUG)).map((r) => r.id)); + const trig = page.waitForResponse( + (r) => r.url().endsWith(`/api/backups/configs/${configId}/run`) && r.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Backup Now' }).click(); + expect((await trig).status()).toBe(202); + + const run = await waitForNewRunForConfig(POSTGRES_SLUG, configId, known); + const done = await pollRunStatus(POSTGRES_SLUG, run.id, ['success', 'failed'], 120_000); + expect(done.status, `run error: ${done.error_msg}`).toBe('success'); + + // The tab should pick up the finished run without a reload. + const history = page.locator('main').getByRole('table').nth(1); + await expect(history.getByRole('row').nth(1)).toContainText('success', { timeout: 15_000 }); }); test('global backups page shows summary', async ({ page }) => { await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/backups`); - await expect(page.getByText(/total config/i).first()).toBeVisible({ timeout: 5_000 }); + await page.goto(`${getState().baseURL}/#/backups`); + const stat = (label) => + page.locator('main').getByText(label, { exact: true }).locator('xpath=following-sibling::div[1]'); + await expect(stat('Total Configs')).toHaveText('1'); + expect(Number(await stat('24h Successful').textContent())).toBeGreaterThanOrEqual(1); }); test('delete backup config', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/e2e-postgres`); - await page.getByRole('button', { name: /backups/i }).click(); - - const deleteBtn = page.getByRole('button', { name: /delete/i }).first(); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } - } + expect(configId, 'wizard config must exist').toBeTruthy(); + await openBackupsTab(page); + + const configsTable = page.locator('main').getByRole('table').first(); + const row = configsTable.getByRole('row', { name: /Database \(PostgreSQL\)/ }); + await row.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('heading', { name: 'No backups configured' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Backup Now' })).toHaveCount(0); + expect(await listConfigs(POSTGRES_SLUG)).toEqual([]); }); }); test.describe('Backups - Functional Roundtrip', () => { test.describe.configure({ mode: 'serial' }); - const POSTGRES_SLUG = 'e2e-postgres'; - const VOLUME_SLUG = 'e2e-volume'; + const MARKER_SQL = "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"; + const SELECT_SQL = "SELECT id||'|'||v FROM e2e_data WHERE id=1;"; let pgContainer = null; - let volContainer = null; - let postgresConfigId = null; - let volumeConfigId = null; - let pgSuccessRunId = null; test.beforeAll(async () => { - test.setTimeout(300_000); const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(login.ok).toBeTruthy(); + expect(login.status).toBe(200); - // Locate postgres container (deployed in earlier spec) pgContainer = findServiceContainer(POSTGRES_SLUG, 'db'); expect(pgContainer, 'e2e-postgres db container must be running').toBeTruthy(); - // Clean any pre-existing configs from earlier UI tests await deleteAppConfigsFor(POSTGRES_SLUG); + }); - // Volume strategy roundtrip uses the e2e-postgres pgdata volume directly. - // (Avoids a separate e2e-volume app deploy which can race with the - // reconciler during a busy test session.) The postgres container has - // /var/lib/postgresql/data mounted from the pgdata volume; we seed and - // restore a test file there for the volume strategy assertion. - volContainer = pgContainer; - await waitForHealthy(volContainer, 'test -d /var/lib/postgresql/data', 30_000); + test.afterAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + await deleteAppConfigsFor(POSTGRES_SLUG); }); test('1. postgres data roundtrip: insert, backup, drop, restore, verify', async () => { - test.setTimeout(300_000); - - // 1. Seed data - psql(pgContainer, 'postgres', 'testdb', - "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"); - const seededOut = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - expect(seededOut).toContain('1|roundtrip-marker-abc123'); - - // 2. Create backup config - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', // yearly on Jan 1; effectively won't auto-trigger - target_config_json: '', - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - expect(createRes.data.id).toBeGreaterThan(0); - postgresConfigId = createRes.data.id; + psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); + expect(psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)).toBe('1|roundtrip-marker-abc123'); - // 3. Trigger manual backup and wait for success - const run = await triggerAndWait(POSTGRES_SLUG, postgresConfigId, 180_000); - expect(run.status).toBe('success'); + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres' }); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); expect(run.checksum.length).toBeGreaterThan(16); - expect(run.file_path).toBeTruthy(); - pgSuccessRunId = run.id; + expect(localBackupExists(run), `backup file ${localBackupPath(run)}`).toBe(true); - // 4. Drop table psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); - let errored = false; - try { - psql(pgContainer, 'postgres', 'testdb', 'SELECT * FROM e2e_data;'); - } catch { - errored = true; - } - expect(errored).toBeTruthy(); + expect(() => psql(pgContainer, 'postgres', 'testdb', 'SELECT * FROM e2e_data;')).toThrow(/does not exist/); - // 5. Restore const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); expect(restoreRes.status).toBe(202); - - // 6. Poll until table comes back (restore is async) - const restoreDeadline = Date.now() + 60_000; - let restoredOut = ''; - while (Date.now() < restoreDeadline) { - try { - restoredOut = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - if (restoredOut.includes('roundtrip-marker-abc123')) break; - } catch { - // table may not exist yet - } - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(restoredOut).toContain('1|roundtrip-marker-abc123'); + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 60_000 }) + .toBe('1|roundtrip-marker-abc123'); }); - test('2. backup file checksum matches SHA-256 of downloaded bytes', async () => { - expect(pgSuccessRunId, 'prior postgres backup must exist').toBeTruthy(); - test.setTimeout(60_000); - - const runRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runRes.ok).toBeTruthy(); - const run = runRes.data.find((r) => r.id === pgSuccessRunId); - expect(run).toBeTruthy(); - expect(run.checksum).toBeTruthy(); - - const dl = await apiDownload(`/api/backups/runs/${pgSuccessRunId}/download`); - expect(dl.ok, `download failed: status=${dl.status} body=${dl.body}`).toBeTruthy(); - expect(dl.buffer.length).toBeGreaterThan(0); - - const actual = createHash('sha256').update(dl.buffer).digest('hex'); - // run.checksum may be prefixed (e.g. "sha256:...") or raw hex; compare both - const stored = run.checksum.replace(/^sha256:/i, '').toLowerCase(); - expect(actual.toLowerCase()).toBe(stored); - }); - - test('3. volume strategy captures files into a valid tar.gz archive', async () => { - // A full restore-over-live-postgres cycle is racy (pg keeps files open). - // Instead we verify the volume backup produces a valid tar.gz archive - // that contains the seeded marker. Read the file directly from disk. - test.setTimeout(300_000); - + test('2. volume strategy captures files into a valid tar.gz archive', async () => { + // Restoring over a live postgres data dir is racy (pg keeps files open), + // so verify the archive contents instead of a full roundtrip. const volDir = '/var/lib/postgresql/data'; const markerPath = `${volDir}/e2e_marker.txt`; + dockerExec(pgContainer, `echo volume-marker-xyz > ${markerPath}`); - dockerExec(volContainer, `echo volume-marker-xyz > ${markerPath}`); - expect(dockerExec(volContainer, `cat ${markerPath}`).trim()).toBe('volume-marker-xyz'); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'volume', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: JSON.stringify([volDir]), - }); - expect(createRes.ok).toBeTruthy(); - volumeConfigId = createRes.data.id; - - const run = await triggerAndWait(POSTGRES_SLUG, volumeConfigId, 180_000); - expect(run.status).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - - // Find the backup file on disk under ${state.dataDir}/backups. run.file_path - // is just the filename for the local target. - const state = getState(); - const { execFileSync } = await import('child_process'); - const filename = (run.file_path || '').split('/').pop(); - expect(filename, 'run must have a file_path').toBeTruthy(); - const absPath = `${state.dataDir}/backups/${filename}`; - - const list = execFileSync('tar', ['-tzf', absPath], { encoding: 'utf-8' }); - expect(list).toContain('e2e_marker.txt'); - - const extracted = execFileSync('tar', ['-xzOf', absPath, 'var/lib/postgresql/data/e2e_marker.txt'], { - encoding: 'utf-8', - }).trim(); - expect(extracted).toBe('volume-marker-xyz'); - - try { dockerExec(volContainer, `rm -f ${markerPath}`); } catch {} - }); - - test('4. pre/post hooks execute around postgres backup', async () => { - test.setTimeout(300_000); - - // Build hook config: stop db pre-backup, start db post-backup. - // NOTE: hook service names map to container names. The scheduler passes - // app.Name as ContainerName; for hooks we target the actual service container. - const preHooks = JSON.stringify([ - { type: 'stop', service: pgContainer }, - ]); - const postHooks = JSON.stringify([ - { type: 'start', service: pgContainer }, - ]); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: preHooks, - post_hooks: postHooks, - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - const hookCfgId = createRes.data.id; - - // Trigger and observe state transitions. The backup itself will likely fail - // because the container is stopped during backup (pg_dump needs running pg), - // but the post-hook should restart it and we can assert container recovered. - const wasRunning = containerRunning(pgContainer); - expect(wasRunning).toBeTruthy(); - - const trig = await apiRequest('POST', `/api/backups/configs/${hookCfgId}/run`); - expect([200, 202]).toContain(trig.status); - - // Observe a stopped state (pre-hook ran) - let sawStopped = false; - const stopDeadline = Date.now() + 30_000; - while (Date.now() < stopDeadline) { - if (containerRunning(pgContainer) === false) { sawStopped = true; break; } - await new Promise((r) => setTimeout(r, 250)); + try { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'volume', paths: JSON.stringify([volDir]) }); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); + expect(run.size_bytes).toBeGreaterThan(0); + + const absPath = localBackupPath(run); + const list = execFileSync('tar', ['-tzf', absPath], { encoding: 'utf-8' }); + expect(list).toContain('var/lib/postgresql/data/e2e_marker.txt'); + const extracted = execFileSync('tar', ['-xzOf', absPath, 'var/lib/postgresql/data/e2e_marker.txt'], { + encoding: 'utf-8', + }).trim(); + expect(extracted).toBe('volume-marker-xyz'); + } finally { + dockerExec(pgContainer, `rm -f ${markerPath}`); } - - // Whether or not we caught the stopped window, wait for container to be running again (post-hook) - await waitForContainerState(pgContainer, true, 60_000); - - // Wait for postgres to accept connections again - await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); - - // Assert we observed the stop (pre-hook) OR that the run recorded hook execution. - // If the backup was too fast to catch the stopped state, at minimum the run record - // should exist. - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const hookRun = runsRes.data.find((r) => r.backup_config_id === hookCfgId); - expect(hookRun).toBeTruthy(); - - // Post-hook must have restarted the container (running now) - expect(containerRunning(pgContainer)).toBeTruthy(); - - // Cleanup hook config so retention test starts clean - await apiRequest('DELETE', `/api/backups/configs/${hookCfgId}`); }); - test('5. retention_count=2 prunes older successful runs', async () => { - test.setTimeout(600_000); + test('3. pre/post hooks run in the app container around the backup', async () => { + // Hooks name compose services. The pre-hook leaves a marker; the post + // hooks restart the db service (so its StartedAt changes) and then leave + // a second marker, which needs the restarted container to be running. + const tag = Date.now(); + const preMarker = `/tmp/e2e-pre-hook-${tag}`; + const postMarker = `/tmp/e2e-post-hook-${tag}`; + const startedBefore = dockerInspect(pgContainer).State.StartedAt; - // Fresh config with retention_count=2 - await deleteAppConfigsFor(POSTGRES_SLUG); - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 2, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', + pre_hooks: JSON.stringify([{ type: 'exec', service: 'db', command: `touch ${preMarker}` }]), + post_hooks: JSON.stringify([ + { type: 'stop', service: 'db' }, + { type: 'start', service: 'db' }, + { type: 'exec', service: 'db', command: `touch ${postMarker}` }, + ]), }); - expect(createRes.ok).toBeTruthy(); - const retCfgId = createRes.data.id; - - const filePaths = []; - for (let i = 0; i < 3; i++) { - const run = await triggerAndWait(POSTGRES_SLUG, retCfgId, 180_000); - expect(run.status).toBe('success'); - if (run.file_path) filePaths.push(run.file_path); - // Ensure different timestamp on filename (postgres strategy uses YYYYMMDD-HHMMSS) - await new Promise((r) => setTimeout(r, 1500)); - } - - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const successRuns = runsRes.data.filter( - (r) => r.backup_config_id === retCfgId && r.status === 'success', - ); - // Scheduler prunes files on disk for runs beyond retention_count, - // though the DB rows remain. Assert at most 2 on-disk files exist. - let onDiskCount = 0; - for (const p of filePaths) { - if (p && existsSync(p)) onDiskCount += 1; + try { + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); + await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); + + expect(() => dockerExec(pgContainer, `test -f ${preMarker}`), 'pre-hook marker').not.toThrow(); + expect(() => dockerExec(pgContainer, `test -f ${postMarker}`), 'post-hook marker').not.toThrow(); + expect(dockerInspect(pgContainer).State.StartedAt, 'post-hooks restart db').not.toBe(startedBefore); + } finally { + await apiRequest('DELETE', `/api/backups/configs/${cfgId}`); + await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); } - expect(onDiskCount).toBeLessThanOrEqual(2); - // DB row count should be at least 3 (we triggered 3); success rows exist for all - expect(successRuns.length).toBeGreaterThanOrEqual(3); - - // Save config id so next test can reuse it for download/upload cycle - postgresConfigId = retCfgId; }); - test('6. download → upload-restore cycle via multipart', async () => { - test.setTimeout(300_000); - - // Find any successful postgres run whose on-disk file still exists. - // run.file_path stores the filename only; resolve against dataDir/backups. - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const state = getState(); - const candidates = runsRes.data.filter((r) => { - if (r.status !== 'success' || !r.file_path) return false; - if (!r.file_path.endsWith('.sql.gz')) return false; - const abs = `${state.dataDir}/backups/${r.file_path.split('/').pop()}`; - return existsSync(abs); - }); - expect(candidates.length).toBeGreaterThan(0); - const run = candidates[0]; + test('4. download, checksum, upload-restore cycle via multipart', async () => { + const runs = await listRuns(POSTGRES_SLUG); + const run = runs.find((r) => r.status === 'success' && r.file_path.endsWith('.sql.gz') && localBackupExists(r)); + expect(run, 'a postgres run from test 1 with its file on disk').toBeTruthy(); - // Re-seed the table so we can prove restore worked after drop - psql(pgContainer, 'postgres', 'testdb', - "CREATE TABLE IF NOT EXISTS e2e_data(id INT, v TEXT); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'roundtrip-marker-abc123');"); + psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); - // Download const dl = await apiDownload(`/api/backups/runs/${run.id}/download`); - expect(dl.ok, `download failed: status=${dl.status}`).toBeTruthy(); - expect(dl.buffer.length).toBeGreaterThan(0); + expect(dl.status, `download failed: ${dl.body}`).toBe(200); + const actual = createHash('sha256').update(dl.buffer).digest('hex'); + expect(actual).toBe(run.checksum.replace(/^sha256:/i, '').toLowerCase()); - // Drop table psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); - // Upload-restore. The downloaded filename should end in .sql.gz. - const fileName = run.file_path.split('/').pop() || 'backup.sql.gz'; const up = await apiUploadMultipart( `/api/apps/${POSTGRES_SLUG}/backups/upload-restore`, { strategy: 'postgres', container: pgContainer }, 'file', - fileName, + run.file_path.split('/').pop(), dl.buffer, ); - expect([200, 202]).toContain(up.status); - - // Poll until data comes back - const deadline = Date.now() + 90_000; - let got = ''; - while (Date.now() < deadline) { - try { - got = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_data WHERE id=1;"); - if (got.includes('roundtrip-marker-abc123')) break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(got).toContain('roundtrip-marker-abc123'); - }); - - test('7. strategy detection reports postgres and volume for postgres app', async () => { - const res = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const byType = Object.fromEntries(strategies.map((s) => [s.strategy_type, s])); - - // Postgres strategy should be available with a postgres service - expect(byType.postgres, 'postgres strategy missing').toBeTruthy(); - expect(byType.postgres.available).toBe(true); - expect(Array.isArray(byType.postgres.services)).toBe(true); - expect(byType.postgres.services.length).toBeGreaterThan(0); - const pgSvc = byType.postgres.services[0]; - expect(pgSvc.container_name).toContain('db'); - - // Volume strategy should also surface for pgdata volume mount - expect(byType.volume, 'volume strategy missing').toBeTruthy(); - expect(byType.volume.available).toBe(true); - expect(byType.volume.services.length).toBeGreaterThan(0); - }); + expect(up.status, `upload-restore: ${JSON.stringify(up.data)}`).toBe(202); - test.afterAll(async () => { - // Best-effort cleanup of backup configs so later specs have a clean slate. - try { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - await deleteAppConfigsFor(POSTGRES_SLUG); - } catch {} + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 90_000 }) + .toBe('1|roundtrip-marker-abc123'); }); }); diff --git a/e2e/tests/13-alerts.spec.js b/e2e/tests/13-alerts.spec.js index 5cfc6391..717cbc6d 100644 --- a/e2e/tests/13-alerts.spec.js +++ b/e2e/tests/13-alerts.spec.js @@ -1,352 +1,254 @@ import { test, expect } from '@playwright/test'; import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; import { startWebhookReceiver } from '../helpers/webhook.js'; -import { - sqliteQuery, - sqliteExec, - injectHighCPUWindow, - insertMetricPoint, - getAppId, - replaceMetricsAtomic, -} from '../helpers/db.js'; +import { sqliteQuery, sqliteExec, getAppId } from '../helpers/db.js'; +import { deleteAppMetrics, startMetricRefresher } from '../helpers/metrics.js'; +import { createBackupConfig } from '../helpers/backups.js'; + +const UI_HOOK = 'E2E Test Hook'; + +async function listWebhooks() { + const res = await apiRequest('GET', '/api/webhooks'); + expect(res.status).toBe(200); + return res.data; +} + +async function listRules() { + const res = await apiRequest('GET', '/api/alerts/rules'); + expect(res.status).toBe(200); + return res.data; +} + +async function createWebhook(body) { + const res = await apiRequest('POST', '/api/webhooks', body); + expect(res.status, `webhook create: ${JSON.stringify(res.data)}`).toBe(201); + return res.data.id; +} + +async function createRule(body) { + const res = await apiRequest('POST', '/api/alerts/rules', { operator: '>', enabled: true, ...body }); + expect(res.status, `rule create: ${JSON.stringify(res.data)}`).toBe(201); + return res.data.id; +} + +// deleteRule drops the rule's alert history first: deleting a rule keeps its +// history rows (rule_id set to NULL). +async function deleteRule(id) { + sqliteExec(`DELETE FROM alert_history WHERE rule_id=${id};`); + const res = await apiRequest('DELETE', `/api/alerts/rules/${id}`); + expect(res.status, `delete rule ${id}`).toBe(200); +} test.describe('Alerts & Webhooks', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/alerts`); - }); - - test('alerts page loads', async ({ page }) => { - await expect(page.getByText(/webhook/i).first()).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText(/alert rule/i).first()).toBeVisible({ timeout: 5_000 }); - }); - - test('create webhook', async ({ page }) => { - await page.getByRole('button', { name: /add webhook/i }).click(); - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); + test.describe.configure({ mode: 'serial' }); - await dialog.getByPlaceholder(/my webhook/i).fill('E2E Test Hook'); - const typeSelect = dialog.locator('select').first(); - await typeSelect.selectOption('custom'); - await dialog.getByPlaceholder(/https:\/\//i).fill('https://httpbin.org/post'); + let receiver; - await dialog.getByRole('button', { name: /create/i }).click(); - await expect(page.getByText('E2E Test Hook')).toBeVisible({ timeout: 5_000 }); + test.beforeAll(async () => { + const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect(login.status).toBe(200); + receiver = await startWebhookReceiver(); }); - test('test webhook', async ({ page }) => { - const testBtn = page.getByRole('button', { name: /test/i }).first(); - if (await testBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await testBtn.click(); - await expect(page.locator('[role="alert"]').first()).toBeVisible({ timeout: 10_000 }); + test.afterAll(async () => { + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + // Leftovers from a failed test; deleting the webhook cascades to its rules. + for (const w of (await listWebhooks()).filter((w) => w.name === UI_HOOK)) { + await apiRequest('DELETE', `/api/webhooks/${w.id}`); } + await receiver.stop(); }); - test('create alert rule', async ({ page }) => { - await page.getByRole('button', { name: /add rule/i }).click(); + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${getState().baseURL}/#/alerts`); + }); + + test('create webhook, with a test send from the form', async ({ page }) => { + const url = `${receiver.url}/ui`; + await page.getByRole('button', { name: 'Add Webhook' }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); - const nameInput = dialog.getByPlaceholder(/name|rule/i); - if (await nameInput.isVisible({ timeout: 2_000 }).catch(() => false)) { - await nameInput.fill('E2E CPU Alert'); - } - - const metricSelect = dialog.locator('select').filter({ hasText: /cpu|metric/i }).first(); - if (await metricSelect.isVisible({ timeout: 2_000 }).catch(() => false)) { - await metricSelect.selectOption({ index: 0 }); - } + await dialog.getByPlaceholder('My Webhook').fill(UI_HOOK); + await dialog.locator('select').first().selectOption('custom'); + await dialog.getByPlaceholder('https://example.com/webhook').fill(url); - const thresholdInput = dialog.locator('input[type="number"]').first(); - if (await thresholdInput.isVisible({ timeout: 2_000 }).catch(() => false)) { - await thresholdInput.fill('90'); - } + await dialog.getByRole('button', { name: 'Send Test' }).click(); + const hit = await receiver.waitFor((r) => r.path === '/ui', 10_000); + expect(hit.body).toMatchObject({ app: 'my-app', metric: 'cpu_pct', status: 'firing' }); - const webhookSelect = dialog.locator('select').last(); - if (await webhookSelect.isVisible({ timeout: 2_000 }).catch(() => false)) { - const options = await webhookSelect.locator('option').allTextContents(); - if (options.length > 1) { - await webhookSelect.selectOption({ index: 1 }); - } - } + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden(); - await dialog.getByRole('button', { name: /create|save/i }).click(); - await expect(page.getByText(/cpu/i).first()).toBeVisible({ timeout: 5_000 }); + const hook = (await listWebhooks()).find((w) => w.name === UI_HOOK); + expect(hook).toMatchObject({ type: 'custom', url }); + await expect(page.locator('main table').first().getByRole('row', { name: new RegExp(UI_HOOK) })).toBeVisible(); }); - test('view alert history', async ({ page }) => { - await expect(page.getByText(/history/i).first()).toBeVisible({ timeout: 5_000 }); + test('create alert rule', async ({ page }) => { + const hook = (await listWebhooks()).find((w) => w.name === UI_HOOK); + expect(hook, 'webhook from the previous test').toBeTruthy(); + + await page.getByRole('button', { name: 'Add Rule' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const select = (optionText) => dialog.locator('select', { has: page.locator('option', { hasText: optionText }) }); + await select('All Apps').selectOption('e2e-nginx'); + await select('CPU Usage').selectOption('cpu_pct'); + await select('above').selectOption('>'); + await dialog.locator('input[type="number"]').first().fill('90'); + await select('Select webhook').selectOption(String(hook.id)); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden(); + + const rule = (await listRules()).find((r) => r.webhook_id === hook.id); + // The rule must keep the app picked in the form, not become an all-apps rule. + expect(rule).toMatchObject({ metric: 'cpu_pct', operator: '>', threshold: 90, enabled: true, app_slug: 'e2e-nginx' }); + await expect(page.locator('main table').nth(1).getByRole('row', { name: /CPU Usage > 90\.0%/ })).toBeVisible(); }); test('delete alert rule', async ({ page }) => { - const deleteBtn = page.locator('button').filter({ hasText: /delete/i }).last(); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } - } + const hook = (await listWebhooks()).find((w) => w.name === UI_HOOK); + expect(hook, 'webhook from the earlier test').toBeTruthy(); + + const row = page.locator('main table').nth(1).getByRole('row', { name: /CPU Usage > 90\.0%/ }); + await row.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + + await expect(row).toHaveCount(0); + expect((await listRules()).filter((r) => r.webhook_id === hook.id)).toEqual([]); }); test('delete webhook', async ({ page }) => { - const deleteBtn = page.locator('button').filter({ hasText: /delete/i }).first(); - if (await deleteBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await deleteBtn.click(); - const dialog = page.getByRole('dialog'); - if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { - await dialog.getByRole('button', { name: /delete|confirm/i }).click(); - } - } + const row = page.locator('main table').first().getByRole('row', { name: new RegExp(UI_HOOK) }); + await row.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + + await expect(row).toHaveCount(0); + expect((await listWebhooks()).some((w) => w.name === UI_HOOK)).toBe(false); }); }); -// ----------------------------------------------------------------------------- -// Functional dispatch tests: exercise the real evaluator + webhook dispatcher -// against a local HTTP receiver by injecting synthetic metric points. -// ----------------------------------------------------------------------------- - +// Functional dispatch: the real evaluator + webhook dispatcher against a +// local receiver, driven by synthetic metric points. The evaluator ticks +// every 2s in e2e and walks enabled rules in id order, sending each webhook +// synchronously. So once a rule created later (higher id) has fired, every +// earlier rule was evaluated in that same tick; probe/control rules use this +// to prove that something did NOT fire without sleeping. test.describe('Alerts - Functional Dispatch', () => { test.describe.configure({ mode: 'serial' }); - // Evaluator ticks every 30s in production. Give it 2+ cycles before giving up. - const EVAL_WAIT_MS = 75_000; + const EVAL_WAIT_MS = 20_000; const APP_SLUG = 'e2e-nginx'; const DURATION_SEC = 60; let receiver; + let appId; let webhookId; let ruleId; - let backupRuleIds = []; - let backupConfigIds = []; - let nginxWasRunning = false; - - function deleteAppMetrics(slug) { - sqliteExec( - `DELETE FROM metrics WHERE app_id = (SELECT id FROM apps WHERE slug='${slug}');`, - ); - } - - function injectLowCPUWindow(slug, cpuPct = 5) { - const now = Math.floor(Date.now() / 1000); - for (let i = 0; i < 10; i++) { - insertMetricPoint({ - appSlug: slug, - cpu: cpuPct, - memoryMb: 50, - tsSec: now - i * 5, - }); - } - } - - // Periodically refresh high-CPU metrics so the evaluator always sees a full - // window of breaching points even if the real collector writes low values. - function startMetricRefresher(slug, high) { - let stopped = false; - const tick = () => { - if (stopped) return; - try { - // Single transaction: DELETE the real collector's points AND the - // previous injected window, then INSERT the fresh window. Non-atomic - // delete+insert would let a fast evaluator tick (2s) see an empty - // window, resolve the alert, then re-fire on the next tick. - replaceMetricsAtomic(slug, { - cpu: high ? 95 : 5, - memoryMb: high ? 100 : 50, - }); - } catch (e) { - // ignore transient sqlite lock errors - } - }; - tick(); - const handle = setInterval(tick, 5_000); - return () => { - stopped = true; - clearInterval(handle); - }; + const ruleIds = []; + const webhookIds = []; + const backupConfigIds = []; + + const cpuEvents = (path, status) => + receiver.onPath(path).filter((r) => r.body.metric === 'cpu_pct' && r.body.status === status); + + // fireProbe adds a rule identical to the main one on its own webhook path + // and waits for it to fire. + async function fireProbe(path, refresher) { + const whId = await createWebhook({ name: `E2E ${path}`, type: 'custom', url: `${receiver.url}${path}` }); + webhookIds.push(whId); + const id = await createRule({ app_id: appId, metric: 'cpu_pct', threshold: 80, duration_sec: DURATION_SEC, webhook_id: whId }); + ruleIds.push(id); + await receiver.waitFor((r) => r.path === path && r.body.status === 'firing', EVAL_WAIT_MS, refresher.lastError); + return id; } test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect(login.status).toBe(200); receiver = await startWebhookReceiver(); + appId = getAppId(APP_SLUG); - // Record current app status so we can restore it after tests. - const appRes = await apiRequest('GET', `/api/apps/${APP_SLUG}`); - nginxWasRunning = appRes.ok && (appRes.data.Status || appRes.data.status) === 'running'; - - // Stop the app so the real metric collector does not clobber our - // synthetic data points during the test window. - if (nginxWasRunning) { - await apiRequest('POST', `/api/apps/${APP_SLUG}/stop`); - // give docker a moment to settle - await new Promise((r) => setTimeout(r, 3_000)); - } - + // Stop the app so the real collector does not write points for it. + const stop = await apiRequest('POST', `/api/apps/${APP_SLUG}/stop`); + expect(stop.status).toBe(200); deleteAppMetrics(APP_SLUG); - // Create the shared webhook pointing at our local receiver. - const whRes = await apiRequest('POST', '/api/webhooks', { - name: 'E2E Dispatch Hook', - type: 'custom', - url: receiver.url, - }); - expect(whRes.ok, `webhook create failed: ${JSON.stringify(whRes)}`).toBeTruthy(); - webhookId = whRes.data.id; + webhookId = await createWebhook({ name: 'E2E Dispatch Hook', type: 'custom', url: receiver.url }); + webhookIds.push(webhookId); }); test.afterAll(async () => { - // Best-effort cleanup; swallow errors so cleanup specs still run. + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); try { - for (const id of backupRuleIds) { - await apiRequest('DELETE', `/api/alerts/rules/${id}`); - } - if (ruleId) { - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - } - if (webhookId) { - await apiRequest('DELETE', `/api/webhooks/${webhookId}`); - } - for (const id of backupConfigIds) { - await apiRequest('DELETE', `/api/backups/configs/${id}`); - } - // Clear synthetic metrics + alert history rows for our rule. + for (const id of [ruleId, ...ruleIds].filter(Boolean)) await deleteRule(id); + for (const id of webhookIds) await apiRequest('DELETE', `/api/webhooks/${id}`); + for (const id of backupConfigIds) await apiRequest('DELETE', `/api/backups/configs/${id}`); deleteAppMetrics(APP_SLUG); - if (ruleId) { - sqliteExec(`DELETE FROM alert_history WHERE rule_id=${ruleId};`); - } - for (const id of backupRuleIds) { - sqliteExec(`DELETE FROM alert_history WHERE rule_id=${id};`); - } - } catch {} - - if (receiver) await receiver.stop(); - - // Restore original app state so downstream tests (14-19) see it running. - if (nginxWasRunning) { - try { await apiRequest('POST', `/api/apps/${APP_SLUG}/start`); } catch {} + await receiver.stop(); + } finally { + const start = await apiRequest('POST', `/api/apps/${APP_SLUG}/start`); + expect(start.status).toBe(200); + await waitForAppStatus(APP_SLUG, 'running', 60_000); } }); - test('CPU threshold breach fires webhook', async () => { - test.setTimeout(120_000); - - const appId = getAppId(APP_SLUG); - - // Create the alert rule. - const ruleRes = await apiRequest('POST', '/api/alerts/rules', { - app_id: appId, - metric: 'cpu_pct', - operator: '>', - threshold: 80, - duration_sec: DURATION_SEC, - webhook_id: webhookId, - enabled: true, - }); - expect(ruleRes.ok, `rule create failed: ${JSON.stringify(ruleRes)}`).toBeTruthy(); - ruleId = ruleRes.data.id; + test('CPU threshold breach fires webhook once while it persists', async () => { + ruleId = await createRule({ app_id: appId, metric: 'cpu_pct', threshold: 80, duration_sec: DURATION_SEC, webhook_id: webhookId }); receiver.clear(); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - + const refresher = startMetricRefresher(APP_SLUG, { high: true }); try { const hit = await receiver.waitFor( - (r) => r.body && r.body.status === 'firing' && r.body.metric === 'cpu_pct', + (r) => r.path === '/' && r.body.status === 'firing' && r.body.metric === 'cpu_pct', EVAL_WAIT_MS, + refresher.lastError, ); - expect(hit.body.status).toBe('firing'); - expect(hit.body.metric).toBe('cpu_pct'); - expect(hit.body.app).toBe(APP_SLUG); - expect(Number(hit.body.value)).toBeGreaterThanOrEqual(80); + expect(Object.keys(hit.body).sort()).toEqual(['app', 'metric', 'status', 'threshold', 'value']); + expect(hit.body).toMatchObject({ app: APP_SLUG, metric: 'cpu_pct', status: 'firing', threshold: 80 }); + expect(hit.body.value).toBeGreaterThanOrEqual(80); + + // The history row is written right after the webhook is sent. + await expect + .poll(() => sqliteQuery(`SELECT id FROM alert_history WHERE rule_id=${ruleId} AND resolved_at IS NULL;`).length) + .toBe(1); + + // Two later ticks (one per probe) must not fire the main rule again. + await fireProbe('/probe1', refresher); + await fireProbe('/probe2', refresher); + expect(cpuEvents('/', 'firing'), 'main rule fired again while still firing').toHaveLength(1); } finally { - stopRefresh(); + refresher.stop(); } - - // Verify alert_history has a firing row (resolved_at NULL). - const hist = sqliteQuery( - `SELECT id, rule_id, resolved_at FROM alert_history WHERE rule_id=${ruleId};`, - ); - expect(hist.length).toBeGreaterThan(0); - const open = hist.find((h) => h.resolved_at === null); - expect(open, 'expected an unresolved alert history row').toBeTruthy(); + for (const id of ruleIds.splice(0)) await deleteRule(id); }); test('alert resolves when metrics drop below threshold', async () => { - test.setTimeout(120_000); - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, false); - + const refresher = startMetricRefresher(APP_SLUG, { high: false }); try { const hit = await receiver.waitFor( - (r) => r.body && r.body.status === 'resolved' && r.body.metric === 'cpu_pct', + (r) => r.path === '/' && r.body.status === 'resolved' && r.body.metric === 'cpu_pct', EVAL_WAIT_MS, + refresher.lastError, ); - expect(hit.body.status).toBe('resolved'); + expect(hit.body).toMatchObject({ app: APP_SLUG, metric: 'cpu_pct', status: 'resolved' }); } finally { - stopRefresh(); + refresher.stop(); } - const hist = sqliteQuery( - `SELECT id, resolved_at FROM alert_history WHERE rule_id=${ruleId};`, - ); - const resolved = hist.find((h) => h.resolved_at !== null); - expect(resolved, 'expected at least one resolved alert history row').toBeTruthy(); - }); - - test('no double-fire while condition persists', async () => { - test.setTimeout(120_000); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - // Wait for the (re-)fire. - await receiver.waitFor( - (r) => r.body && r.body.status === 'firing' && r.body.metric === 'cpu_pct', - EVAL_WAIT_MS, - ); - - // Keep the breach active through a few evaluator cycles and count fires. - // With the e2e 2s evaluator tick, 15s is plenty to prove "no re-fire". - await new Promise((r) => setTimeout(r, 15_000)); - // A second "firing" is only a double-fire if no "resolved" came in - // between (the real collector can briefly report low CPU on a busy - // runner, which legitimately resolves and re-fires the alert). - const events = receiver.received.filter( - (r) => r.body && r.body.metric === 'cpu_pct' && (r.body.status === 'firing' || r.body.status === 'resolved'), - ); - const seq = events.map((e) => `${e.body.status}@${e.at}`).join(','); - let doubleFires = 0; - for (let i = 1; i < events.length; i++) { - if (events[i].body.status === 'firing' && events[i - 1].body.status === 'firing') doubleFires++; - } - expect(doubleFires, `fired again without resolving: ${seq}`).toBe(0); - expect(events.some((e) => e.body.status === 'firing'), `no firing event: ${seq}`).toBe(true); - } finally { - stopRefresh(); - } + const hist = sqliteQuery(`SELECT resolved_at FROM alert_history WHERE rule_id=${ruleId};`); + expect(hist).toHaveLength(1); + expect(hist[0].resolved_at).not.toBeNull(); }); test('disabled rule does not fire', async () => { - test.setTimeout(180_000); - - // Resolve first so a future enable+breach would be a fresh fire. - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - await receiver.waitFor( - (r) => r.body && r.body.status === 'resolved' && r.body.metric === 'cpu_pct', - EVAL_WAIT_MS, - ).catch(() => {}); // may already be resolved - stopLow(); - - // Disable the rule. const upd = await apiRequest('PUT', `/api/alerts/rules/${ruleId}`, { - app_id: getAppId(APP_SLUG), + app_id: appId, metric: 'cpu_pct', operator: '>', threshold: 80, @@ -354,111 +256,60 @@ test.describe('Alerts - Functional Dispatch', () => { webhook_id: webhookId, enabled: false, }); - expect(upd.ok, `rule update failed: ${JSON.stringify(upd)}`).toBeTruthy(); + expect(upd.status).toBe(200); + expect((await listRules()).find((r) => r.id === ruleId).enabled).toBe(false); + const open = sqliteQuery(`SELECT id FROM alert_history WHERE rule_id=${ruleId} AND resolved_at IS NULL;`); + expect(open, 'main rule must have no open alert, or a non-fire proves nothing').toEqual([]); receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - + const refresher = startMetricRefresher(APP_SLUG, { high: true }); try { - // e2e evaluator tick is 2s; 15s is ~7 ticks, enough to prove no fire. - await new Promise((r) => setTimeout(r, 15_000)); - const cpuHits = receiver.received.filter( - (r) => r.body && r.body.metric === 'cpu_pct', - ); - expect(cpuHits.length, `expected 0 cpu webhook calls, got ${cpuHits.length}`).toBe(0); + // The control rule (higher id) firing means the disabled rule's turn + // in that tick has passed. + await fireProbe('/control', refresher); + expect(cpuEvents('/', 'firing'), 'disabled rule fired').toEqual([]); } finally { - stopRefresh(); + refresher.stop(); } + for (const id of ruleIds.splice(0)) await deleteRule(id); }); - test('backup_failed alert fires when backup run fails', async () => { - test.setTimeout(120_000); - - const nginxAppId = getAppId('e2e-nginx'); + test('backup_failed alert fires for the failing app only', async () => { + // A failed-backup rule scoped to another app, created first so its turn + // comes before the nginx rule's. + const otherHook = await createWebhook({ name: 'E2E Other App Hook', type: 'custom', url: `${receiver.url}/other` }); + webhookIds.push(otherHook); + ruleIds.push(await createRule({ app_id: getAppId('e2e-postgres'), metric: 'backup_failed', threshold: 0, duration_sec: 0, webhook_id: otherHook })); + ruleIds.push(await createRule({ app_id: appId, metric: 'backup_failed', threshold: 0, duration_sec: 0, webhook_id: webhookId })); - const ruleRes = await apiRequest('POST', '/api/alerts/rules', { - app_id: nginxAppId, - metric: 'backup_failed', - operator: '>', - threshold: 0, - duration_sec: 0, - webhook_id: webhookId, - enabled: true, - }); - expect(ruleRes.ok, `backup rule create failed: ${JSON.stringify(ruleRes)}`).toBeTruthy(); - backupRuleIds.push(ruleRes.data.id); - - // Postgres strategy against nginx app: pg_dump binary doesn't exist in the - // nginx container, so the docker exec reliably returns non-zero. - const createRes = await apiRequest('POST', '/api/apps/e2e-nginx/backups/configs', { - strategy: 'postgres', - target: 'local', - schedule_cron: '', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - }); - expect(createRes.ok, `bad backup config create failed: ${JSON.stringify(createRes)}`).toBeTruthy(); - const cfgId = createRes.data.id; + // nginx is stopped, so the dump's docker exec fails. + const cfgId = await createBackupConfig(APP_SLUG, { strategy: 'postgres', schedule_cron: '' }); backupConfigIds.push(cfgId); receiver.clear(); - const trig = await apiRequest('POST', `/api/backups/configs/${cfgId}/run`); - expect([200, 202]).toContain(trig.status); - - const hit = await receiver.waitFor( - (r) => r.body && r.body.metric === 'backup_failed', - 60_000, - ); - expect(hit.body.metric).toBe('backup_failed'); - expect(hit.body.status).toBe('firing'); - }); + expect(trig.status).toBe(202); - test('backup_success alert fires when backup run succeeds', async () => { - test.setTimeout(120_000); + const hit = await receiver.waitFor((r) => r.path === '/' && r.body.metric === 'backup_failed', 60_000); + expect(hit.body).toMatchObject({ app: APP_SLUG, metric: 'backup_failed', status: 'firing' }); + expect(receiver.onPath('/other'), 'rule scoped to e2e-postgres fired for e2e-nginx').toEqual([]); + }); - const pgAppId = getAppId('e2e-postgres'); + test('backup_success alert names the app and says it succeeded', async () => { + const slackHook = await createWebhook({ name: 'E2E Slack Hook', type: 'slack', url: `${receiver.url}/slack` }); + webhookIds.push(slackHook); + ruleIds.push(await createRule({ app_id: getAppId('e2e-postgres'), metric: 'backup_success', threshold: 0, duration_sec: 0, webhook_id: slackHook })); - const ruleRes = await apiRequest('POST', '/api/alerts/rules', { - app_id: pgAppId, - metric: 'backup_success', - operator: '>', - threshold: 0, - duration_sec: 0, - webhook_id: webhookId, - enabled: true, - }); - expect(ruleRes.ok, `backup_success rule create failed: ${JSON.stringify(ruleRes)}`).toBeTruthy(); - backupRuleIds.push(ruleRes.data.id); - - const createRes = await apiRequest('POST', '/api/apps/e2e-postgres/backups/configs', { - strategy: 'postgres', - target: 'local', - schedule_cron: '', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - }); - expect(createRes.ok, `backup config create failed: ${JSON.stringify(createRes)}`).toBeTruthy(); - const cfgId = createRes.data.id; + const cfgId = await createBackupConfig('e2e-postgres', { strategy: 'postgres', schedule_cron: '', retention_count: 3 }); backupConfigIds.push(cfgId); receiver.clear(); - const trig = await apiRequest('POST', `/api/backups/configs/${cfgId}/run`); - expect([200, 202]).toContain(trig.status); - - const hit = await receiver.waitFor( - (r) => r.body && r.body.metric === 'backup_success', - 90_000, - ); - expect(hit.body.metric).toBe('backup_success'); - expect(hit.body.status).toBe('firing'); - }); + expect(trig.status).toBe(202); - // Dispatcher sends once with no retry (internal/alerts/webhook.go Send: - // single http.Client.Do, returns error on status >= 400). Nothing to assert. - test.skip('webhook retry behavior', async () => {}); + const hit = await receiver.waitFor((r) => r.path === '/slack', 90_000); + expect(hit.body.text).toContain('[firing] e2e-postgres'); + expect(hit.body.text).toContain('Backup Succeeded'); + expect(hit.body.text).not.toMatch(/failed/i); + }); }); diff --git a/e2e/tests/13b-webhook-formats.spec.js b/e2e/tests/13b-webhook-formats.spec.js deleted file mode 100644 index 20c16acc..00000000 --- a/e2e/tests/13b-webhook-formats.spec.js +++ /dev/null @@ -1,445 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest } from '../helpers/api.js'; -import { startWebhookReceiver } from '../helpers/webhook.js'; -import { - sqliteExec, - injectHighCPUWindow, - insertMetricPoint, - getAppId, - replaceMetricsAtomic, -} from '../helpers/db.js'; - -// ----------------------------------------------------------------------------- -// Webhook payload-format tests: verify the dispatcher produces the correct -// JSON body shape for each integration type (slack/telegram/discord/custom) -// plus a template_override sanity check, by injecting synthetic CPU metrics -// and inspecting the bytes received at a local HTTP listener. -// ----------------------------------------------------------------------------- - -test.describe('Alerts - Webhook Payload Formats', () => { - test.describe.configure({ mode: 'serial' }); - - // e2e server runs the evaluator every 2s (SIMPLEDEPLOY_ALERT_EVAL_INTERVAL). - // A handful of cycles is plenty; 20s is a generous ceiling. - const EVAL_WAIT_MS = 20_000; - const APP_SLUG = 'e2e-nginx'; - const DURATION_SEC = 60; - const THRESHOLD = 80; - const INJECTED_CPU = 95; - - let receiver; - let nginxWasRunning = false; - const createdWebhookIds = []; - const createdRuleIds = []; - - function deleteAppMetrics(slug) { - sqliteExec( - `DELETE FROM metrics WHERE app_id = (SELECT id FROM apps WHERE slug='${slug}');`, - ); - } - - function injectLowCPUWindow(slug, cpuPct = 5) { - const now = Math.floor(Date.now() / 1000); - for (let i = 0; i < 10; i++) { - insertMetricPoint({ - appSlug: slug, - cpu: cpuPct, - memoryMb: 50, - tsSec: now - i * 5, - }); - } - } - - // Continuously re-inject high/low CPU points so the evaluator always sees a - // fresh breach window. Atomic DELETE+INSERT so the faster e2e evaluator - // tick (2s) can't see an empty window and flip firing->resolve->refire. - function startMetricRefresher(slug, high) { - let stopped = false; - const tick = () => { - if (stopped) return; - try { - replaceMetricsAtomic(slug, { - cpu: high ? INJECTED_CPU : 5, - memoryMb: high ? 100 : 50, - }); - } catch (_) { /* transient lock errors ignored */ } - }; - tick(); - const handle = setInterval(tick, 5_000); - return () => { stopped = true; clearInterval(handle); }; - } - - async function createWebhook(body) { - const res = await apiRequest('POST', '/api/webhooks', body); - expect(res.ok, `webhook create failed: ${JSON.stringify(res)}`).toBeTruthy(); - createdWebhookIds.push(res.data.id); - return res.data.id; - } - - async function createRule(webhookId) { - const appId = getAppId(APP_SLUG); - const res = await apiRequest('POST', '/api/alerts/rules', { - app_id: appId, - metric: 'cpu_pct', - operator: '>', - threshold: THRESHOLD, - duration_sec: DURATION_SEC, - webhook_id: webhookId, - enabled: true, - }); - expect(res.ok, `rule create failed: ${JSON.stringify(res)}`).toBeTruthy(); - createdRuleIds.push(res.data.id); - return res.data.id; - } - - // Wait for a firing payload, then resolve it so a later test for the same - // rule starts from a clean (non-firing) state. - async function waitForFiring(predicate = () => true) { - return receiver.waitFor( - (r) => r && r.body && predicate(r), - EVAL_WAIT_MS, - ); - } - - test.beforeAll(async () => { - await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - receiver = await startWebhookReceiver(); - - // Stop the nginx app so the real collector doesn't clobber injected points. - const appRes = await apiRequest('GET', `/api/apps/${APP_SLUG}`); - nginxWasRunning = appRes.ok && (appRes.data.Status || appRes.data.status) === 'running'; - if (nginxWasRunning) { - await apiRequest('POST', `/api/apps/${APP_SLUG}/stop`); - await new Promise((r) => setTimeout(r, 3_000)); - } - deleteAppMetrics(APP_SLUG); - }); - - test.afterAll(async () => { - try { - for (const id of createdRuleIds) { - await apiRequest('DELETE', `/api/alerts/rules/${id}`); - sqliteExec(`DELETE FROM alert_history WHERE rule_id=${id};`); - } - for (const id of createdWebhookIds) { - await apiRequest('DELETE', `/api/webhooks/${id}`); - } - deleteAppMetrics(APP_SLUG); - } catch (_) { /* best-effort */ } - - if (receiver) await receiver.stop(); - - if (nginxWasRunning) { - try { await apiRequest('POST', `/api/apps/${APP_SLUG}/start`); } catch (_) {} - } - }); - - // ---- Slack -------------------------------------------------------------- - test('slack webhook payload shape', async () => { - test.setTimeout(120_000); - - const whId = await createWebhook({ - name: 'E2E Slack Hook', - type: 'slack', - url: receiver.url, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - const hit = await waitForFiring((r) => r.body && typeof r.body.text === 'string' && r.body.text.includes('[firing]')); - - // Headers + JSON parse already done by receiver. - expect(hit.headers['content-type']).toMatch(/application\/json/i); - - // Schema: exactly { text }. - expect(Object.keys(hit.body).sort()).toEqual(['text']); - expect(typeof hit.body.text).toBe('string'); - - // Content: "[firing] - CPU > 80.0% (current: %)" - const text = hit.body.text; - expect(text).toMatch(/^\[firing\]/); - expect(text).toContain(APP_SLUG); - expect(text).toContain('CPU'); - expect(text).toContain('>'); - expect(text).toContain('80.0%'); - const m = text.match(/current:\s*([\d.]+)%/); - expect(m, `expected "current: N%" in ${text}`).toBeTruthy(); - const current = Number(m[1]); - expect(current).toBeGreaterThanOrEqual(THRESHOLD); - expect(current).toBeLessThanOrEqual(100); - } finally { - stopRefresh(); - } - - // Resolve to clean up for next test. - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - try { - await receiver.waitFor( - (r) => r.body && r.body.text && r.body.text.includes('[resolved]'), - EVAL_WAIT_MS, - ); - } finally { - stopLow(); - } - // Delete the rule so its history row doesn't interfere with later rules. - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); - - // ---- Slack: firing -> resolved transition ------------------------------ - test('slack payload flips status firing -> resolved', async () => { - test.setTimeout(180_000); - - const whId = await createWebhook({ - name: 'E2E Slack Transition Hook', - type: 'slack', - url: receiver.url, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - - // Phase 1: drive firing. - let stopRefresh = startMetricRefresher(APP_SLUG, true); - try { - const firing = await waitForFiring( - (r) => r.body && r.body.text && r.body.text.startsWith('[firing]'), - ); - expect(firing.body.text).toMatch(/^\[firing\]/); - } finally { - stopRefresh(); - } - - // Phase 2: drop metrics below threshold, expect resolved payload. - deleteAppMetrics(APP_SLUG); - stopRefresh = startMetricRefresher(APP_SLUG, false); - try { - const resolved = await receiver.waitFor( - (r) => r.body && r.body.text && r.body.text.startsWith('[resolved]'), - EVAL_WAIT_MS, - ); - // Still exactly { text }, just with resolved status. - expect(Object.keys(resolved.body).sort()).toEqual(['text']); - expect(resolved.body.text).toContain(APP_SLUG); - expect(resolved.body.text).toContain('CPU'); - } finally { - stopRefresh(); - } - - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); - - // ---- Telegram ----------------------------------------------------------- - test('telegram webhook payload shape', async () => { - test.setTimeout(120_000); - - const whId = await createWebhook({ - name: 'E2E Telegram Hook', - type: 'telegram', - url: receiver.url, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - const hit = await waitForFiring( - (r) => r.body && r.body.parse_mode === 'HTML' && typeof r.body.text === 'string', - ); - - expect(hit.headers['content-type']).toMatch(/application\/json/i); - - // Schema: exactly { text, parse_mode }. - expect(Object.keys(hit.body).sort()).toEqual(['parse_mode', 'text']); - expect(hit.body.parse_mode).toBe('HTML'); - expect(typeof hit.body.text).toBe('string'); - - // Content: "[firing] \nCPU > 80.0% (current: N%)" - const text = hit.body.text; - expect(text).toMatch(/^\[firing\]/); - expect(text).toContain(APP_SLUG); - expect(text).toContain('\n'); - expect(text).toContain('CPU'); - expect(text).toContain('> 80.0%'); - expect(text).toMatch(/current:\s*[\d.]+%/); - } finally { - stopRefresh(); - } - - // Resolve before next test. - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - try { - await receiver.waitFor( - (r) => r.body && r.body.text && r.body.text.startsWith('[resolved]'), - EVAL_WAIT_MS, - ); - } finally { - stopLow(); - } - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); - - // ---- Discord ------------------------------------------------------------ - test('discord webhook payload shape', async () => { - test.setTimeout(120_000); - - const whId = await createWebhook({ - name: 'E2E Discord Hook', - type: 'discord', - url: receiver.url, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - const hit = await waitForFiring( - (r) => r.body && typeof r.body.content === 'string' && r.body.content.startsWith('[firing]'), - ); - - expect(hit.headers['content-type']).toMatch(/application\/json/i); - - // Schema: exactly { content }. - expect(Object.keys(hit.body).sort()).toEqual(['content']); - expect(typeof hit.body.content).toBe('string'); - - const content = hit.body.content; - expect(content).toMatch(/^\[firing\]/); - expect(content).toContain(APP_SLUG); - expect(content).toContain('CPU'); - expect(content).toContain('> 80.0%'); - expect(content).toMatch(/current:\s*[\d.]+%/); - } finally { - stopRefresh(); - } - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - try { - await receiver.waitFor( - (r) => r.body && r.body.content && r.body.content.startsWith('[resolved]'), - EVAL_WAIT_MS, - ); - } finally { - stopLow(); - } - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); - - // ---- Custom (default template) ------------------------------------------ - test('custom webhook default payload shape', async () => { - test.setTimeout(120_000); - - const whId = await createWebhook({ - name: 'E2E Custom Hook', - type: 'custom', - url: receiver.url, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - const hit = await waitForFiring( - (r) => r.body && r.body.status === 'firing' && r.body.metric === 'cpu_pct', - ); - - expect(hit.headers['content-type']).toMatch(/application\/json/i); - - // Schema: exactly { app, metric, value, threshold, status }. - expect(Object.keys(hit.body).sort()).toEqual( - ['app', 'metric', 'status', 'threshold', 'value'], - ); - expect(hit.body.app).toBe(APP_SLUG); - expect(hit.body.metric).toBe('cpu_pct'); - expect(hit.body.status).toBe('firing'); - expect(typeof hit.body.value).toBe('number'); - expect(typeof hit.body.threshold).toBe('number'); - expect(hit.body.threshold).toBeCloseTo(THRESHOLD, 2); - expect(hit.body.value).toBeGreaterThanOrEqual(THRESHOLD); - expect(hit.body.value).toBeLessThanOrEqual(100); - } finally { - stopRefresh(); - } - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - try { - await receiver.waitFor( - (r) => r.body && r.body.status === 'resolved' && r.body.metric === 'cpu_pct', - EVAL_WAIT_MS, - ); - } finally { - stopLow(); - } - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); - - // ---- template_override -------------------------------------------------- - test('template_override replaces default payload', async () => { - test.setTimeout(120_000); - - const override = '{"custom":"tpl","metric":"{{.Metric}}","value":{{.Value}}}'; - const whId = await createWebhook({ - name: 'E2E Override Hook', - type: 'custom', - url: receiver.url, - template_override: override, - }); - const ruleId = await createRule(whId); - - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopRefresh = startMetricRefresher(APP_SLUG, true); - - try { - const hit = await waitForFiring( - (r) => r.body && r.body.custom === 'tpl' && r.body.metric === 'cpu_pct', - ); - - expect(hit.headers['content-type']).toMatch(/application\/json/i); - - // Only the three override fields should be present. The default - // custom fields (app, threshold, status) must NOT appear. - expect(Object.keys(hit.body).sort()).toEqual(['custom', 'metric', 'value']); - expect(hit.body.custom).toBe('tpl'); - expect(hit.body.metric).toBe('cpu_pct'); - expect(typeof hit.body.value).toBe('number'); - expect(hit.body.value).toBeGreaterThanOrEqual(THRESHOLD); - } finally { - stopRefresh(); - } - - // Resolve. - receiver.clear(); - deleteAppMetrics(APP_SLUG); - const stopLow = startMetricRefresher(APP_SLUG, false); - try { - // Resolved renders with the override too; just wait a full cycle and move on. - await receiver.waitFor( - (r) => r.body && r.body.custom === 'tpl', - EVAL_WAIT_MS, - ).catch(() => { /* override doesn't surface status; best-effort */ }); - } finally { - stopLow(); - } - await apiRequest('DELETE', `/api/alerts/rules/${ruleId}`); - }); -}); diff --git a/e2e/tests/27-backup-s3.spec.js b/e2e/tests/27-backup-s3.spec.js index 4f96eff9..57a382ba 100644 --- a/e2e/tests/27-backup-s3.spec.js +++ b/e2e/tests/27-backup-s3.spec.js @@ -1,14 +1,20 @@ import { test, expect } from '@playwright/test'; -import { createGunzip } from 'zlib'; -import { Readable } from 'stream'; -import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; -import { apiLogin, apiRequest, apiDownload, getSessionCookie } from '../helpers/api.js'; +import { createHash } from 'crypto'; +import { getState, TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest, getSessionCookie } from '../helpers/api.js'; import { findServiceContainer, psql } from '../helpers/docker.js'; import { startMinIO, dockerAvailable } from '../helpers/minio.js'; - -// Verifies simpledeploy's S3 backup target end-to-end against a MinIO fixture. -// Covers: create config with encrypted S3 target, backup roundtrip (backup → -// drop → restore), gzipped SQL payload validation, pre-signed download URL, +import { + createBackupConfig, + deleteAppConfigsFor, + readOr, + runsForConfig, + triggerAndWait, + waitForNextSecond, +} from '../helpers/backups.js'; + +// Verifies simpledeploy's S3 backup target end-to-end against a MinIO fixture: +// encrypted target config, backup -> drop -> restore, pre-signed download, // retention pruning of S3 objects, and failed-auth error reporting. const POSTGRES_SLUG = 'e2e-postgres'; @@ -29,96 +35,36 @@ function s3TargetJSON({ endpoint, bucket, accessKey, secretKey, region = 'us-eas }); } -async function pollRunStatus(slug, runId, desiredStatuses, timeoutMs) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const run = res.data.find((r) => r.id === runId); - if (run) { - last = run; - if (desiredStatuses.includes(run.status)) return run; - } - } - await new Promise((r) => setTimeout(r, 1_000)); - } - throw new Error(`run ${runId} did not reach ${desiredStatuses.join('|')} in ${timeoutMs}ms (last=${JSON.stringify(last)})`); -} - -async function waitForNewRunForConfig(slug, configId, sinceCount, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const forCfg = res.data.filter((r) => r.backup_config_id === configId); - if (forCfg.length > sinceCount) { - forCfg.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))); - return forCfg[0]; - } - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); -} - -async function triggerAndWait(slug, configId, { timeoutMs = 180_000, allowFail = true } = {}) { - const before = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - const beforeCount = Array.isArray(before.data) - ? before.data.filter((r) => r.backup_config_id === configId).length - : 0; - const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); - expect([200, 202]).toContain(trig.status); - const newest = await waitForNewRunForConfig(slug, configId, beforeCount, 15_000); - const desired = allowFail ? ['success', 'failed'] : ['success']; - return await pollRunStatus(slug, newest.id, desired, timeoutMs); -} - -async function deleteAppConfigsFor(slug) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); - if (!res.ok || !Array.isArray(res.data)) return; - for (const c of res.data) { - await apiRequest('DELETE', `/api/backups/configs/${c.id}`); - } -} - -async function streamToBuffer(stream) { - const chunks = []; - for await (const chunk of stream) chunks.push(chunk); - return Buffer.concat(chunks); -} - -async function gunzipBuffer(buf) { - const gz = createGunzip(); - const src = Readable.from(buf); - src.pipe(gz); - return await streamToBuffer(gz); -} +const basename = (key) => String(key).split('/').pop(); test.describe('Backups - S3 target (MinIO fixture)', () => { test.describe.configure({ mode: 'serial' }); - // Skip entire suite if docker is unavailable. test.skip(() => !dockerAvailable(), 'docker not available'); let minio = null; let pgContainer = null; + let roundtripRun = null; + + const s3Config = (prefix) => + s3TargetJSON({ + endpoint: minio.endpoint, + bucket: BUCKET, + accessKey: minio.accessKey, + secretKey: minio.secretKey, + prefix, + }); test.beforeAll(async () => { test.setTimeout(180_000); const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(login.ok).toBeTruthy(); + expect(login.status).toBe(200); pgContainer = findServiceContainer(POSTGRES_SLUG, 'db'); expect(pgContainer, 'e2e-postgres db container must be running').toBeTruthy(); - // Start MinIO and create bucket. simpledeploy is running on the host so - // it reaches MinIO at http://localhost:; no container networking - // gymnastics needed for the server. The `mc` subprocess runs inside its - // own container and uses host.docker.internal. + // simpledeploy runs on the host and reaches MinIO at localhost:. minio = await startMinIO({ bucket: BUCKET }); - - // Clean any configs from the earlier backups spec. await deleteAppConfigsFor(POSTGRES_SLUG); }); @@ -126,258 +72,110 @@ test.describe('Backups - S3 target (MinIO fixture)', () => { try { await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); await deleteAppConfigsFor(POSTGRES_SLUG); - } catch {} - if (minio) { - try { minio.stop(); } catch {} + } finally { + if (minio) minio.stop(); } }); test('1. S3 postgres backup roundtrip: seed, backup, drop, restore, verify', async () => { - test.setTimeout(300_000); - - // Seed data + const SELECT_SQL = "SELECT id||'|'||v FROM e2e_s3 WHERE id=1;"; psql(pgContainer, 'postgres', 'testdb', "CREATE TABLE IF NOT EXISTS e2e_s3(id INT, v TEXT); DELETE FROM e2e_s3; INSERT INTO e2e_s3 VALUES (1, 's3-roundtrip-xyz');"); - expect(psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_s3 WHERE id=1;")) - .toContain('1|s3-roundtrip-xyz'); + expect(psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)).toBe('1|s3-roundtrip-xyz'); - const targetJSON = s3TargetJSON({ - endpoint: minio.endpoint, - bucket: BUCKET, - accessKey: minio.accessKey, - secretKey: minio.secretKey, - prefix: 'roundtrip', - }); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { - strategy: 'postgres', - target: 's3', - schedule_cron: '0 0 1 1 *', - target_config_json: targetJSON, - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', - }); - expect(createRes.ok, `create config: ${JSON.stringify(createRes.data)}`).toBeTruthy(); - const cfgId = createRes.data.id; - expect(cfgId).toBeGreaterThan(0); + const targetJSON = s3Config('roundtrip'); + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres', target: 's3', target_config_json: targetJSON }); - // Server stores the target_config_json encrypted; GET should return - // the encrypted blob (not the plaintext we sent). + // The target config (with the secret key) is stored encrypted. const getRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/configs`); - const stored = Array.isArray(getRes.data) ? getRes.data.find((c) => c.id === cfgId) : null; - expect(stored).toBeTruthy(); + const stored = getRes.data.find((c) => c.id === cfgId); expect(stored.target_config_json).toBeTruthy(); expect(stored.target_config_json).not.toBe(targetJSON); + expect(stored.target_config_json).not.toContain(minio.secretKey); - // Trigger backup and wait for success - const run = await triggerAndWait(POSTGRES_SLUG, cfgId, { timeoutMs: 180_000, allowFail: false }); - expect(run.status).toBe('success'); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); expect(run.size_bytes).toBeGreaterThan(0); expect(run.checksum).toBeTruthy(); - expect(run.file_path).toBeTruthy(); - // For S3 target, file_path is the object key (may include prefix). - expect(run.file_path).toMatch(/roundtrip\//); + expect(run.file_path).toMatch(/^roundtrip\/.+\.sql\.gz$/); + expect(minio.listObjects('roundtrip').map((o) => o.key)).toEqual([basename(run.file_path)]); + roundtripRun = run; - // Drop data, restore, verify psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_s3;'); const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); expect(restoreRes.status).toBe(202); - - const deadline = Date.now() + 120_000; - let restored = ''; - while (Date.now() < deadline) { - try { - restored = psql(pgContainer, 'postgres', 'testdb', "SELECT id||'|'||v FROM e2e_s3 WHERE id=1;"); - if (restored.includes('s3-roundtrip-xyz')) break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(restored).toContain('1|s3-roundtrip-xyz'); - - // Save for next test - test.info().annotations.push({ type: 'configId', description: String(cfgId) }); + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 120_000 }) + .toBe('1|s3-roundtrip-xyz'); }); - test('2. S3 upload is a valid gzipped SQL dump containing expected markers', async () => { - test.setTimeout(60_000); - - // List objects in the bucket under the roundtrip prefix - const objs = minio.listObjects('roundtrip'); - expect(objs.length, `expected at least one object, got ${JSON.stringify(objs)}`).toBeGreaterThanOrEqual(1); - - // Pick an object key ending in .sql.gz (postgres strategy) - const sqlGz = objs.find((o) => String(o.key).endsWith('.sql.gz')) || objs[0]; - expect(sqlGz).toBeTruthy(); - - // Download via mc cat to stdout, capture as buffer. - // Use the mc subprocess through our helper; key is relative to bucket. - // mc path: local// - // listObjects returned keys relative to the prefix scope; build a full path. - // Safer: just download through the simpledeploy pre-signed URL path in test 3. - // Here, use `mc cat` with full path reconstructed from listObjects call above. - const fullKey = String(sqlGz.key).startsWith('roundtrip/') - ? sqlGz.key - : `roundtrip/${String(sqlGz.key).replace(/^\//, '')}`; - const catOut = minio.mc(['cat', `local/${BUCKET}/${fullKey}`], { encoding: 'buffer' }); - const gzBuf = Buffer.isBuffer(catOut) ? catOut : Buffer.from(catOut, 'binary'); - expect(gzBuf.length).toBeGreaterThan(0); - - // gzip magic bytes 0x1f 0x8b - expect(gzBuf[0]).toBe(0x1f); - expect(gzBuf[1]).toBe(0x8b); - - const plain = await gunzipBuffer(gzBuf); - const text = plain.toString('utf-8'); - // pg_dump output contains schema markers and our table - expect(text).toMatch(/(CREATE TABLE|COPY )/); - expect(text).toContain('e2e_s3'); - }); - - test('3. pre-signed download URL: 307 redirect returns valid gzip', async () => { - test.setTimeout(60_000); - - // Find a successful s3 run - const runsRes = await apiRequest('GET', `/api/apps/${POSTGRES_SLUG}/backups/runs`); - expect(runsRes.ok).toBeTruthy(); - const run = runsRes.data.find((r) => r.status === 'success' && r.file_path && r.file_path.endsWith('.sql.gz')); - expect(run, 'need a successful S3 run to test download').toBeTruthy(); + test('2. download redirects to a pre-signed URL serving the exact backup', async () => { + expect(roundtripRun, 'run from test 1').toBeTruthy(); - // Issue download WITHOUT following redirects so we can assert 307. - const state = getState(); - const cookie = getSessionCookie(); - const res = await fetch(`${state.baseURL}/api/backups/runs/${run.id}/download`, { - method: 'GET', + // Without following redirects, so the 307 is visible. + const res = await fetch(`${getState().baseURL}/api/backups/runs/${roundtripRun.id}/download`, { redirect: 'manual', - headers: cookie ? { Cookie: cookie } : {}, + headers: { Cookie: getSessionCookie() }, }); expect(res.status).toBe(307); const location = res.headers.get('location'); - expect(location).toBeTruthy(); expect(location).toMatch(/^https?:\/\//); - expect(location).toMatch(/X-Amz-Signature|X-Amz-Credential/); + expect(location).toMatch(/X-Amz-Signature/); - // Follow the pre-signed URL directly (no auth cookie needed — it's signed). + // The pre-signed URL needs no session. const dl = await fetch(location); - expect(dl.ok, `presigned fetch failed: ${dl.status}`).toBeTruthy(); + expect(dl.status).toBe(200); const buf = Buffer.from(await dl.arrayBuffer()); - expect(buf.length).toBeGreaterThan(0); - expect(buf[0]).toBe(0x1f); - expect(buf[1]).toBe(0x8b); - // Gunzip should succeed - const plain = await gunzipBuffer(buf); - expect(plain.length).toBeGreaterThan(0); + expect(createHash('sha256').update(buf).digest('hex')).toBe(roundtripRun.checksum.replace(/^sha256:/i, '')); }); - test('4. retention_count=2 prunes old S3 objects', async () => { - test.setTimeout(600_000); - - // Fresh bucket prefix so we can count in isolation + test('3. retention_count=2 prunes the oldest S3 object and its run', async () => { await deleteAppConfigsFor(POSTGRES_SLUG); - const prefix = 'retention'; - const targetJSON = s3TargetJSON({ - endpoint: minio.endpoint, - bucket: BUCKET, - accessKey: minio.accessKey, - secretKey: minio.secretKey, - prefix, - }); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres', target: 's3', - schedule_cron: '0 0 1 1 *', - target_config_json: targetJSON, - retention_mode: 'count', + target_config_json: s3Config(prefix), retention_count: 2, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', }); - expect(createRes.ok).toBeTruthy(); - const cfgId = createRes.data.id; - // Trigger 3 backups sequentially (each pg strategy uses a timestamped filename) + const runs = []; for (let i = 0; i < 3; i++) { - const run = await triggerAndWait(POSTGRES_SLUG, cfgId, { timeoutMs: 180_000, allowFail: false }); - expect(run.status).toBe('success'); - // Ensure distinct timestamps in the filename (YYYYMMDD-HHMMSS granularity). - await new Promise((r) => setTimeout(r, 1500)); - } - - // List objects under this prefix. Retention prunes to retention_count=2. - // Poll briefly in case pruning runs slightly after the run marks success. - let objs = []; - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - objs = minio.listObjects(prefix); - if (objs.length <= 2) break; - await new Promise((r) => setTimeout(r, 1_000)); + if (runs.length) await waitForNextSecond(runs[runs.length - 1].finished_at); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId); + expect(run.status, `run error: ${run.error_msg}`).toBe('success'); + runs.push(run); } - expect(objs.length, `expected <=2 objects, got ${objs.length}: ${JSON.stringify(objs)}`).toBeLessThanOrEqual(2); - expect(objs.length).toBeGreaterThanOrEqual(1); + expect(new Set(runs.map((r) => r.file_path)).size, 'distinct object keys').toBe(3); + + // Pruning runs right after the third run is marked success. + const kept = runs.slice(1); + await expect + .poll(() => minio.listObjects(prefix).map((o) => o.key).sort()) + .toEqual(kept.map((r) => basename(r.file_path)).sort()); + + // A pruned run must not stay listed as a downloadable/restorable backup. + await expect + .poll(async () => (await runsForConfig(POSTGRES_SLUG, cfgId)).map((r) => r.id).sort()) + .toEqual(kept.map((r) => r.id).sort()); }); - test('5. bogus S3 credentials produce a failed run with informative error', async () => { - test.setTimeout(180_000); - + test('4. bogus S3 credentials produce a failed run with informative error', async () => { await deleteAppConfigsFor(POSTGRES_SLUG); - - const targetJSON = s3TargetJSON({ - endpoint: minio.endpoint, - bucket: 'definitely-does-not-exist-e2e', - accessKey: 'bogus-access-key', - secretKey: 'bogus-secret-key-12345', - prefix: 'bogus', - }); - - const createRes = await apiRequest('POST', `/api/apps/${POSTGRES_SLUG}/backups/configs`, { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres', target: 's3', - schedule_cron: '0 0 1 1 *', - target_config_json: targetJSON, - retention_mode: 'count', - retention_count: 5, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', + target_config_json: s3TargetJSON({ + endpoint: minio.endpoint, + bucket: 'definitely-does-not-exist-e2e', + accessKey: 'bogus-access-key', + secretKey: 'bogus-secret-key-12345', + prefix: 'bogus', + }), }); - expect(createRes.ok).toBeTruthy(); - const cfgId = createRes.data.id; - const run = await triggerAndWait(POSTGRES_SLUG, cfgId, { timeoutMs: 120_000, allowFail: true }); + const run = await triggerAndWait(POSTGRES_SLUG, cfgId, 120_000); expect(run.status).toBe('failed'); - expect(run.error_msg || '').toBeTruthy(); - // Should mention s3/bucket/auth/access/credential somewhere - expect(run.error_msg.toLowerCase()).toMatch(/(s3|bucket|access|auth|credential|signature|key|denied)/); - }); - - test('6. UI shows S3 option in backup wizard', async ({ page }) => { - await loginAsAdmin(page); - const state = getState(); - await page.goto(`${state.baseURL}/#/apps/${POSTGRES_SLUG}`); - await page.getByRole('button', { name: /backups/i }).click(); - - const configBtn = page.getByRole('button', { name: /configure backup|add config/i }); - await configBtn.click(); - - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 5_000 }); - - // Advance to step 2 (storage target). Strategy step first. - const pgBtn = dialog.getByText(/postgresql/i).first(); - if (await pgBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - await pgBtn.click(); - } - await dialog.getByRole('button', { name: /next/i }).click(); - - // S3 option should be visible as a storage target. - await expect(dialog.getByText(/s3|object storage/i).first()).toBeVisible({ timeout: 5_000 }); + expect(run.error_msg).toMatch(/InvalidAccessKeyId|SignatureDoesNotMatch|AccessDenied|NoSuchBucket/i); }); }); diff --git a/e2e/tests/28-db-strategies.spec.js b/e2e/tests/28-db-strategies.spec.js index 66b114a2..487f59e4 100644 --- a/e2e/tests/28-db-strategies.spec.js +++ b/e2e/tests/28-db-strategies.spec.js @@ -1,546 +1,155 @@ -// E2E: non-postgres backup strategies (mysql, mongo, redis, sqlite). -// Each strategy runs in its own serial describe that deploys its own fixture app, -// exercises seed -> backup -> drop -> restore -> verify, then tears down. -// -// KNOWN BACKEND BUG (found during test authoring): -// Scheduler.RunBackup / RunRestore build BackupOpts without ever populating -// Credentials. The postgres strategy tolerates this (reads env inside the -// container via `sh -c` script). mysql + mongo do NOT — they only consult -// opts.Credentials for MYSQL_ROOT_PASSWORD / MONGO_INITDB_ROOT_* and will -// therefore fail auth. The tests below will surface this: the backup run -// status will come back "failed" with an auth error. See report. -// -// Fix direction: either -// (a) read container env for MYSQL_ROOT_PASSWORD / MONGO_INITDB_ROOT_* via -// `sh -c` inside the container (like postgres), or -// (b) have Scheduler read env vars from the container (docker inspect) and -// populate opts.Credentials before calling Backup/Restore. +// E2E: non-postgres backup strategies (mysql, mongo, redis, sqlite). One +// beforeAll deploys all four fixture apps concurrently; each test runs +// seed -> backup -> drop -> restore -> verify against its own app. import { test, expect } from '@playwright/test'; -import { readFileSync, existsSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; -import { getState, TEST_ADMIN } from '../helpers/auth.js'; +import { TEST_ADMIN } from '../helpers/auth.js'; +import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; +import { findServiceContainer, waitForHealthy } from '../helpers/docker.js'; +import { mysqlExec, mongoEval, redisCmd, sqlite3Eval } from '../helpers/dbclients.js'; import { - apiLogin, - apiRequest, - waitForAppStatus, -} from '../helpers/api.js'; -import { - findServiceContainer, - waitForContainerState, - waitForHealthy, -} from '../helpers/docker.js'; -import { - mysqlExec, - mongoEval, - redisCmd, - sqlite3Eval, -} from '../helpers/dbclients.js'; + createBackupConfig, + deleteAppConfigsFor, + localBackupExists, + localBackupPath, + readOr, + triggerAndWait, +} from '../helpers/backups.js'; const FIXTURES = join(import.meta.dirname, '..', 'fixtures'); - -function readFixture(name) { - return readFileSync(join(FIXTURES, name), 'utf-8'); -} - -// --- shared helpers (kept local; near-duplicates of 12-backups.spec.js) --- - -async function pollRunStatus(slug, runId, desired, timeoutMs) { - const deadline = Date.now() + timeoutMs; - let last = null; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const run = res.data.find((r) => r.id === runId); - if (run) { - last = run; - const match = Array.isArray(desired) - ? desired.includes(run.status) - : run.status === desired; - if (match) return run; - } - } - await new Promise((r) => setTimeout(r, 1_000)); - } - throw new Error( - `run ${runId} did not reach status ${desired} within ${timeoutMs}ms (last=${JSON.stringify(last)})`, - ); -} - -async function waitForNewRunForConfig(slug, configId, sinceCount, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - if (res.ok && Array.isArray(res.data)) { - const forCfg = res.data.filter((r) => r.backup_config_id === configId); - if (forCfg.length > sinceCount) { - forCfg.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))); - return forCfg[0]; - } - } - await new Promise((r) => setTimeout(r, 500)); - } - throw new Error(`no new run for config ${configId} in ${slug} within ${timeoutMs}ms`); -} - -async function triggerAndWait(slug, configId, timeoutMs = 180_000) { - const before = await apiRequest('GET', `/api/apps/${slug}/backups/runs`); - const beforeCount = Array.isArray(before.data) - ? before.data.filter((r) => r.backup_config_id === configId).length - : 0; - const trig = await apiRequest('POST', `/api/backups/configs/${configId}/run`); - expect([200, 202]).toContain(trig.status); - const newest = await waitForNewRunForConfig(slug, configId, beforeCount, 15_000); - return await pollRunStatus(slug, newest.id, ['success', 'failed'], timeoutMs); -} - -async function deployViaAPI(name, composeYaml) { - const b64 = Buffer.from(composeYaml, 'utf-8').toString('base64'); - const res = await apiRequest('POST', '/api/apps/deploy', { name, compose: b64 }); - expect([200, 202], `deploy ${name}: ${JSON.stringify(res.data)}`).toContain(res.status); +const MYSQL_PW = 'testrootpass123'; +const MONGO_USER = 'root'; +const MONGO_PW = 'testrootpass123'; +const SQLITE_DB = '/data/test.db'; + +// ready is a shell probe run in the container until it exits 0. +const APPS = { + mysql: { + slug: 'e2e-mysql', + fixture: 'compose-mysql.yml', + service: 'db', + // mysqladmin ping can pass before the root password is set; run a query. + ready: `mysql -u root -p${MYSQL_PW} -N -B -e 'SELECT 1'`, + }, + mongo: { + slug: 'e2e-mongo', + fixture: 'compose-mongo.yml', + service: 'db', + ready: `mongosh --quiet --username ${MONGO_USER} --password ${MONGO_PW} --authenticationDatabase admin --eval "db.runCommand({ ping: 1 }).ok"`, + }, + redis: { slug: 'e2e-redis', fixture: 'compose-redis.yml', service: 'db', ready: 'redis-cli PING' }, + sqlite: { slug: 'e2e-sqlite', fixture: 'compose-sqlite.yml', service: 'app', ready: `test -f ${SQLITE_DB}` }, +}; + +async function deployAndWait(app) { + const compose = readFileSync(join(FIXTURES, app.fixture), 'utf-8'); + const res = await apiRequest('POST', '/api/apps/deploy', { + name: app.slug, + compose: Buffer.from(compose, 'utf-8').toString('base64'), + }); + expect(res.status, `deploy ${app.slug}: ${JSON.stringify(res.data)}`).toBe(202); + app.deployed = true; + await waitForAppStatus(app.slug, 'running', 240_000); + app.container = findServiceContainer(app.slug, app.service); + expect(app.container, `${app.slug} ${app.service} container`).toBeTruthy(); + await waitForHealthy(app.container, app.ready, 180_000); } -async function removeApp(slug) { - try { await apiRequest('DELETE', `/api/apps/${slug}`); } catch {} +// backupDropRestore takes a local backup of app with strategy, runs drop() +// to destroy the seeded data, restores the backup and returns once the +// restore request is accepted. +async function backupDropRestore(app, strategy, drop, extra = {}) { + const cfgId = await createBackupConfig(app.slug, { strategy, retention_count: 3, ...extra }); + const run = await triggerAndWait(app.slug, cfgId); + expect(run.status, `${strategy} run error: ${run.error_msg}`).toBe('success'); + expect(run.size_bytes).toBeGreaterThan(0); + expect(run.checksum).toBeTruthy(); + expect(localBackupExists(run), `backup file ${localBackupPath(run)}`).toBe(true); + + drop(); + + const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); + expect(restoreRes.status).toBe(202); } -async function deleteAppConfigsFor(slug) { - const res = await apiRequest('GET', `/api/apps/${slug}/backups/configs`); - if (!res.ok || !Array.isArray(res.data)) return; - for (const c of res.data) { - await apiRequest('DELETE', `/api/backups/configs/${c.id}`); - } -} - -async function loginAPI() { - const r = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); - expect(r.ok).toBeTruthy(); -} - -// Verify a run's file is on disk at /backups/. -function backupFileOnDisk(run) { - if (!run || !run.file_path) return null; - const state = getState(); - const filename = run.file_path.split('/').pop(); - const abs = `${state.dataDir}/backups/${filename}`; - return existsSync(abs) ? abs : null; -} - -// ===================================================================== -// MySQL -// ===================================================================== -test.describe('Backup strategy: MySQL', () => { +test.describe('Backup strategies: MySQL, MongoDB, Redis, SQLite', () => { test.describe.configure({ mode: 'serial' }); - const SLUG = 'e2e-mysql'; - const SERVICE = 'db'; - let container = null; - let configId = null; - test.beforeAll(async () => { - test.setTimeout(300_000); - await loginAPI(); - await deployViaAPI(SLUG, readFixture('compose-mysql.yml')); - await waitForAppStatus(SLUG, 'running', 180_000); - container = findServiceContainer(SLUG, SERVICE); - expect(container, `${SLUG} ${SERVICE} container`).toBeTruthy(); - // Wait until mysql accepts queries with the configured root password. - // mysqladmin ping can succeed before the root password is provisioned, - // so assert we can actually run a query end-to-end. - await waitForHealthy( - container, - `mysql -u root -ptestrootpass123 -N -B -e 'SELECT 1'`, - 120_000, - ); + test.setTimeout(420_000); + const login = await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + expect(login.status).toBe(200); + await Promise.all(Object.values(APPS).map(deployAndWait)); }); test.afterAll(async () => { - try { await loginAPI(); } catch {} - await deleteAppConfigsFor(SLUG); - await removeApp(SLUG); - }); - - test('strategy detection reports mysql service', async () => { - const res = await apiRequest('GET', `/api/apps/${SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const mysql = strategies.find((s) => s.strategy_type === 'mysql'); - expect(mysql, 'mysql strategy missing').toBeTruthy(); - expect(mysql.available).toBe(true); - expect(Array.isArray(mysql.services)).toBe(true); - expect(mysql.services.length).toBeGreaterThan(0); - // Container name must include the simpledeploy- prefix so docker exec can - // reach it; this is what the scheduler uses to locate the container. - expect(mysql.services[0].container_name).toBe(`simpledeploy-${SLUG}-${SERVICE}-1`); - }); - - test('seed -> backup -> drop -> restore -> verify', async () => { - test.setTimeout(300_000); - - // Seed - mysqlExec(container, 'testrootpass123', 'testdb', - "CREATE TABLE IF NOT EXISTS e2e_data(id INT PRIMARY KEY, v VARCHAR(64));"); - mysqlExec(container, 'testrootpass123', 'testdb', - "DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'mysql-marker-abc');"); - const seeded = mysqlExec(container, 'testrootpass123', 'testdb', - "SELECT CONCAT(id,'|',v) FROM e2e_data WHERE id=1;"); - expect(seeded).toContain('1|mysql-marker-abc'); - - // Create backup config - const createRes = await apiRequest('POST', `/api/apps/${SLUG}/backups/configs`, { - strategy: 'mysql', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', - }); - expect(createRes.ok).toBeTruthy(); - configId = createRes.data.id; - - // Trigger and wait. NOTE: known backend bug — Scheduler does not populate - // Credentials, so mysqldump will fail auth. This assertion will catch it - // until the backend is fixed. - const run = await triggerAndWait(SLUG, configId, 180_000); - expect(run.status, `mysql backup run status: ${JSON.stringify(run)}`).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); - - // Backup file on disk - const abs = backupFileOnDisk(run); - expect(abs, 'backup file must exist on disk').toBeTruthy(); - - // Drop and verify gone - mysqlExec(container, 'testrootpass123', 'testdb', 'DROP TABLE e2e_data;'); - let dropped = false; - try { - mysqlExec(container, 'testrootpass123', 'testdb', 'SELECT * FROM e2e_data;'); - } catch { - dropped = true; - } - expect(dropped).toBeTruthy(); - - // Restore - const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); - expect(restoreRes.status).toBe(202); - - // Poll until data returns - const deadline = Date.now() + 90_000; - let got = ''; - while (Date.now() < deadline) { - try { - got = mysqlExec(container, 'testrootpass123', 'testdb', - "SELECT CONCAT(id,'|',v) FROM e2e_data WHERE id=1;"); - if (got.includes('mysql-marker-abc')) break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); + await apiLogin(TEST_ADMIN.username, TEST_ADMIN.password); + for (const app of Object.values(APPS).filter((a) => a.deployed)) { + await deleteAppConfigsFor(app.slug); + const res = await apiRequest('DELETE', `/api/apps/${app.slug}`); + expect(res.status, `remove ${app.slug}`).toBe(200); } - expect(got).toContain('mysql-marker-abc'); }); -}); - -// ===================================================================== -// MongoDB -// ===================================================================== -test.describe('Backup strategy: MongoDB', () => { - test.describe.configure({ mode: 'serial' }); - const SLUG = 'e2e-mongo'; - const SERVICE = 'db'; - const USER = 'root'; - const PW = 'testrootpass123'; - let container = null; - let configId = null; + test('MySQL: seed -> backup -> drop -> restore -> verify', async () => { + const c = APPS.mysql.container; + const select = () => mysqlExec(c, MYSQL_PW, 'testdb', "SELECT CONCAT(id,'|',v) FROM e2e_data WHERE id=1;"); + mysqlExec(c, MYSQL_PW, 'testdb', + "CREATE TABLE IF NOT EXISTS e2e_data(id INT PRIMARY KEY, v VARCHAR(64)); DELETE FROM e2e_data; INSERT INTO e2e_data VALUES (1, 'mysql-marker-abc');"); + expect(select()).toBe('1|mysql-marker-abc'); - test.beforeAll(async () => { - test.setTimeout(300_000); - await loginAPI(); - await deployViaAPI(SLUG, readFixture('compose-mongo.yml')); - await waitForAppStatus(SLUG, 'running', 180_000); - container = findServiceContainer(SLUG, SERVICE); - expect(container, `${SLUG} ${SERVICE} container`).toBeTruthy(); - await waitForHealthy( - container, - `mongosh --quiet --username ${USER} --password ${PW} --authenticationDatabase admin --eval "db.runCommand({ ping: 1 }).ok"`, - 120_000, - ); - }); - - test.afterAll(async () => { - try { await loginAPI(); } catch {} - await deleteAppConfigsFor(SLUG); - await removeApp(SLUG); - }); + await backupDropRestore(APPS.mysql, 'mysql', () => { + mysqlExec(c, MYSQL_PW, 'testdb', 'DROP TABLE e2e_data;'); + expect(select).toThrow(/doesn't exist/); + }); - test('strategy detection reports mongo service', async () => { - const res = await apiRequest('GET', `/api/apps/${SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const mongo = strategies.find((s) => s.strategy_type === 'mongo'); - expect(mongo, 'mongo strategy missing').toBeTruthy(); - expect(mongo.available).toBe(true); - expect(mongo.services[0].container_name).toBe(`simpledeploy-${SLUG}-${SERVICE}-1`); + await expect.poll(() => readOr(select), { timeout: 90_000 }).toBe('1|mysql-marker-abc'); }); - test('seed -> backup -> drop -> restore -> verify', async () => { - test.setTimeout(300_000); - - // Seed via mongosh - mongoEval(container, USER, PW, + test('MongoDB: seed -> backup -> drop -> restore -> verify', async () => { + const c = APPS.mongo.container; + const find = () => mongoEval(c, MONGO_USER, MONGO_PW, 'JSON.stringify(db.getSiblingDB("testdb").e2e.findOne({_id:1}))'); + mongoEval(c, MONGO_USER, MONGO_PW, 'db.getSiblingDB("testdb").e2e.deleteMany({}); db.getSiblingDB("testdb").e2e.insertOne({_id:1, v:"mongo-marker-abc"});'); - const seeded = mongoEval(container, USER, PW, - 'JSON.stringify(db.getSiblingDB("testdb").e2e.findOne({_id:1}))'); - expect(seeded).toContain('mongo-marker-abc'); + expect(find()).toContain('mongo-marker-abc'); - // Create config - const createRes = await apiRequest('POST', `/api/apps/${SLUG}/backups/configs`, { - strategy: 'mongo', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', + await backupDropRestore(APPS.mongo, 'mongo', () => { + mongoEval(c, MONGO_USER, MONGO_PW, 'db.getSiblingDB("testdb").e2e.drop();'); + expect(mongoEval(c, MONGO_USER, MONGO_PW, 'db.getSiblingDB("testdb").e2e.countDocuments({})')).toBe('0'); }); - expect(createRes.ok).toBeTruthy(); - configId = createRes.data.id; - // Trigger. Same Credentials-bug caveat as mysql. - const run = await triggerAndWait(SLUG, configId, 180_000); - expect(run.status, `mongo backup run status: ${JSON.stringify(run)}`).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); - - const abs = backupFileOnDisk(run); - expect(abs, 'mongo backup file must exist on disk').toBeTruthy(); - - // Drop collection - mongoEval(container, USER, PW, 'db.getSiblingDB("testdb").e2e.drop();'); - const afterDrop = mongoEval(container, USER, PW, - 'db.getSiblingDB("testdb").e2e.countDocuments({})'); - expect(afterDrop.trim()).toBe('0'); - - // Restore - const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); - expect(restoreRes.status).toBe(202); - - // Poll until doc returns - const deadline = Date.now() + 90_000; - let found = false; - while (Date.now() < deadline) { - try { - const out = mongoEval(container, USER, PW, - 'JSON.stringify(db.getSiblingDB("testdb").e2e.findOne({_id:1}))'); - if (out.includes('mongo-marker-abc')) { found = true; break; } - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(found).toBeTruthy(); - }); -}); - -// ===================================================================== -// Redis -// ===================================================================== -test.describe('Backup strategy: Redis', () => { - test.describe.configure({ mode: 'serial' }); - - const SLUG = 'e2e-redis'; - const SERVICE = 'db'; - let container = null; - let configId = null; - - test.beforeAll(async () => { - test.setTimeout(300_000); - await loginAPI(); - await deployViaAPI(SLUG, readFixture('compose-redis.yml')); - await waitForAppStatus(SLUG, 'running', 180_000); - container = findServiceContainer(SLUG, SERVICE); - expect(container, `${SLUG} ${SERVICE} container`).toBeTruthy(); - await waitForHealthy(container, 'redis-cli PING', 60_000); - }); - - test.afterAll(async () => { - try { await loginAPI(); } catch {} - await deleteAppConfigsFor(SLUG); - await removeApp(SLUG); - }); - - test('strategy detection reports redis service', async () => { - const res = await apiRequest('GET', `/api/apps/${SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const redis = strategies.find((s) => s.strategy_type === 'redis'); - expect(redis, 'redis strategy missing').toBeTruthy(); - expect(redis.available).toBe(true); - expect(redis.services[0].container_name).toBe(`simpledeploy-${SLUG}-${SERVICE}-1`); + await expect.poll(() => readOr(find), { timeout: 90_000 }).toContain('mongo-marker-abc'); }); - test('seed -> backup -> delete -> restore -> verify', async () => { - test.setTimeout(300_000); + test('Redis: seed -> backup -> delete -> restore -> verify', async () => { + const c = APPS.redis.container; + redisCmd(c, 'SET', 'e2e:marker', 'redis-marker-abc'); + expect(redisCmd(c, 'GET', 'e2e:marker')).toBe('redis-marker-abc'); - // Seed - redisCmd(container, 'SET', 'e2e:marker', 'redis-marker-abc'); - expect(redisCmd(container, 'GET', 'e2e:marker')).toBe('redis-marker-abc'); - - // Config - const createRes = await apiRequest('POST', `/api/apps/${SLUG}/backups/configs`, { - strategy: 'redis', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: '', + await backupDropRestore(APPS.redis, 'redis', () => { + redisCmd(c, 'DEL', 'e2e:marker'); + expect(redisCmd(c, 'GET', 'e2e:marker')).toBe(''); }); - expect(createRes.ok).toBeTruthy(); - configId = createRes.data.id; - - const run = await triggerAndWait(SLUG, configId, 180_000); - expect(run.status, `redis backup run: ${JSON.stringify(run)}`).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); - - const abs = backupFileOnDisk(run); - expect(abs, 'redis backup file must exist on disk').toBeTruthy(); - - // Delete the key so we can prove restore worked - redisCmd(container, 'DEL', 'e2e:marker'); - expect(redisCmd(container, 'GET', 'e2e:marker')).toBe(''); - - // Restore (redis strategy stops container, cp's in the RDB, starts it) - const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); - expect(restoreRes.status).toBe(202); - // Wait for container to cycle back up after restore - await waitForContainerState(container, true, 60_000); - await waitForHealthy(container, 'redis-cli PING', 60_000); - - // Poll until value returns - const deadline = Date.now() + 60_000; - let got = ''; - while (Date.now() < deadline) { - try { - got = redisCmd(container, 'GET', 'e2e:marker'); - if (got === 'redis-marker-abc') break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(got).toBe('redis-marker-abc'); - }); -}); - -// ===================================================================== -// SQLite -// ===================================================================== -test.describe('Backup strategy: SQLite', () => { - test.describe.configure({ mode: 'serial' }); - - const SLUG = 'e2e-sqlite'; - const SERVICE = 'web'; - const DB = '/data/test.db'; - let container = null; - let configId = null; - - test.beforeAll(async () => { - test.setTimeout(300_000); - await loginAPI(); - await deployViaAPI(SLUG, readFixture('compose-sqlite.yml')); - await waitForAppStatus(SLUG, 'running', 180_000); - container = findServiceContainer(SLUG, SERVICE); - expect(container, `${SLUG} ${SERVICE} container`).toBeTruthy(); - // Wait for the seed command to finish (sqlite install + table create) by - // probing for the DB file. - await waitForHealthy(container, `test -f ${DB}`, 120_000); - // Extra: ensure sqlite3 CLI is present - await waitForHealthy(container, 'which sqlite3', 60_000); - }); - - test.afterAll(async () => { - try { await loginAPI(); } catch {} - await deleteAppConfigsFor(SLUG); - await removeApp(SLUG); - }); - - test('strategy detection reports sqlite service with path', async () => { - const res = await apiRequest('GET', `/api/apps/${SLUG}/backups/detect`); - expect(res.ok).toBeTruthy(); - const strategies = res.data.strategies || []; - const sqlite = strategies.find((s) => s.strategy_type === 'sqlite'); - expect(sqlite, 'sqlite strategy missing').toBeTruthy(); - expect(sqlite.available).toBe(true); - expect(sqlite.services[0].container_name).toBe(`simpledeploy-${SLUG}-${SERVICE}-1`); - // Volume target /data should surface as a candidate path - expect(Array.isArray(sqlite.services[0].paths)).toBe(true); - expect(sqlite.services[0].paths).toContain('/data'); + // The redis restore stops the container, copies the RDB in and starts it. + await expect.poll(() => readOr(() => redisCmd(c, 'GET', 'e2e:marker')), { timeout: 60_000 }).toBe('redis-marker-abc'); }); - test('seed -> backup -> delete -> restore -> verify', async () => { - test.setTimeout(300_000); - - // Seed - sqlite3Eval(container, DB, - "DELETE FROM e2e_items; INSERT INTO e2e_items(id, v) VALUES (1, 'sqlite-marker-abc');"); - const seeded = sqlite3Eval(container, DB, - "SELECT id||'|'||v FROM e2e_items WHERE id=1;"); - expect(seeded).toBe('1|sqlite-marker-abc'); - - // Create config. The sqlite strategy needs paths — pass the DB file path - // explicitly via paths (not the volume mount point), since the strategy - // executes `sqlite3 '.backup ...'` on each path. - const createRes = await apiRequest('POST', `/api/apps/${SLUG}/backups/configs`, { - strategy: 'sqlite', - target: 'local', - schedule_cron: '0 0 1 1 *', - target_config_json: '', - retention_mode: 'count', - retention_count: 3, - verify_upload: false, - pre_hooks: '', - post_hooks: '', - paths: JSON.stringify([DB]), - }); - expect(createRes.ok).toBeTruthy(); - configId = createRes.data.id; + test('SQLite: seed -> backup -> delete -> restore -> verify', async () => { + const c = APPS.sqlite.container; + const select = () => sqlite3Eval(c, SQLITE_DB, "SELECT id||'|'||v FROM e2e_items WHERE id=1;"); + sqlite3Eval(c, SQLITE_DB, "DELETE FROM e2e_items; INSERT INTO e2e_items(id, v) VALUES (1, 'sqlite-marker-abc');"); + expect(select()).toBe('1|sqlite-marker-abc'); - const run = await triggerAndWait(SLUG, configId, 180_000); - expect(run.status, `sqlite backup run: ${JSON.stringify(run)}`).toBe('success'); - expect(run.size_bytes).toBeGreaterThan(0); - expect(run.checksum).toBeTruthy(); + // The strategy runs `sqlite3 .backup` per path, so pass the DB + // file, not the volume mount point. + await backupDropRestore(APPS.sqlite, 'sqlite', () => { + sqlite3Eval(c, SQLITE_DB, 'DELETE FROM e2e_items;'); + expect(sqlite3Eval(c, SQLITE_DB, 'SELECT COUNT(*) FROM e2e_items;')).toBe('0'); + }, { paths: JSON.stringify([SQLITE_DB]) }); - const abs = backupFileOnDisk(run); - expect(abs, 'sqlite backup file must exist on disk').toBeTruthy(); - - // Delete data (keep table, remove rows) - sqlite3Eval(container, DB, 'DELETE FROM e2e_items;'); - const gone = sqlite3Eval(container, DB, 'SELECT COUNT(*) FROM e2e_items;'); - expect(gone).toBe('0'); - - // Restore - const restoreRes = await apiRequest('POST', `/api/backups/restore/${run.id}`); - expect(restoreRes.status).toBe(202); - - // Poll until row returns - const deadline = Date.now() + 60_000; - let got = ''; - while (Date.now() < deadline) { - try { - got = sqlite3Eval(container, DB, "SELECT id||'|'||v FROM e2e_items WHERE id=1;"); - if (got === '1|sqlite-marker-abc') break; - } catch {} - await new Promise((r) => setTimeout(r, 1_000)); - } - expect(got).toBe('1|sqlite-marker-abc'); + await expect.poll(() => readOr(select), { timeout: 60_000 }).toBe('1|sqlite-marker-abc'); }); }); - From 369b2b785311a93f1e8212d4f306364f64f22f6c Mon Sep 17 00:00:00 2001 From: Ameen Rashad <7609895+vazra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:24:16 -0700 Subject: [PATCH 11/15] test(e2e): run expected-fail checks last; wait out the collector before alert tests --- e2e/helpers/metrics.js | 14 +++++- e2e/tests/12-backups.spec.js | 80 ++++++++++++++++++++-------------- e2e/tests/13-alerts.spec.js | 58 +++++++++++++++++++----- e2e/tests/27-backup-s3.spec.js | 40 ++++++++--------- 4 files changed, 126 insertions(+), 66 deletions(-) diff --git a/e2e/helpers/metrics.js b/e2e/helpers/metrics.js index 17be3223..0f00cec0 100644 --- a/e2e/helpers/metrics.js +++ b/e2e/helpers/metrics.js @@ -1,4 +1,5 @@ -import { sqliteExec, replaceMetricsAtomic } from './db.js'; +import { expect } from '@playwright/test'; +import { sqliteExec, sqliteQuery, replaceMetricsAtomic } from './db.js'; // Synthetic metric helpers for driving the alert evaluator. @@ -29,3 +30,14 @@ export function startMetricRefresher(slug, { high }) { lastError: () => (lastError ? `metric refresher: ${lastError.message}` : ''), }; } + +// waitForCollectorCycle resolves once the collector has stored a system +// point stamped at or after sinceSec. Cycles run back to back and a slow one +// can store an app's points seconds after they were sampled (e.g. a last idle +// point for a container stopped mid-cycle); once a later cycle's point is in +// the table, every earlier point is too. +export async function waitForCollectorCycle(sinceSec, timeoutMs = 60_000) { + const latest = () => + Number(sqliteQuery("SELECT COALESCE(MAX(ts), 0) AS ts FROM metrics WHERE app_id IS NULL AND tier = 'raw';")[0].ts); + await expect.poll(latest, { timeout: timeoutMs, message: 'collector cycle after the stop' }).toBeGreaterThanOrEqual(sinceSec); +} diff --git a/e2e/tests/12-backups.spec.js b/e2e/tests/12-backups.spec.js index 1df816b8..317ad8e7 100644 --- a/e2e/tests/12-backups.spec.js +++ b/e2e/tests/12-backups.spec.js @@ -91,10 +91,6 @@ test.describe('Backups', () => { const run = await waitForNewRunForConfig(POSTGRES_SLUG, configId, known); const done = await pollRunStatus(POSTGRES_SLUG, run.id, ['success', 'failed'], 120_000); expect(done.status, `run error: ${done.error_msg}`).toBe('success'); - - // The tab should pick up the finished run without a reload. - const history = page.locator('main').getByRole('table').nth(1); - await expect(history.getByRole('row').nth(1)).toContainText('success', { timeout: 15_000 }); }); test('global backups page shows summary', async ({ page }) => { @@ -118,6 +114,24 @@ test.describe('Backups', () => { await expect(page.getByRole('button', { name: 'Backup Now' })).toHaveCount(0); expect(await listConfigs(POSTGRES_SLUG)).toEqual([]); }); + + test('backups tab shows the finished run without a reload', async ({ page }) => { + const cfgId = await createBackupConfig(POSTGRES_SLUG, { strategy: 'postgres' }); + try { + await openBackupsTab(page); + const known = new Set((await listRuns(POSTGRES_SLUG)).map((r) => r.id)); + await page.getByRole('button', { name: 'Backup Now' }).click(); + + const run = await waitForNewRunForConfig(POSTGRES_SLUG, cfgId, known); + const done = await pollRunStatus(POSTGRES_SLUG, run.id, ['success', 'failed'], 120_000); + expect(done.status, `run error: ${done.error_msg}`).toBe('success'); + + const history = page.locator('main').getByRole('table').nth(1); + await expect(history.getByRole('row').nth(1)).toContainText('success'); + } finally { + await apiRequest('DELETE', `/api/backups/configs/${cfgId}`); + } + }); }); test.describe('Backups - Functional Roundtrip', () => { @@ -189,7 +203,35 @@ test.describe('Backups - Functional Roundtrip', () => { } }); - test('3. pre/post hooks run in the app container around the backup', async () => { + test('3. download, checksum, upload-restore cycle via multipart', async () => { + const runs = await listRuns(POSTGRES_SLUG); + const run = runs.find((r) => r.status === 'success' && r.file_path.endsWith('.sql.gz') && localBackupExists(r)); + expect(run, 'a postgres run from test 1 with its file on disk').toBeTruthy(); + + psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); + + const dl = await apiDownload(`/api/backups/runs/${run.id}/download`); + expect(dl.status, `download failed: ${dl.body}`).toBe(200); + const actual = createHash('sha256').update(dl.buffer).digest('hex'); + expect(actual).toBe(run.checksum.replace(/^sha256:/i, '').toLowerCase()); + + psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); + + const up = await apiUploadMultipart( + `/api/apps/${POSTGRES_SLUG}/backups/upload-restore`, + { strategy: 'postgres', container: pgContainer }, + 'file', + run.file_path.split('/').pop(), + dl.buffer, + ); + expect(up.status, `upload-restore: ${JSON.stringify(up.data)}`).toBe(202); + + await expect + .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 90_000 }) + .toBe('1|roundtrip-marker-abc123'); + }); + + test('4. pre/post hooks run in the app container around the backup', async () => { // Hooks name compose services. The pre-hook leaves a marker; the post // hooks restart the db service (so its StartedAt changes) and then leave // a second marker, which needs the restarted container to be running. @@ -221,32 +263,4 @@ test.describe('Backups - Functional Roundtrip', () => { await waitForHealthy(pgContainer, 'pg_isready -U postgres', 60_000); } }); - - test('4. download, checksum, upload-restore cycle via multipart', async () => { - const runs = await listRuns(POSTGRES_SLUG); - const run = runs.find((r) => r.status === 'success' && r.file_path.endsWith('.sql.gz') && localBackupExists(r)); - expect(run, 'a postgres run from test 1 with its file on disk').toBeTruthy(); - - psql(pgContainer, 'postgres', 'testdb', MARKER_SQL); - - const dl = await apiDownload(`/api/backups/runs/${run.id}/download`); - expect(dl.status, `download failed: ${dl.body}`).toBe(200); - const actual = createHash('sha256').update(dl.buffer).digest('hex'); - expect(actual).toBe(run.checksum.replace(/^sha256:/i, '').toLowerCase()); - - psql(pgContainer, 'postgres', 'testdb', 'DROP TABLE e2e_data;'); - - const up = await apiUploadMultipart( - `/api/apps/${POSTGRES_SLUG}/backups/upload-restore`, - { strategy: 'postgres', container: pgContainer }, - 'file', - run.file_path.split('/').pop(), - dl.buffer, - ); - expect(up.status, `upload-restore: ${JSON.stringify(up.data)}`).toBe(202); - - await expect - .poll(() => readOr(() => psql(pgContainer, 'postgres', 'testdb', SELECT_SQL)), { timeout: 90_000 }) - .toBe('1|roundtrip-marker-abc123'); - }); }); diff --git a/e2e/tests/13-alerts.spec.js b/e2e/tests/13-alerts.spec.js index 717cbc6d..f50007dc 100644 --- a/e2e/tests/13-alerts.spec.js +++ b/e2e/tests/13-alerts.spec.js @@ -3,7 +3,7 @@ import { loginAsAdmin, getState, TEST_ADMIN } from '../helpers/auth.js'; import { apiLogin, apiRequest, waitForAppStatus } from '../helpers/api.js'; import { startWebhookReceiver } from '../helpers/webhook.js'; import { sqliteQuery, sqliteExec, getAppId } from '../helpers/db.js'; -import { deleteAppMetrics, startMetricRefresher } from '../helpers/metrics.js'; +import { deleteAppMetrics, startMetricRefresher, waitForCollectorCycle } from '../helpers/metrics.js'; import { createBackupConfig } from '../helpers/backups.js'; const UI_HOOK = 'E2E Test Hook'; @@ -32,6 +32,12 @@ async function createRule(body) { return res.data.id; } +// ruleSelect finds a