feat(cms): version history panel with diff view and one-click restore
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
- GET /versions lists the automatic backups of the selected file (timestamp,
size); ?show=<backup> renders a line diff against the current content
- POST /restore validates the backup against the content schema and restores
it atomically; the pre-restore state gets a fresh backup first, so a
restore itself is reversible; audited as version_restored
- dependency-free LCS line diff (scripts/cms-diff.js) with add/del
highlighting and context trimming; backup names validated against a strict
pattern (path traversal impossible)
- new 🕘 Verziók entry in the CMS bottom bar
- refactor: security/infra helpers extracted to scripts/cms-core.js to keep
content-editor.js under the 400-line hard limit
- integration test: list, diff, restore + reversibility backup, traversal
rejection, CSRF enforcement, auth
Closes MITHOME-64
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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, '>')
|
||||
.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 => `<div class="diff-${e.type}">${escapeHtml(e.text) || ' '}</div>`).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
|
||||
+82
-1
@@ -113,6 +113,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
||||
<span class="save-status" id="saveStatus"></span>
|
||||
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
|
||||
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
|
||||
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
|
||||
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
|
||||
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
||||
</div>
|
||||
@@ -237,4 +238,84 @@ async function login(e) {
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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) => `<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>mozdIT — Verziók: ${fileLabel}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
|
||||
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
|
||||
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
|
||||
header a:hover { color: #e2e8f0; }
|
||||
main { max-width: 860px; margin: 0 auto; padding: 28px 24px; }
|
||||
.note { color: #94a3b8; font-size: 14px; margin-bottom: 18px; }
|
||||
.ver { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.ver .when { font-family: monospace; font-size: 14px; color: #93c5fd; }
|
||||
.ver .size { color: #64748b; font-size: 13px; }
|
||||
.ver .actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.btn { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; text-decoration: none; }
|
||||
.btn:hover { background: #374151; }
|
||||
.btn-restore { background: #14532d; border-color: #10b981; color: #6ee7b7; }
|
||||
.btn-restore:hover { background: #166534; }
|
||||
h2 { font-size: 16px; margin: 26px 0 10px; color: #93c5fd; }
|
||||
.diff { background: #0f1420; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; font-family: monospace; font-size: 13px; overflow-x: auto; }
|
||||
.diff div { padding: 1px 10px; white-space: pre-wrap; word-break: break-all; }
|
||||
.diff-add { background: #064e3b; color: #6ee7b7; }
|
||||
.diff-del { background: #450a0a; color: #fca5a5; text-decoration: line-through; }
|
||||
.diff-skip { color: #475569; }
|
||||
.diff-ctx { color: #94a3b8; }
|
||||
.empty { color: #64748b; padding: 24px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>🕘 Verziók — ${fileLabel}</h1>
|
||||
<a href="/?file=${fileKey}">← Vissza a szerkesztőhöz</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="note">Minden Mentés automatikus másolatot készít. A ⚖ Összehasonlítás megmutatja az adott mentés és a <strong>jelenlegi</strong> 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ó.</p>
|
||||
|
||||
${versions.length === 0 ? '<div class="empty">Ehhez a fájlhoz még nincs mentés.</div>' : versions.map(v => `
|
||||
<div class="ver">
|
||||
<span class="when">${v.when}</span>
|
||||
<span class="size">${v.size} B</span>
|
||||
<span class="actions">
|
||||
<a class="btn" href="/versions?file=${fileKey}&show=${v.name}">⚖ Összehasonlítás</a>
|
||||
<button class="btn btn-restore" onclick="restore('${v.name}')">↩ Visszaállítás</button>
|
||||
</span>
|
||||
</div>`).join('')}
|
||||
|
||||
${diff ? `
|
||||
<h2>Különbség: mentés (${diff.when}) → jelenlegi tartalom</h2>
|
||||
<div class="diff">${diff.diffHtml}</div>` : ''}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const CSRF_TOKEN = "${csrfToken}";
|
||||
const FILE = "${fileKey}";
|
||||
async function restore(name) {
|
||||
if (!confirm('Biztosan visszaállítod ezt a mentést?\\nA jelenlegi tartalom mentésre kerül, így ez később is visszavonható.')) return;
|
||||
try {
|
||||
const res = await fetch('/restore?file=' + FILE + '&backup=' + encodeURIComponent(name), {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': CSRF_TOKEN }
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const json = await res.json();
|
||||
if (json.ok) { alert('✅ Visszaállítva.'); location.href = '/?file=' + FILE; }
|
||||
else alert('❌ Hiba: ' + json.error);
|
||||
} catch (e) { alert('❌ Hálózati hiba'); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE };
|
||||
|
||||
@@ -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 `<fileKey>.<ISO-ish timestamp>.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
|
||||
// `<fileKey>.<timestamp>.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 };
|
||||
@@ -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=<backup> 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 */ }
|
||||
});
|
||||
Reference in New Issue
Block a user