#!/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); // 4. partner logo upload (MITHOME-83) const partnerDir = path.join(ROOT, 'proto', 'public', 'partners'); const partnerFile = path.join(partnerDir, 'acme.png'); try { const up = await fetch(`${BASE}/partner-logo?name=acme`, { method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, body: TINY_PNG, }); assert.equal(up.status, 200); const body = await up.json(); assert.equal(body.ok, true); assert.equal(body.path, '/partners/acme.png'); assert.ok(fs.existsSync(partnerFile), 'partner logo file created'); // non-PNG → 415 const bad = await fetch(`${BASE}/partner-logo?name=x`, { method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, body: Buffer.from('not a png'), }); assert.equal(bad.status, 415); // traversal name is sanitized (no path escape) const trav = await fetch(`${BASE}/partner-logo?name=../evil`, { method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, body: TINY_PNG, }); assert.equal(trav.status, 200); const tBody = await trav.json(); assert.ok(tBody.path.startsWith('/partners/'), 'traversal name is sanitized to a safe slug'); assert.ok(!tBody.path.includes('..'), 'no traversal in the returned path'); } finally { try { fs.unlinkSync(partnerFile); } catch { /* noop */ } try { fs.rmdirSync(partnerDir); } catch { /* not empty */ } } 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 */ } });