diff --git a/.gitignore b/.gitignore index 98c4f71..542114b 100755 --- a/.gitignore +++ b/.gitignore @@ -25,13 +25,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files +# local env files (.env.* covers staging/production variants; examples stay tracked) .env*.local .env -.env.staging -.env.production -.env.staging.* -.env.production.* .env.* !.env.staging.example !.env.production.example diff --git a/content-editor.js b/content-editor.js index c05bb9a..1db5b97 100644 --- a/content-editor.js +++ b/content-editor.js @@ -15,9 +15,12 @@ const { exec } = require('child_process'); 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 PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001; -const CONTENT_DIR = path.join(__dirname, 'proto', 'src', 'content'); +// WHY: overridable so the publish integration test can run against a throwaway +// git clone instead of the real repository. +const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content'); const BACKUP_DIR = path.join(__dirname, '.content-backups'); const MAX_REQUEST_BODY_BYTES = 256 * 1024; const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl'); @@ -300,25 +303,26 @@ const server = http.createServer(async (req, res) => { return; } - // WHY: (git diff --cached --quiet || git commit) ensures we only commit when - // staged changes exist. git pull --rebase origin main integrates remote changes - // (or unpushed local commits) cleanly before git push origin main. - const publishCmd = 'git add . && (git diff --cached --quiet || git commit -m "content: frissítve a CMS-ből") && git pull --rebase origin main && git push origin main'; - - exec(publishCmd, { cwd: CONTENT_DIR }, (error, stdout, stderr) => { + // Command shape and result classification live in scripts/cms-publish.js + // (WHY comments there): commit only when staged changes exist, rebase with + // abort-on-failure, deterministic no-changes marker instead of output matching. + exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => { res.writeHead(200, { 'Content-Type': 'application/json' }); - const combinedOutput = `${stdout}\n${stderr}`; - const isNoChanges = /nothing to commit|nothing added to commit|working tree clean|everything up-to-date|already up to date/i.test(combinedOutput); - - if (error && !isNoChanges) { - writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'error' }); - res.end(JSON.stringify({ ok: false, error: stderr || stdout || error.message })); - } else { - // Deploy only the explicitly configured environment; never default to production. - exec(`cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1 &`); - writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: isNoChanges ? 'no_changes' : 'ok' }); - res.end(JSON.stringify({ ok: true, output: stdout || 'Sikeres publikálás' })); + const outcome = interpretPublishResult(error, stdout, stderr); + writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result }); + if (!outcome.ok) { + res.end(JSON.stringify({ ok: false, error: outcome.error })); + return; } + // Deploy only when content actually changed — a no-op publish must not + // trigger a rebuild. Deploy only the explicitly configured environment; + // never default to production. Overridable for tests. + if (outcome.hadChanges) { + const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD + || `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1 &`; + exec(deployCmd); + } + res.end(JSON.stringify({ ok: true, output: outcome.output })); }); return; } diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index 8e0baca..b013ea4 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -71,7 +71,7 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció ### 🚀 Publikálás - A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt. -- „No changes to commit" üzenet: nincs új változtatás — ez **nem hiba**. +- „Nincs új változtatás." üzenet: nincs új mentett változtatás — ez **nem hiba**, ilyenkor deploy sem indul. - A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére. - A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted. diff --git a/scripts/cms-publish.js b/scripts/cms-publish.js new file mode 100644 index 0000000..f2606a9 --- /dev/null +++ b/scripts/cms-publish.js @@ -0,0 +1,40 @@ +// Publish (git commit + push) command construction and result interpretation +// for the Content Editor. Extracted so it is unit-testable in isolation. +// +// WHY the shell shape: +// - `git diff --cached --quiet && echo MARKER || git commit` — commit only when +// staged changes exist; a skipped commit must NOT produce a failing exit code +// (that was the original bug: "nothing added to commit" surfaced as an error). +// - the MARKER echo is the only reliable signal for "no content changes": plain +// output matching ("Already up to date", "Everything up-to-date") also appears +// after REAL publishes (the pull prints it when the remote did not move), which +// used to misclassify genuine publishes as no-ops. +// - `git pull --rebase || (git rebase --abort; false)` — a failed rebase must be +// aborted, otherwise the repo stays mid-rebase and every later publish fails +// with "cannot pull with rebase". + +const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__'; + +function buildPublishCommand(commitMessage) { + return [ + 'git add .', + `(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', + ].join(' && '); +} + +function interpretPublishResult(error, stdout, stderr) { + const hadChanges = !stdout.includes(NO_CHANGES_MARKER); + if (error) { + return { ok: false, hadChanges, result: 'error', error: stderr || stdout || error.message }; + } + return { + ok: true, + hadChanges, + result: hadChanges ? 'ok' : 'no_changes', + output: hadChanges ? stdout : 'Nincs új változtatás.', + }; +} + +module.exports = { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult }; diff --git a/scripts/test-cms-publish.js b/scripts/test-cms-publish.js new file mode 100644 index 0000000..eb03154 --- /dev/null +++ b/scripts/test-cms-publish.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +/** + * Tests for the CMS publish flow (MITHOME-60): + * + * Unit (scripts/cms-publish.js): + * - command shape: marker echo, conditional commit, rebase-abort fallback + * - result interpretation: marker → no_changes; no marker → ok; error → error + * + * Integration (real server + throwaway git repos): + * 1. publish with no changes → ok, "Nincs új változtatás.", NO deploy + * 2. publish with real changes → ok, commit pushed, deploy ran + * 3. publish with rebase conflict → error reported, rebase aborted (repo not + * left mid-rebase), push never happened, no deploy + */ +const assert = require('assert/strict'); +const { execFileSync, spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult } = require('../scripts/cms-publish'); +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.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback'); +assert.ok(cmd.endsWith('git push origin main'), 'push last'); + +const noChanges = interpretPublishResult(null, `__NO_CONTENT_CHANGES__\nAlready up to date.\nTo ssh://…\n * [new] nothing`, ''); +assert.equal(noChanges.ok, true); +assert.equal(noChanges.hadChanges, false); +assert.equal(noChanges.result, 'no_changes'); +assert.equal(noChanges.output, 'Nincs új változtatás.'); + +// Real publish on an unmoved remote: pull prints "Already up to date." AND the +// push line — the old regex misclassified this as no_changes; the marker must win. +const realPublish = interpretPublishResult(null, '[main abc1234] content: frissítve a CMS-ből\n 1 file changed\nAlready up to date.\nTo ssh://git…\n c2cc701..84a4527 main -> main\n', ''); +assert.equal(realPublish.ok, true); +assert.equal(realPublish.hadChanges, true); +assert.equal(realPublish.result, 'ok'); + +const failed = interpretPublishResult(new Error('exit 1'), 'CONFLICT (content): Merge conflict in x\nerror: could not apply…', 'error: Failed to rebase'); +assert.equal(failed.ok, false); +assert.equal(failed.result, 'error'); +assert.equal(failed.error, 'error: Failed to rebase'); + +console.log('cms-publish unit tests: OK'); + +// ── Integration ────────────────────────────────────────────────────────────── + +function git(cwd, ...args) { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }); +} + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-publish-')); +const origin = path.join(tmp, 'origin.git'); +const work = path.join(tmp, 'work'); +const other = path.join(tmp, 'other'); +const deployLog = path.join(tmp, 'deploy.log'); +const auditFile = path.join(tmp, 'audit.jsonl'); +const PORT = 4127; +const BASE = `http://127.0.0.1:${PORT}`; + +execFileSync('git', ['init', '--bare', '-b', 'main', origin]); +execFileSync('git', ['clone', origin, work]); +for (const repo of [work]) git(repo, 'config', 'user.email', 'test@test.hu'), git(repo, 'config', 'user.name', 'Test'); +fs.writeFileSync(path.join(work, 'home.json'), '{"v": 1}\n'); +git(work, 'add', '.'); +git(work, 'commit', '-m', 'init'); +git(work, 'push', '-u', 'origin', 'main'); + +const child = spawn('node', ['content-editor.js'], { + cwd: ROOT, + env: { + ...process.env, + CONTENT_EDITOR_PORT: String(PORT), + CONTENT_EDITOR_AUDIT_FILE: auditFile, + CONTENT_EDITOR_CONTENT_DIR: work, + CONTENT_EDITOR_DEPLOY_CMD: `echo deploy >> ${deployLog}`, + CMS_USER: 'pub-test-user', + CMS_PASS: 'pub-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}/logout`); // rate-limit-free probe + return; + } catch { + await new Promise(r => setTimeout(r, 200)); + } + } + throw new Error('server did not start'); +} + +async function publish() { + const login = await fetch(`${BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: 'pub-test-user', pass: 'pub-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 res = await fetch(`${BASE}/publish`, { method: 'POST', headers: { Cookie: cookie, 'X-CSRF-Token': csrf } }); + return { status: res.status, body: await res.json() }; +} + +function deployCount() { + try { return fs.readFileSync(deployLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; } +} + +// The deploy command runs detached after the response — poll for its effect. +async function waitForDeployCount(expected, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (deployCount() === expected) return; + await new Promise(r => setTimeout(r, 100)); + } + assert.equal(deployCount(), expected); +} + +async function main() { + await waitForServer(); + + // 1. no changes → ok, no deploy + const r1 = await publish(); + assert.equal(r1.status, 200); + assert.equal(r1.body.ok, true); + assert.equal(r1.body.output, 'Nincs új változtatás.'); + assert.equal(deployCount(), 0, 'no-op publish must not deploy'); + + // 2. real change → ok, pushed, deploy ran + fs.writeFileSync(path.join(work, 'home.json'), '{"v": 2}\n'); + const r2 = await publish(); + assert.equal(r2.body.ok, true); + await waitForDeployCount(1); + assert.match(git(work, 'log', '-1', '--format=%s'), /content: frissítve a CMS-ből/); + assert.match(git(work, 'status', '--porcelain'), /^$/, 'worktree clean after publish'); + + // 3. rebase conflict → error, rebase aborted, no push, no deploy + execFileSync('git', ['clone', origin, other]); + for (const args of [['config', 'user.email', 'o@test.hu'], ['config', 'user.name', 'Other']]) git(other, ...args); + fs.writeFileSync(path.join(other, 'home.json'), '{"v": "remote"}\n'); + git(other, 'add', '.'); + git(other, 'commit', '-m', 'remote edit'); + git(other, 'push', 'origin', 'main'); + + fs.writeFileSync(path.join(work, 'home.json'), '{"v": "local"}\n'); + const r3 = await publish(); + assert.equal(r3.body.ok, false, 'conflicting publish must report an error'); + await new Promise(r => setTimeout(r, 700)); // give a would-be deploy time to (not) appear + assert.equal(deployCount(), 1, 'failed publish must not deploy'); + // The local commit exists locally (created before the rebase) but must NOT be pushed. + assert.equal(git(work, 'log', '-1', '--format=%s').trim(), 'content: frissítve a CMS-ből'); + const localCommit = git(work, 'rev-parse', 'HEAD'); + assert.ok(!git(work, 'ls-remote', origin, 'refs/heads/main').includes(localCommit), 'local conflicted commit must not be pushed'); + assert.ok(!git(work, 'status').includes('rebase in progress'), 'rebase must be aborted'); + + // audit trail classification + const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l)); + const publishResults = audit.filter(e => e.event === 'publish_finished').map(e => e.result); + assert.deepEqual(publishResults, ['no_changes', 'ok', 'error']); + + console.log('Content Editor publish integration test: OK'); +} + +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 */ } + });