diff --git a/content-editor.js b/content-editor.js index d9c0c1c..85f9efc 100644 --- a/content-editor.js +++ b/content-editor.js @@ -16,6 +16,7 @@ const crypto = require('crypto'); const { validateContent } = require('./proto/src/content/schema'); const { renderMarkdown } = require('./scripts/markdown-render'); const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish'); +const { safeBackupName, listVersions, readBackupContent, buildVersionDiff } = require('./scripts/cms-versions'); const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001; // WHY: overridable so the publish integration test can run against a throwaway @@ -49,7 +50,7 @@ const FILE_LABELS = { hasznalatiFeltetelek: '⚖️ ÁSZF', }; -const { HTML, GUIDE_PAGE, LOGIN_PAGE } = require('./scripts/cms-pages'); +const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages'); const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session'); // Browser script is kept in its own file and inlined into the HTML template at render time. @@ -58,80 +59,14 @@ const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-cli // ── Server ─────────────────────────────────────────────────────────────────── -const CMS_USER = process.env.CMS_USER; -const CMS_PASS = process.env.CMS_PASS; -const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV; -const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex'); -const rateLimits = new Map(); - -function securityConfigIsValid() { - return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV)); -} - -function getClientAddress(req) { - // The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header. - // WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client - // IP to the list, so the first entry may be a spoofed value sent by the client — using - // it would let attackers bypass the rate limiter with a fresh "IP" per request. - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string' && forwarded.trim()) { - const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean); - if (parts.length > 0) return parts[parts.length - 1]; - } - return req.socket.remoteAddress || 'unknown'; -} - -function exceedsRateLimit(key, limit) { - const now = Date.now(); - const attempts = (rateLimits.get(key) || []).filter(time => now - time < RATE_LIMIT_WINDOW_MS); - attempts.push(now); - rateLimits.set(key, attempts); - return attempts.length > limit; -} - -function hasValidCredentials(req) { - const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; - const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':'); - return validateLogin(login, password, CMS_USER, CMS_PASS); -} - -function isBrowserNavigation(req) { - return req.method === 'GET' && String(req.headers.accept || '').includes('text/html'); -} - -// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them -// automatically, which would let an already-logged-out browser straight back in. -// Browser navigations therefore authenticate ONLY via the session cookie, so -// logout is final. Non-browser requests (curl, API clients) keep Basic Auth. -function isAuthenticated(req) { - if (isBrowserNavigation(req)) return hasValidSession(req); - return hasValidCredentials(req) || hasValidSession(req); -} - -function hasValidCsrfToken(req) { - const token = req.headers['x-csrf-token']; - return typeof token === 'string' - && token.length === CSRF_TOKEN.length - && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN)); -} - -function writeAudit(event, details = {}) { - const record = { timestamp: new Date().toISOString(), event, ...details }; - fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 }); -} - -function backupAndWriteAtomically(targetFile, data, backupDir = BACKUP_DIR) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`; - const backupFile = path.join(backupDir, backupName); - const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`; - - fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); - fs.copyFileSync(targetFile, backupFile); - fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); - fs.renameSync(tempFile, targetFile); - return backupFile; -} +// Security/infra helpers live in scripts/cms-core.js (file-size limits). +const core = require('./scripts/cms-core'); +const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core; +const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS); +const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin); +const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin); +const isBrowserNavigation = core.isBrowserNavigation; +const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE); // Deploy version = git short SHA of the checked-out commit. Read once at startup: // a CMS "deploy" is git pull + service restart, so this identifies the running code. @@ -323,7 +258,7 @@ const server = http.createServer(async (req, res) => { res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors })); return; } - const backupFile = backupAndWriteAtomically(FILES[activeFile], data); + const backupFile = backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR); 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) })); @@ -369,6 +304,56 @@ const server = http.createServer(async (req, res) => { return; } + // GET /versions — backup list + optional diff view (browser page, session-auth). + if (req.method === 'GET' && u.pathname === '/versions') { + const versions = listVersions(BACKUP_DIR, activeFile); + let diff = null; + const showRaw = u.searchParams.get('show'); + if (showRaw) { + const safe = safeBackupName(activeFile, showRaw); + if (safe) { + try { + diff = buildVersionDiff(BACKUP_DIR, FILES[activeFile], activeFile, safe); + } catch { /* unreadable backup: render list only */ } + } + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(VERSIONS_PAGE(activeFile, FILE_LABELS[activeFile] || activeFile, versions, diff, CSRF_TOKEN)); + return; + } + + // POST /restore — restore a backup; the current state is backed up first, + // so the restore itself is reversible. Schema validation guards against + // restoring a structurally broken backup. + if (req.method === 'POST' && u.pathname === '/restore') { + const backup = safeBackupName(activeFile, u.searchParams.get('backup') || ''); + if (!backup) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' })); + return; + } + try { + const content = readBackupContent(BACKUP_DIR, backup); + const data = JSON.parse(content); + const validation = validateContent(activeFile, data); + if (!validation.ok) { + writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'validation_failed' }); + res.writeHead(422, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') })); + return; + } + backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR); + writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'ok' }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'error' }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: e.message })); + } + return; + } + // GET / — editor UI let message = null; let jsonData = '{}'; diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index 09cfce7..d48cd4d 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -62,6 +62,14 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció - **➕ Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal). - Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal. +### 🕘 Verziók — korábbi állapotok + +- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít). +- **⚖ Összehasonlítás**: megmutatja, mi változott az adott mentéshez képest (piros = a mentésben lévő régi szöveg, zöld = a jelenlegi). +- **↩ Visszaállítás**: egy kattintással visszaállítja a mentést. A visszaállítás **előtt a jelenlegi tartalom is mentésre kerül**, tehát a visszaállítás is visszavonható. +- A visszaállítás sémaillesztésen megy át — hibás mentést nem lehet visszaállítani. +- Visszaállítás után a nyitott szerkesztő fülek frissítést kérnek (a tartalom megváltozott). + ### 💾 Mentés - A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani. diff --git a/scripts/cms-core.js b/scripts/cms-core.js new file mode 100644 index 0000000..b862d1a --- /dev/null +++ b/scripts/cms-core.js @@ -0,0 +1,101 @@ +// Security and infrastructure helpers for the Content Editor, extracted so +// content-editor.js stays focused on HTTP routing (file-size limits). +// Dependencies (validateLogin, hasValidSession) are injected to avoid cycles. +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const CMS_USER = process.env.CMS_USER; +const CMS_PASS = process.env.CMS_PASS; +const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV; +const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex'); +const rateLimits = new Map(); + +function securityConfigIsValid() { + return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV)); +} + +function getClientAddress(req) { + // The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header. + // WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client + // IP to the list, so the first entry may be a spoofed value sent by the client — using + // it would let attackers bypass the rate limiter with a fresh "IP" per request. + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string' && forwarded.trim()) { + const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean); + if (parts.length > 0) return parts[parts.length - 1]; + } + return req.socket.remoteAddress || 'unknown'; +} + +function exceedsRateLimit(key, limit, windowMs) { + const now = Date.now(); + const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs); + attempts.push(now); + rateLimits.set(key, attempts); + return attempts.length > limit; +} + +function hasValidCredentials(req, validateLogin) { + const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; + const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':'); + return validateLogin(login, password, CMS_USER, CMS_PASS); +} + +function isBrowserNavigation(req) { + return req.method === 'GET' && String(req.headers.accept || '').includes('text/html'); +} + +// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them +// automatically, which would let an already-logged-out browser straight back in. +// Browser navigations therefore authenticate ONLY via the session cookie, so +// logout is final. Non-browser requests (curl, API clients) keep Basic Auth. +function makeIsAuthenticated(hasValidSession, validateLogin) { + return function isAuthenticated(req) { + if (isBrowserNavigation(req)) return hasValidSession(req); + return hasValidCredentials(req, validateLogin) || hasValidSession(req); + }; +} + +function hasValidCsrfToken(req) { + const token = req.headers['x-csrf-token']; + return typeof token === 'string' + && token.length === CSRF_TOKEN.length + && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN)); +} + +function makeWriteAudit(auditFile) { + return function writeAudit(event, details = {}) { + const record = { timestamp: new Date().toISOString(), event, ...details }; + fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 }); + }; +} + +function backupAndWriteAtomically(targetFile, data, backupDir) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`; + const backupFile = path.join(backupDir, backupName); + const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`; + + fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + fs.copyFileSync(targetFile, backupFile); + fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempFile, targetFile); + return backupFile; +} + +module.exports = { + CMS_USER, + CMS_PASS, + CMS_DEPLOY_ENV, + CSRF_TOKEN, + securityConfigIsValid, + getClientAddress, + exceedsRateLimit, + hasValidCredentials, + isBrowserNavigation, + makeIsAuthenticated, + hasValidCsrfToken, + makeWriteAudit, + backupAndWriteAtomically, +}; diff --git a/scripts/cms-diff.js b/scripts/cms-diff.js new file mode 100644 index 0000000..63fe544 --- /dev/null +++ b/scripts/cms-diff.js @@ -0,0 +1,65 @@ +// Dependency-free line diff (LCS) for the CMS version comparison view. +// Input lines are plain text; output entries are typed add/del/ctx rows. + +function diffLines(oldLines, newLines) { + const n = oldLines.length; + const m = newLines.length; + // LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine) + const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const out = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (oldLines[i] === newLines[j]) { + out.push({ type: 'ctx', text: oldLines[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push({ type: 'del', text: oldLines[i] }); + i++; + } else { + out.push({ type: 'add', text: newLines[j] }); + j++; + } + } + while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; } + while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; } + return out; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Keep only ±contextAround context lines around changes to keep pages small. +function trimContext(entries, contextAround = 3) { + const keep = new Array(entries.length).fill(false); + entries.forEach((e, idx) => { + if (e.type !== 'ctx') { + for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true; + } + }); + const out = []; + let skipping = false; + entries.forEach((e, idx) => { + if (keep[idx]) { out.push(e); skipping = false; } + else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; } + }); + return out; +} + +function renderDiffHtml(oldText, newText) { + const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n'))); + return entries.map(e => `
${escapeHtml(e.text) || ' '}
`).join('\n'); +} + +module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml }; diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js index e351e13..5ab40bb 100644 --- a/scripts/cms-pages.js +++ b/scripts/cms-pages.js @@ -113,6 +113,7 @@ ${message ? `
${messag 🔗 Előnézet → ❓ Súgó + 🕘 Verziók v${deployVersion}
@@ -237,4 +238,84 @@ async function login(e) { `; -module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE }; +// Version history page: lists automatic backups of the selected file with a +// diff view (?show=) and one-click restore (POST /restore). +const VERSIONS_PAGE = (fileKey, fileLabel, versions, diff, csrfToken) => ` + + + + + mozdIT — Verziók: ${fileLabel} + + + + +
+

🕘 Verziók — ${fileLabel}

+ ← Vissza a szerkesztőhöz +
+ +
+

Minden Mentés automatikus másolatot készít. A ⚖ Összehasonlítás megmutatja az adott mentés és a jelenlegi tartalom különbségét (piros = mentésben volt, zöld = most van). A visszaállítás előtt a jelenlegi állapot is mentésre kerül, tehát a visszaállítás is visszavonható.

+ + ${versions.length === 0 ? '
Ehhez a fájlhoz még nincs mentés.
' : versions.map(v => ` +
+ ${v.when} + ${v.size} B + + ⚖ Összehasonlítás + + +
`).join('')} + + ${diff ? ` +

Különbség: mentés (${diff.when}) → jelenlegi tartalom

+
${diff.diffHtml}
` : ''} +
+ + + +`; + +module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE }; diff --git a/scripts/cms-versions.js b/scripts/cms-versions.js new file mode 100644 index 0000000..8097b66 --- /dev/null +++ b/scripts/cms-versions.js @@ -0,0 +1,59 @@ +// Version history helpers for the CMS: listing automatic backups from +// .content-backups, safe backup-name validation and diff assembly. +const fs = require('fs'); +const path = require('path'); +const { renderDiffHtml } = require('./cms-diff'); + +// Backup files are named `..json` +const BACKUP_NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/; + +// WHY: the backup name arrives as a query parameter — only allow the exact +// `..json` shape so path traversal (`../`) is impossible. +function safeBackupName(fileKey, candidate) { + if (typeof candidate !== 'string' || !candidate.startsWith(`${fileKey}.`) || !candidate.endsWith('.json')) return null; + const ts = candidate.slice(fileKey.length + 1, -5); + if (!BACKUP_NAME_RE.test(ts)) return null; + return candidate; +} + +function formatBackupTimestamp(fileKey, backupName) { + const ts = backupName.slice(fileKey.length + 1, -5); + const m = ts.match(BACKUP_NAME_RE); + if (!m) return ts; + return `${m[1]} ${m[2]}:${m[3]}:${m[4]}`; +} + +function listVersions(backupDir, fileKey) { + try { + return fs.readdirSync(backupDir) + .filter(name => safeBackupName(fileKey, name)) + .map(name => { + const full = path.join(backupDir, name); + const stat = fs.statSync(full); + return { name, size: stat.size, when: formatBackupTimestamp(fileKey, name) }; + }) + .sort((a, b) => b.name.localeCompare(a.name)); // newest first + } catch { + return []; + } +} + +function readBackupContent(backupDir, backupName) { + return fs.readFileSync(path.join(backupDir, backupName), 'utf8'); +} + +// Compare a backup with the current file content; returns both pretty texts and +// the rendered diff HTML (backup = old/left, current = new/right). +function buildVersionDiff(backupDir, currentFilePath, fileKey, backupName) { + const backupText = readBackupContent(backupDir, backupName); + const currentText = fs.readFileSync(currentFilePath, 'utf8'); + return { + backupName, + when: formatBackupTimestamp(fileKey, backupName), + backupText: backupText.trim(), + currentText: currentText.trim(), + diffHtml: renderDiffHtml(backupText, currentText), + }; +} + +module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp }; diff --git a/scripts/test-content-editor-versions.js b/scripts/test-content-editor-versions.js new file mode 100644 index 0000000..9f09feb --- /dev/null +++ b/scripts/test-content-editor-versions.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +/** + * Integration test for the CMS Versions panel (MITHOME-64): + * 1. GET /versions lists the backups of the file (auth required) + * 2. GET /versions?show= renders a diff vs the current content + * 3. POST /restore restores an older backup; the pre-restore state gets a + * fresh backup too (restore is reversible) + * 4. path traversal backup names are rejected (400) + * 5. restore without CSRF is rejected (403) + */ +const assert = require('assert/strict'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const PORT = 4131; +const BASE = `http://127.0.0.1:${PORT}`; +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-versions-')); +const auditFile = path.join(tmp, 'audit.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: 'versions-test-user', + CMS_PASS: 'versions-test-pass', + CMS_DEPLOY_ENV: 'staging', + }, + stdio: 'ignore', +}); + +async function waitForServer(timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch(`${BASE}/version`); + return; + } catch { + await new Promise(r => setTimeout(r, 200)); + } + } + throw new Error('server did not start'); +} + +async function session() { + const login = await fetch(`${BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: 'versions-test-user', pass: 'versions-test-pass' }), + }); + return (login.headers.get('set-cookie') || '').split(';')[0]; +} + +async function csrfOf(cookie) { + const page = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text(); + return page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1]; +} + +const hashOf = s => crypto.createHash('sha256').update(s.trim()).digest('hex'); + +async function main() { + await waitForServer(); + const cookie = await session(); + const csrf = await csrfOf(cookie); + const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json'); + const original = fs.readFileSync(contentFile, 'utf8'); + const backupDir = path.join(ROOT, '.content-backups'); + const testStartedAt = Date.now(); + + try { + // Create two saves → two backups of intermediate states + const v1 = JSON.parse(original); + v1.hero.subtitle = 'Verzió teszt #1'; + const v2 = JSON.parse(original); + v2.hero.subtitle = 'Verzió teszt #2'; + + for (const variant of [v1, v2]) { + const res = 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(variant, null, 2), + }); + assert.equal(res.status, 200, 'seed save must succeed'); + } + // restore the pristine original as the "current" state for the diff assertion + const third = 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: original, + }); + assert.equal(third.status, 200); + + // 1. versions page lists backups (names appear in the show= comparison links) + const versionsPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text(); + assert.match(versionsPage, /Verziók/); + assert.match(versionsPage, /Visszaállítás/); + const names = [...versionsPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]); + assert.ok(names.length >= 3, `expected at least 3 backups, got ${names.length}`); + // backups of v1 (the oldest seeded state) — pick the one that contains subtitle #1 + // (backups hold the state BEFORE each save: original, v1, v2) + + // 2. diff view: pick the backup that holds "Verzió teszt #1" (created during + // this run) and compare it with the current (original) content + const backupHoldingV1 = fs.readdirSync(backupDir) + .filter(name => name.startsWith('contact.')) + .filter(name => fs.statSync(path.join(backupDir, name)).mtimeMs >= testStartedAt) + .find(name => fs.readFileSync(path.join(backupDir, name), 'utf8').includes('Verzió teszt #1')); + assert.ok(backupHoldingV1, 'seeded backup holding v1 must exist'); + + const diffPage = await (await fetch(`${BASE}/versions?file=contact&show=${backupHoldingV1}`, { headers: { Cookie: cookie } })).text(); + assert.match(diffPage, /diff-del/, 'diff must contain removed lines (backup side)'); + assert.match(diffPage, /diff-add/, 'diff must contain added lines (current side)'); + assert.match(diffPage, /Verzió teszt #1/); + + // 3. restore the v1 backup → file content becomes v1 + const restore = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, { + method: 'POST', + headers: { Cookie: cookie, 'X-CSRF-Token': csrf }, + }); + assert.equal(restore.status, 200); + assert.ok(fs.readFileSync(contentFile, 'utf8').includes('Verzió teszt #1')); + + // restore created a new backup of the pre-restore state (reversibility) + const afterPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text(); + const namesAfter = [...afterPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]); + assert.equal(namesAfter.length, names.length + 1, 'restore must back up the current state first'); + + // 4. traversal is rejected + const evil = await fetch(`${BASE}/restore?file=contact&backup=${encodeURIComponent('../../package.json')}`, { + method: 'POST', + headers: { Cookie: cookie, 'X-CSRF-Token': csrf }, + }); + assert.equal(evil.status, 400); + + // 5. no CSRF → 403 + const noCsrf = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, { + method: 'POST', + headers: { Cookie: cookie }, + }); + assert.equal(noCsrf.status, 403); + + // unauthenticated listing is redirected for browsers / 401 otherwise + const anon = await fetch(`${BASE}/versions?file=contact`); + assert.equal(anon.status, 401); + + console.log('Content Editor versions panel test: OK'); + } finally { + fs.writeFileSync(contentFile, original); // leave the repo pristine + } +} + +main() + .catch(err => { console.error('❌', err.message); process.exitCode = 1; }) + .finally(() => { + child.kill('SIGTERM'); + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } + });