feat(cms): logo upload with preview, backup and audit
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
- 🎨 Logó page in the CMS bottom bar: replace the website header logo and
the CMS login icon with a PNG upload (magic-byte validation, 1 MiB cap)
- the replaced logo gets a timestamped backup in .content-backups; every
upload is audited (logo_updated)
- /logo.png?variant=header serves the header variant for the preview
- publish stages proto/public too, so logo changes ride the same
commit+deploy pipeline as content
- route handling extracted to scripts/cms-logo.js to stay under the
400-line limit
- integration test: upload+replace+backup, variant preview, 415/413/400,
CSRF, auth
Closes MITHOME-65
This commit is contained in:
+23
-50
@@ -16,7 +16,8 @@ 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 { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
|
||||
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
|
||||
|
||||
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
||||
// WHY: overridable so the publish integration test can run against a throwaway
|
||||
@@ -51,6 +52,7 @@ const FILE_LABELS = {
|
||||
};
|
||||
|
||||
const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages');
|
||||
const { LOGO_PAGE } = require('./scripts/cms-logo-page');
|
||||
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.
|
||||
@@ -104,7 +106,9 @@ const server = http.createServer(async (req, res) => {
|
||||
// Public: logo asset for the login page.
|
||||
if (req.method === 'GET' && u.pathname === '/logo.png') {
|
||||
try {
|
||||
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', 'mozdit_logo.png'));
|
||||
// ?variant=header serves the website header logo (branding page preview).
|
||||
const file = u.searchParams.get('variant') === 'header' ? LOGO_TARGETS.header : LOGO_TARGETS.icon;
|
||||
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', file));
|
||||
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
|
||||
res.end(logo);
|
||||
} catch {
|
||||
@@ -304,55 +308,24 @@ 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;
|
||||
}
|
||||
// GET /versions + POST /restore — handled in scripts/cms-versions.js.
|
||||
if (handleVersionRoutes({
|
||||
req, res, u, activeFile,
|
||||
backupDir: BACKUP_DIR,
|
||||
currentFile: FILES[activeFile],
|
||||
validate: validateContent,
|
||||
writeAudit, clientAddress, user: CMS_USER,
|
||||
versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), 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 /branding + POST /logo — handled in scripts/cms-logo.js.
|
||||
if (handleLogoRoutes({
|
||||
req, res, u,
|
||||
publicDir: path.join(__dirname, 'proto', 'public'),
|
||||
backupDir: BACKUP_DIR,
|
||||
writeAudit, clientAddress, user: CMS_USER,
|
||||
logoPage: () => LOGO_PAGE(CSRF_TOKEN),
|
||||
})) return;
|
||||
|
||||
// GET / — editor UI
|
||||
let message = null;
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 🎨 Logó kezelése
|
||||
|
||||
- Az alsó sáv **🎨 Logó** gombja megnyitja a logókezelő oldalt.
|
||||
- Két logó cserélhető: a **weboldal fejléclogója** (szöveges) és a **CMS bejelentkező oldal ikonja**.
|
||||
- Csak **PNG**, max. **1 MB**; ajánlott átlátszó háttér a sötét fejléchez.
|
||||
- A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában).
|
||||
- A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg.
|
||||
|
||||
### 🕘 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).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Branding page for the Content Editor: upload/replace the two logos with
|
||||
// client-side preview. Kept separate from cms-pages.js (file-size limits).
|
||||
const { LOGO_TARGETS } = require('./cms-logo');
|
||||
|
||||
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>mozdIT — Logó kezelése</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: 720px; margin: 0 auto; padding: 28px 24px; }
|
||||
.note { color: #94a3b8; font-size: 14px; margin-bottom: 22px; }
|
||||
.card { background: #1a2035; border: 1px solid #2d3748; border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
|
||||
.card h2 { font-size: 16px; color: #93c5fd; margin-bottom: 4px; }
|
||||
.card .where { color: #64748b; font-size: 13px; margin-bottom: 14px; }
|
||||
.preview { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 22px 22px; border: 1px solid #2d3748; border-radius: 10px; padding: 16px; margin-bottom: 14px; text-align: center; min-height: 90px; }
|
||||
.preview img { max-width: 100%; max-height: 72px; }
|
||||
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
|
||||
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
|
||||
button { background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 10px 22px; font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button:disabled { opacity: .5; cursor: wait; }
|
||||
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
|
||||
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>🎨 Logó kezelése</h1>
|
||||
<a href="/">← Vissza a szerkesztőhöz</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="note">Csak <strong>PNG</strong> fájl, max. 1 MB. A régi logó mentésre kerül (a 🕘 Verziókhoz hasonlóan visszavonható). A CMS-belei változás azonnal, a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg. Ajánlott átlátszó háttérű PNG a sötét fejléchez.</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Weboldal fejléc logója (szöveges)</h2>
|
||||
<p class="where">Használat: weboldal fejléc — jelenlegi fájl: /${LOGO_TARGETS.header}</p>
|
||||
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
|
||||
<input type="file" id="file-header" accept="image/png">
|
||||
<div class="meta" id="meta-header"></div>
|
||||
<button onclick="upload('header')">⬆ Fejléc logó cseréje</button>
|
||||
<p class="msg" id="msg-header"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>CMS logó (ikon)</h2>
|
||||
<p class="where">Használat: CMS bejelentkező oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}</p>
|
||||
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
|
||||
<input type="file" id="file-icon" accept="image/png">
|
||||
<div class="meta" id="meta-icon"></div>
|
||||
<button onclick="upload('icon')">⬆ Ikon logó cseréje</button>
|
||||
<p class="msg" id="msg-icon"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const CSRF_TOKEN = "${csrfToken}";
|
||||
function setMsg(target, text, ok) {
|
||||
const el = document.getElementById('msg-' + target);
|
||||
el.textContent = text;
|
||||
el.className = 'msg ' + (ok ? 'ok' : 'err');
|
||||
}
|
||||
async function upload(target) {
|
||||
const file = document.getElementById('file-' + target).files[0];
|
||||
const msg = t => setMsg(target, t, false);
|
||||
if (!file) { msg('Először válassz PNG fájlt.'); return; }
|
||||
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
|
||||
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
|
||||
const btn = event.target; btn.disabled = true;
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const res = await fetch('/logo?target=' + target, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
|
||||
body: bytes
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const json = await res.json();
|
||||
if (json.ok) {
|
||||
setMsg(target, '✅ Cserélve. (A weboldalon a Publikálás után jelenik meg.)', true);
|
||||
document.getElementById('prev-' + target).src = '/logo.png?t=' + Date.now();
|
||||
} else msg('❌ ' + json.error);
|
||||
} catch (e) { msg('❌ Hálózati hiba'); }
|
||||
btn.disabled = false;
|
||||
}
|
||||
document.querySelectorAll('input[type=file]').forEach(inp => {
|
||||
inp.addEventListener('change', () => {
|
||||
const f = inp.files[0];
|
||||
const meta = document.getElementById('meta-' + inp.id.replace('file-', ''));
|
||||
if (f) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
module.exports = { LOGO_PAGE };
|
||||
@@ -0,0 +1,92 @@
|
||||
// Logo upload handling for the Content Editor: PNG validation, timestamped
|
||||
// backup and atomic binary replace.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_LOGO_BYTES = 1024 * 1024; // 1 MiB — plenty for a logo
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// WHY fixed targets instead of a client-supplied filename: arbitrary write
|
||||
// paths would be a traversal risk; the two known logos are the only assets
|
||||
// the site consumes.
|
||||
const LOGO_TARGETS = {
|
||||
icon: 'mozdit_logo.png', // CMS login page
|
||||
header: 'mozdit_logo_text.png', // website Header
|
||||
};
|
||||
|
||||
function isPng(buffer) {
|
||||
return Buffer.isBuffer(buffer) && buffer.length >= PNG_MAGIC.length && buffer.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
|
||||
}
|
||||
|
||||
function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) {
|
||||
const fileName = LOGO_TARGETS[targetKey];
|
||||
if (!fileName) throw new Error('Ismeretlen logó célpont');
|
||||
const targetFile = path.join(publicDir, fileName);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `${fileName}.${timestamp}.bak`;
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(targetFile, path.join(backupDir, backupName));
|
||||
|
||||
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
|
||||
fs.renameSync(tempFile, targetFile);
|
||||
return { targetFile, backupName };
|
||||
}
|
||||
|
||||
// WHY: route handling lives here so content-editor.js stays under the
|
||||
// 400-line limit. Returns true when the request was handled.
|
||||
function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clientAddress, user, logoPage }) {
|
||||
if (req.method === 'GET' && u.pathname === '/branding') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(logoPage());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && u.pathname === '/logo') {
|
||||
const target = u.searchParams.get('target') || '';
|
||||
if (!LOGO_TARGETS[target]) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: 'Ismeretlen logó célpont.' }));
|
||||
return true;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let tooLarge = false;
|
||||
req.on('data', c => {
|
||||
total += c.length;
|
||||
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (tooLarge) {
|
||||
writeAudit('logo_updated', { clientAddress, user, target, result: 'request_too_large' });
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
|
||||
return;
|
||||
}
|
||||
if (!isPng(buffer)) {
|
||||
writeAudit('logo_updated', { clientAddress, user, target, result: 'invalid_type' });
|
||||
res.writeHead(415, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { backupName } = saveLogoAtomically(publicDir, target, buffer, backupDir);
|
||||
writeAudit('logo_updated', { clientAddress, user, target, result: 'ok', backup: backupName });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, backup: backupName }));
|
||||
} catch (e) {
|
||||
writeAudit('logo_updated', { clientAddress, user, target, result: 'error' });
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, handleLogoRoutes };
|
||||
@@ -114,6 +114,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
||||
<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>
|
||||
<a href="/branding" target="_blank" class="preview-link">🎨 Logó</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>
|
||||
|
||||
@@ -18,6 +18,9 @@ const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
|
||||
function buildPublishCommand(commitMessage) {
|
||||
return [
|
||||
'git add .',
|
||||
// WHY: logo uploads live in proto/public — outside the content cwd — so
|
||||
// stage them too (tolerant: optional path in test throwaway repos).
|
||||
'(git add ../public || true)',
|
||||
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
|
||||
'(git pull --rebase origin main || (git rebase --abort; false))',
|
||||
'git push origin main',
|
||||
|
||||
@@ -25,7 +25,7 @@ const ROOT = path.join(__dirname, '..');
|
||||
// ── Unit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
|
||||
assert.ok(cmd.startsWith('git add . && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
|
||||
assert.ok(cmd.startsWith('git add . && (git add ../public || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
|
||||
assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
|
||||
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Integration test for CMS logo upload (MITHOME-65):
|
||||
* 1. GET /branding serves the logo page (session-auth)
|
||||
* 2. POST /logo?target=icon with a valid PNG replaces the file, backs up the
|
||||
* old one into .content-backups and audits logo_updated
|
||||
* 3. non-PNG bytes → 415; >1 MiB → 413; bad target → 400; no CSRF → 403;
|
||||
* unauthenticated → 401
|
||||
* Original logo files are restored at the end.
|
||||
*/
|
||||
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, '..');
|
||||
const PORT = 4132;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-logo-'));
|
||||
const auditFile = path.join(tmp, 'audit.jsonl');
|
||||
|
||||
// Minimal valid 1x1 transparent PNG
|
||||
const TINY_PNG = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da636460f8ff9f0001040100c9fe92ef0000000049454e44ae426082',
|
||||
'hex'
|
||||
);
|
||||
|
||||
const child = spawn('node', ['content-editor.js'], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
CONTENT_EDITOR_PORT: String(PORT),
|
||||
CONTENT_EDITOR_AUDIT_FILE: auditFile,
|
||||
CMS_USER: 'logo-test-user',
|
||||
CMS_PASS: 'logo-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 main() {
|
||||
await waitForServer();
|
||||
const login = await fetch(`${BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user: 'logo-test-user', pass: 'logo-test-pass' }),
|
||||
});
|
||||
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
|
||||
const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text();
|
||||
const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
|
||||
|
||||
const iconPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo.png');
|
||||
const headerPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo_text.png');
|
||||
const originalIcon = fs.readFileSync(iconPath);
|
||||
const originalHeader = fs.readFileSync(headerPath);
|
||||
const backupDir = path.join(ROOT, '.content-backups');
|
||||
|
||||
try {
|
||||
// 1. branding page
|
||||
const branding = await fetch(`${BASE}/branding`, { headers: { Cookie: cookie } });
|
||||
assert.equal(branding.status, 200);
|
||||
assert.match(await branding.text(), /Logó kezelése/);
|
||||
|
||||
// 2. valid upload replaces the file and creates a backup
|
||||
const before = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
|
||||
const up = await fetch(`${BASE}/logo?target=icon`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
|
||||
body: TINY_PNG,
|
||||
});
|
||||
assert.equal(up.status, 200);
|
||||
const upBody = await up.json();
|
||||
assert.equal(upBody.ok, true);
|
||||
assert.match(upBody.backup, /^mozdit_logo\.png\./);
|
||||
assert.deepEqual(fs.readFileSync(iconPath), TINY_PNG, 'icon file replaced');
|
||||
const after = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
|
||||
assert.equal(after.length, before.length + 1, 'old logo backed up');
|
||||
// audit entry
|
||||
const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
assert.ok(audit.some(e => e.event === 'logo_updated' && e.result === 'ok'));
|
||||
|
||||
// variant preview route serves the header logo
|
||||
const headerPreview = await fetch(`${BASE}/logo.png?variant=header`);
|
||||
assert.equal(headerPreview.status, 200);
|
||||
assert.deepEqual(Buffer.from(await headerPreview.arrayBuffer()), originalHeader);
|
||||
|
||||
// 3a. non-PNG → 415
|
||||
const bad = await fetch(`${BASE}/logo?target=icon`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
|
||||
body: Buffer.from('definitely not a png'),
|
||||
});
|
||||
assert.equal(bad.status, 415);
|
||||
|
||||
// 3b. oversized → 413
|
||||
const big = Buffer.alloc(1024 * 1024 + 1);
|
||||
big.set(TINY_PNG.subarray(0, 8));
|
||||
const tooBig = await fetch(`${BASE}/logo?target=icon`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
|
||||
body: big,
|
||||
});
|
||||
assert.equal(tooBig.status, 413);
|
||||
|
||||
// 3c. bad target → 400
|
||||
const badTarget = await fetch(`${BASE}/logo?target=../../etc`,
|
||||
{ method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
|
||||
assert.equal(badTarget.status, 400);
|
||||
|
||||
// 3d. authenticated but no CSRF → 403
|
||||
const noCsrf = await fetch(`${BASE}/logo?target=icon`,
|
||||
{ method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png' }, body: TINY_PNG });
|
||||
assert.equal(noCsrf.status, 403);
|
||||
|
||||
// 3e. unauthenticated (valid CSRF token but no session) → 401
|
||||
const anon = await fetch(`${BASE}/logo?target=icon`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
|
||||
assert.equal(anon.status, 401);
|
||||
|
||||
console.log('Content Editor logo upload test: OK');
|
||||
} finally {
|
||||
fs.writeFileSync(iconPath, originalIcon);
|
||||
fs.writeFileSync(headerPath, originalHeader);
|
||||
}
|
||||
}
|
||||
|
||||
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