#!/usr/bin/env node /** * Integration test for the CMS login flow (MITHOME-58): * 1. GET /login is public and serves the styled login page with the logo * 2. GET /logo.png is public * 3. POST /login with wrong credentials → 401; with correct ones → 200 + session cookie * 4. The session cookie authenticates GET / (200) where no Basic credentials exist * 5. POST /logout (cookie + CSRF) invalidates the session; GET / with the dead * cookie now redirects to /login for browser navigations * 6. Non-browser requests without credentials still get the 401 challenge * 7. Failed form logins count toward the auth rate limiter (6th → 429) */ const assert = require('assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const ROOT = path.join(__dirname, '..'); function startServer(port) { const auditFile = path.join(os.tmpdir(), `content-editor-audit-login-${port}-${process.pid}.jsonl`); const child = spawn('node', ['content-editor.js'], { cwd: ROOT, env: { ...process.env, CONTENT_EDITOR_PORT: String(port), CONTENT_EDITOR_AUDIT_FILE: auditFile, CMS_USER: 'login-test-user', CMS_PASS: 'login-test-pass', CMS_DEPLOY_ENV: 'staging', }, stdio: 'ignore', }); return { child, auditFile }; } async function waitForServer(base, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(`${base}/logout`); // rate-limit-free readiness probe (GET) return; } catch { await new Promise(r => setTimeout(r, 200)); } } throw new Error('server did not start'); } async function main() { // ── Happy path server ────────────────────────────────────────────────────── const PORT = 4125; const BASE = `http://127.0.0.1:${PORT}`; const s1 = startServer(PORT); try { await waitForServer(BASE); // 1. login page is public const page = await fetch(`${BASE}/login`); assert.equal(page.status, 200); const pageHtml = await page.text(); assert.match(pageHtml, /mozdIT CMS — Belépés/); assert.match(pageHtml, /\/logo\.png/); // 2. logo is public const logo = await fetch(`${BASE}/logo.png`); assert.equal(logo.status, 200); assert.match(logo.headers.get('content-type') || '', /image\/png/); // 3a. wrong credentials const bad = await fetch(`${BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }), }); assert.equal(bad.status, 401); // 3b. correct credentials → session cookie const good = await fetch(`${BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: 'login-test-user', pass: 'login-test-pass' }), }); assert.equal(good.status, 200); assert.deepEqual(await good.json(), { ok: true }); const setCookie = good.headers.get('set-cookie') || ''; assert.match(setCookie, /cms_session=[a-f0-9]+/); assert.match(setCookie, /HttpOnly/); assert.match(setCookie, /SameSite=Strict/); // HTTP test run (no x-forwarded-proto) must NOT set Secure, or the cookie would be unusable assert.doesNotMatch(setCookie, /Secure/); const sessionCookie = setCookie.split(';')[0]; // 4. session cookie authenticates without Basic credentials const authed = await fetch(`${BASE}/`, { headers: { Cookie: sessionCookie } }); assert.equal(authed.status, 200); // Extract the CSRF token from the served editor page for the logout POST const editorHtml = await authed.text(); const csrf = editorHtml.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1]; // 5. POST /logout kills the session const logout = await fetch(`${BASE}/logout`, { method: 'POST', headers: { Cookie: sessionCookie, 'X-CSRF-Token': csrf }, }); assert.equal(logout.status, 200); // Dead cookie + browser navigation → redirect to /login const redirected = await fetch(`${BASE}/`, { headers: { Cookie: sessionCookie, Accept: 'text/html,application/xhtml+xml' }, redirect: 'manual', }); assert.equal(redirected.status, 302); assert.equal(redirected.headers.get('location'), '/login'); // 6. non-browser requests get a plain 401 WITHOUT a Basic challenge // (Safari pops its native auth dialog on challenged fetch calls). const apiStyle = await fetch(`${BASE}/`); assert.equal(apiStyle.status, 401); assert.equal(apiStyle.headers.get('www-authenticate'), null); // 7. Safari scenario: browser navigation with CACHED Basic credentials but no // session must still land on /login — otherwise logout would be ineffective // in browsers that resend Basic auth automatically. const basic = 'Basic ' + Buffer.from('login-test-user:login-test-pass').toString('base64'); const safariLike = await fetch(`${BASE}/`, { headers: { Authorization: basic, Accept: 'text/html,application/xhtml+xml' }, redirect: 'manual', }); assert.equal(safariLike.status, 302); assert.equal(safariLike.headers.get('location'), '/login'); // 8. the same credentials DO authenticate a non-browser request (curl/API) const curlLike = await fetch(`${BASE}/`, { headers: { Authorization: basic } }); assert.equal(curlLike.status, 200); console.log('Content Editor login flow test: OK'); } finally { s1.child.kill('SIGTERM'); try { fs.unlinkSync(s1.auditFile); } catch { /* already gone */ } } // ── Rate-limit server (fresh limiter state) ──────────────────────────────── const PORT2 = 4126; const BASE2 = `http://127.0.0.1:${PORT2}`; const s2 = startServer(PORT2); try { await waitForServer(BASE2); const attempt = () => fetch(`${BASE2}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: 'login-test-user', pass: 'wrong' }), }); for (let i = 0; i < 5; i++) { assert.equal((await attempt()).status, 401); } const limited = await attempt(); assert.equal(limited.status, 429); console.log('Content Editor login rate-limit test: OK'); } finally { s2.child.kill('SIGTERM'); try { fs.unlinkSync(s2.auditFile); } catch { /* already gone */ } } } main().catch(err => { console.error('❌', err.message); process.exitCode = 1; });