Files
websitedev/scripts/test-content-editor-login.js
T
Do Siki 5fe36584dd
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
feat(cms): confirm-before-logout and branded login page
- logout asks for confirmation, then invalidates the server-side session
  and navigates to a public /login page (logo, form, error messages)
- POST /login validates credentials (timing-safe) and issues an HttpOnly
  SameSite=Strict session cookie (8h, Secure behind HTTPS); Basic Auth
  stays valid in parallel for curl/API use
- unauthenticated browser navigations redirect to /login; non-browser
  requests keep the 401 challenge
- failed form logins share the auth rate-limit budget with Basic attempts
- save/publish redirect to /login when the session expired
- refactor: templates and browser script extracted to scripts/cms-pages.js
  and scripts/cms-editor-client.js, session logic to scripts/cms-session.js
  (content-editor.js back under the 400-line limit)
- user guide updated (login page, confirmation, 8h session)

Closes MITHOME-58
2026-08-18 14:01:42 +02:00

154 lines
5.7 KiB
JavaScript

#!/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 keep the 401 challenge (curl/API compatibility)
const apiStyle = await fetch(`${BASE}/`);
assert.equal(apiStyle.status, 401);
assert.match(apiStyle.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/);
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; });