feat(cms): confirm-before-logout and branded login page
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
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
- 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
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
#!/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; });
|
||||
Reference in New Issue
Block a user