fix(cms): stable bottom bar and self-save no longer trips the 409 lock
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

MITHOME-68: the optimistic-lock fingerprint was frozen at page load, so the
user's own second save 409'd. /save now returns the hash of the written
content and the client refreshes CONTENT_HASH on success — 409 only fires
for genuine external changes (deploy, other tab, restore). Also: successful
logins no longer consume the auth failure budget (only failed attempts do).

MITHOME-69: bottom bar items no longer shift while saving/publishing — the
status message occupies a constant flex slot (visibility instead of
display), the publish button locks its width while running and restores
its env-specific label, auto margins removed. Layout guard test added.

Test markers are now run-unique so a crashed run can never poison the
next one's expectations.
This commit is contained in:
Do Siki
2026-08-19 14:04:49 +02:00
parent 41a2259ede
commit 88383049d5
6 changed files with 126 additions and 20 deletions
+13 -7
View File
@@ -134,12 +134,6 @@ const server = http.createServer(async (req, res) => {
// Public: login form endpoint. Shares the auth rate-limit budget with failed
// Basic attempts so the form cannot be brute-forced either.
if (req.method === 'POST' && u.pathname === '/login') {
if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
let body = '';
let bodyTooLarge = false;
req.on('data', c => {
@@ -155,12 +149,20 @@ const server = http.createServer(async (req, res) => {
pass = String(parsed.pass || '');
} catch { /* empty credentials fail validation below */ }
if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) {
// WHY: successful logins must not consume the failure budget — tests and
// multi-tab users log in repeatedly and would lock themselves out.
const isSecure = req.headers['x-forwarded-proto'] === 'https';
writeAudit('login_success', { clientAddress });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) });
res.end(JSON.stringify({ ok: true }));
return;
}
if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
@@ -263,9 +265,13 @@ const server = http.createServer(async (req, res) => {
return;
}
const backupFile = backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR);
// Return the hash of the written content so the editor tab can refresh
// its fingerprint — otherwise the user's OWN next save would trip the
// optimistic-lock 409 (MITHOME-68).
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile) }));
res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile), contentHash: newHash }));
} catch (e) {
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
+13 -5
View File
@@ -258,14 +258,17 @@ async function save() {
const json = await res.json();
const status = document.getElementById('saveStatus');
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
}
status.style.display = 'inline';
setTimeout(() => status.style.display = 'none', 3000);
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
}
async function publish() {
@@ -275,6 +278,10 @@ async function publish() {
// Save first
await save();
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
const originalLabel = btn.textContent;
btn.style.minWidth = btn.offsetWidth + 'px';
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
@@ -295,10 +302,11 @@ async function publish() {
status.style.color = '#f87171';
}
btn.textContent = '🚀 Publikálás & Élesítés';
btn.textContent = originalLabel;
btn.style.minWidth = '';
btn.disabled = false;
status.style.display = 'inline';
setTimeout(() => status.style.display = 'none', 5000);
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
}
async function logout() {
+6 -4
View File
@@ -66,7 +66,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
/* Bottom bar */
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
.btn-logout { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
.btn-logout:hover { background: #374151; }
.version-tag { color: #475569; font-size: 12px; font-family: monospace; }
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
@@ -75,9 +75,11 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
.btn-publish:active { transform: translateY(0); }
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; }
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; }
.preview-link:hover { color: #94a3b8; }
.save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; }
/* WHY: the status slot always occupies the same flex space (visibility, not
display) so showing/hiding messages never shifts the other bar items. */
.save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
@@ -124,7 +126,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
const DATA = JSON.parse(document.getElementById('page-data').textContent);
const FILE = "${activeFile}";
const CSRF_TOKEN = "${csrfToken}";
const CONTENT_HASH = "${contentHash}";
let CONTENT_HASH = "${contentHash}";
${clientJs}
</script>
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Layout guard for the CMS bottom bar (MITHOME-69): the bar items must not
* shift while saving/publishing. Asserts the invariants that keep the layout
* stable:
* - the status slot reserves constant space (flex + visibility, not display)
* - no auto margins redistribute free space between bar items
* - the publish handler locks the button width and restores its label
* - save refreshes the optimistic-lock fingerprint (MITHOME-68)
*/
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const pages = fs.readFileSync(path.join(__dirname, '../scripts/cms-pages.js'), 'utf8');
const client = fs.readFileSync(path.join(__dirname, '../scripts/cms-editor-client.js'), 'utf8');
const statusRule = pages.match(/\.save-status \{[^}]*\}/)[0];
assert.match(statusRule, /flex: 1 1 0/, 'status slot must reserve constant space');
assert.match(statusRule, /visibility: hidden/, 'status must hide via visibility (keeps layout slot)');
assert.doesNotMatch(statusRule, /display: none/, 'display:none would collapse the slot and shift items');
const previewRule = pages.match(/\.preview-link \{[^}]*\}/)[0];
assert.doesNotMatch(previewRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
const logoutRule = pages.match(/\.btn-logout \{[^}]*\}/)[0];
assert.doesNotMatch(logoutRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
assert.match(client, /btn\.style\.minWidth = btn\.offsetWidth \+ 'px'/, 'publish must lock the button width');
assert.match(client, /const originalLabel = btn\.textContent/, 'publish must restore the env-specific label');
assert.match(client, /if \(json\.contentHash\) CONTENT_HASH = json\.contentHash/, 'save must refresh the lock fingerprint');
console.log('Content Editor bottom bar layout guard: OK');
+58 -1
View File
@@ -67,16 +67,73 @@ async function call(method, pathName, { body, headers } = {}) {
async function main() {
await waitForServer();
const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json');
const original = fs.readFileSync(contentFile, 'utf8');
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'conflict-test-user', pass: 'conflict-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
const csrfPage = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text();
const csrf = csrfPage.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
// Baseline: read the page and capture the served fingerprint
const page = await (await call('GET', '/?file=contact')).text();
const servedHash = page.match(/const CONTENT_HASH = "([a-f0-9]+)"/)[1];
const servedHash = page.match(/let CONTENT_HASH = "([a-f0-9]+)"/)[1];
const diskBefore = fs.readFileSync(contentFile, 'utf8');
assert.equal(servedHash, hashOf(diskBefore), 'served fingerprint matches the file on disk');
const payload = diskBefore; // unchanged content is still a valid save payload
const jsonHeaders = { 'Content-Type': 'application/json' };
// MITHOME-68: the save response returns the new content hash; a tab that
// adopts it can save again — only a genuinely external change may 409.
const v3 = JSON.parse(original);
v3.hero.subtitle = 'Hash frissítés teszt ' + Date.now();
const first = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(first.status, 200);
const firstBody = await first.json();
assert.match(firstBody.contentHash, /^[a-f0-9]{64}$/, 'save must return the new content hash');
// same tab continues with the returned hash → 200
const again = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': firstBody.contentHash,
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(again.status, 200, 'save with the refreshed hash must succeed');
// a stale (pre-save) hash still 409s
const stale = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(original),
},
body: JSON.stringify(v3, null, 2),
});
assert.equal(stale.status, 409, 'stale hash must still be rejected');
// leave the disk as the following sections expect it
fs.writeFileSync(contentFile, original);
// 1. correct hash → 200
const ok = await call('POST', '/save?file=contact', {
headers: { ...jsonHeaders, 'X-Content-Hash': servedHash },
+1 -1
View File
@@ -26,7 +26,7 @@ const fixture = {
};
const clientJs = fs.readFileSync('scripts/cms-editor-client.js', 'utf8');
const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null, 'csrf-test-token', { common: '⚙️ Közös' }, clientJs, 'hash-test-value');
assert.ok(html.includes('const CONTENT_HASH = "hash-test-value"'));
assert.ok(html.includes('let CONTENT_HASH = "hash-test-value"'));
const browserSource = [...html.matchAll(/<script(?: [^>]*)?>([\s\S]*?)<\/script>/g)].at(-1)[1]
.replace("render(DATA, document.getElementById('editor'));", '')
.replace("const toast = document.querySelector('.toast');", 'const toast = null;');