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
Safari-specific deviations fixed: 1. Safari shows its native auth dialog on fetch() calls answered with a 401 + WWW-Authenticate challenge (e.g. save with an expired session). All CMS 401 responses now omit WWW-Authenticate; browsers use the styled /login page instead. 2. Safari caches Basic credentials and resends them automatically, which made logout ineffective (a navigation after logout went straight back into the editor). Browser navigations (GET + text/html) now authenticate ONLY via the session cookie; Basic Auth remains valid for non-browser clients (curl, API). 3. /login and redirects send Cache-Control: no-store so Safari does not cache the login page or the 302. Closes MITHOME-62
170 lines
6.5 KiB
JavaScript
170 lines
6.5 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 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; });
|