feat: harden content workflows and staging smoke tests
CI — Test & Build / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI — Test & Build / 🏗️ Build Docker Image (push) Blocked by required conditions
CI — Test & Build / 🐳 Docker integration & API E2E (push) Blocked by required conditions
CI — Test & Build / 🌐 Staging Playwright smoke (push) Waiting to run

This commit is contained in:
Do Siki
2026-08-17 16:08:57 +02:00
parent 35e341d5b2
commit 0047e1b441
28 changed files with 1208 additions and 360 deletions
+87
View File
@@ -8,6 +8,9 @@ on:
branches: [main, develop] branches: [main, develop]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
schedule:
- cron: '17 4 * * *'
env: env:
NODE_VERSION: '20' NODE_VERSION: '20'
@@ -71,3 +74,87 @@ jobs:
docker cp mozdit-app-ci:/app/.next/BUILD_ID ./build-id 2>/dev/null || \ docker cp mozdit-app-ci:/app/.next/BUILD_ID ./build-id 2>/dev/null || \
echo "BUILD_ID nem elérhető — build verification skip" echo "BUILD_ID nem elérhető — build verification skip"
docker rm mozdit-app-ci docker rm mozdit-app-ci
docker-integration:
name: 🐳 Docker integration & API E2E
runs-on: ubuntu-latest
needs: test
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: proto/package-lock.json
- name: 📥 Install dependencies
working-directory: ./proto
run: npm ci
- name: 🐳 Start Docker development stack
run: docker compose -f docker-compose.dev.yml up --build -d
- name: ⏳ Wait for application health
run: |
for attempt in $(seq 1 30); do
if curl --fail --silent http://localhost:8080/api/health > /dev/null; then
exit 0
fi
sleep 2
done
docker compose -f docker-compose.dev.yml logs --tail=100
exit 1
- name: 🔗 Run integration tests
working-directory: ./proto
run: npm run test:integration
- name: 🔄 Run API E2E tests
working-directory: ./proto
run: npm run test:e2e
- name: 🧹 Stop Docker development stack
if: always()
run: docker compose -f docker-compose.dev.yml down -v
staging-smoke:
name: 🌐 Staging Playwright smoke
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: proto/package-lock.json
- name: 📥 Install dependencies
working-directory: ./proto
run: npm ci
- name: 🌐 Install Chromium for Playwright
working-directory: ./proto
run: npx playwright install --with-deps chromium
- name: 🧪 Run staging smoke suite
working-directory: ./proto
env:
BASE_URL: https://stage.mozdit.hu
run: npm run test:smoke:staging
- name: 📦 Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: staging-smoke-artifacts
path: |
proto/playwright-report/
proto/test-results/
retention-days: 30
+5
View File
@@ -42,3 +42,8 @@ mcp.json
# Plane sync backup fájlok # Plane sync backup fájlok
TODO.md.backup.* TODO.md.backup.*
.content-backups/
.content-editor-audit.jsonl
.playwright-mcp/
proto/playwright-report/
proto/test-results/
+213 -44
View File
@@ -12,9 +12,17 @@ const http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { exec } = require('child_process'); const { exec } = require('child_process');
const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema');
const PORT = 4001; const PORT = 4001;
const CONTENT_DIR = path.join(__dirname, 'proto', 'src', 'content'); const 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 = path.join(__dirname, '.content-editor-audit.jsonl');
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const AUTH_MAX_ATTEMPTS = 5;
const PUBLISH_MAX_ATTEMPTS = 3;
const FILES = { const FILES = {
common: path.join(CONTENT_DIR, 'common.json'), common: path.join(CONTENT_DIR, 'common.json'),
@@ -36,7 +44,7 @@ const FILE_LABELS = {
hasznalatiFeltetelek: '⚖️ ÁSZF', hasznalatiFeltetelek: '⚖️ ÁSZF',
}; };
const HTML = (activeFile, jsonData, message) => `<!DOCTYPE html> const HTML = (activeFile, jsonData, message, csrfToken) => `<!DOCTYPE html>
<html lang="hu"> <html lang="hu">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -145,6 +153,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
<script> <script>
const DATA = JSON.parse(document.getElementById('page-data').textContent); const DATA = JSON.parse(document.getElementById('page-data').textContent);
const FILE = "${activeFile}"; const FILE = "${activeFile}";
const CSRF_TOKEN = "${csrfToken}";
// ── Render ────────────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────────────
@@ -170,11 +179,20 @@ function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<'); const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'field'; div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = \`<input type="checkbox" data-path="\${path}" data-type="boolean" \${val ? 'checked' : ''}>\`;
} else if (type === 'number') {
control = \`<input type="number" data-path="\${path}" data-type="number" value="\${esc(val)}">\`;
} else {
control = isLong
? \`<textarea data-path="\${path}" data-type="\${type}" rows="\${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">\${esc(val ?? '')}<\/textarea>\`
: \`<input type="text" data-path="\${path}" data-type="\${type}" value="\${esc(val ?? '')}">\`;
}
div.innerHTML = \` div.innerHTML = \`
<label>\${path}</label> <label>\${path}</label>
\${isLong \${control}
? \`<textarea data-path="\${path}" rows="\${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">\${esc(val)}</textarea>\`
: \`<input type="text" data-path="\${path}" value="\${esc(val)}">\`}
\`; \`;
container.appendChild(div); container.appendChild(div);
} }
@@ -212,7 +230,7 @@ function renderArray(key, arr, container, path) {
addBtn.onclick = () => { addBtn.onclick = () => {
const idx = items.children.length; const idx = items.children.length;
if (isObj) { if (isObj) {
const blank = Object.fromEntries(Object.keys(sample).map(k => [k, ''])); const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path)); items.appendChild(makeObjCard(blank, idx, path));
} else { } else {
items.appendChild(makeStrItem('', idx, path)); items.appendChild(makeStrItem('', idx, path));
@@ -223,13 +241,30 @@ function renderArray(key, arr, container, path) {
container.appendChild(section); container.appendChild(section);
} }
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) { function makeStrItem(val, idx, path) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'str-item'; wrap.className = 'str-item';
const ta = document.createElement('textarea'); const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']'; ta.dataset.path = path + '[' + idx + ']';
ta.value = val; ta.dataset.type = type;
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val).length / 80))); if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'btn-del'; del.className = 'btn-del';
del.textContent = '❌'; del.textContent = '❌';
@@ -248,19 +283,7 @@ function makeObjCard(obj, idx, path) {
hdr.textContent = path + '[' + idx + ']'; hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr); card.appendChild(hdr);
for (const [k, v] of Object.entries(obj)) { renderObject(obj, card, path + '[' + idx + ']');
const fieldPath = path + '[' + idx + '].' + k;
const isLong = String(v).length > 80 || String(v).includes('<');
const fd = document.createElement('div');
fd.className = 'inner-field';
fd.innerHTML = \`
<div class="inner-label">\${k}</div>
\${isLong
? \`<textarea data-path="\${fieldPath}" rows="\${Math.min(6,Math.max(2,Math.ceil(String(v).length/80)))}">\${esc(v)}</textarea>\`
: \`<input type="text" data-path="\${fieldPath}" value="\${esc(v)}">\`}
\`;
card.appendChild(fd);
}
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'btn-del-card'; del.className = 'btn-del-card';
@@ -299,11 +322,20 @@ function collect() {
clearArrays(result); clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => { document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, el.value); setPath(result, el.dataset.path, readValue(el));
}); });
return result; return result;
} }
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) { function clearArrays(obj) {
for (const k of Object.keys(obj)) { for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = []; if (Array.isArray(obj[k])) obj[k] = [];
@@ -312,24 +344,47 @@ function clearArrays(obj) {
} }
function setPath(obj, path, value) { function setPath(obj, path, value) {
// Parse "a.b[0].c" into parts const parts = parsePath(path);
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
let cur = obj; let cur = obj;
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
const p = isNaN(parts[i]) ? parts[i] : +parts[i]; const part = parts[i];
const next = isNaN(parts[i+1]) ? {} : (cur[p] || []); if (cur[part] === undefined || cur[part] === null) {
if (cur[p] === undefined || cur[p] === null) cur[p] = next; cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
cur = cur[p];
} }
const last = isNaN(parts[parts.length-1]) ? parts[parts.length-1] : +parts[parts.length-1]; cur = cur[part];
cur[last] = value; }
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
} }
async function save() { async function save() {
const data = collect(); const data = collect();
const res = await fetch('/save?file=' + FILE, { const res = await fetch('/save?file=' + FILE, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
body: JSON.stringify(data, null, 2) body: JSON.stringify(data, null, 2)
}); });
const json = await res.json(); const json = await res.json();
@@ -356,7 +411,7 @@ async function publish() {
btn.disabled = true; btn.disabled = true;
try { try {
const res = await fetch('/publish', { method: 'POST' }); const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
const json = await res.json(); const json = await res.json();
if (json.ok) { if (json.ok) {
@@ -393,15 +448,75 @@ if (toast) setTimeout(() => toast.remove(), 3500);
// ── Server ─────────────────────────────────────────────────────────────────── // ── Server ───────────────────────────────────────────────────────────────────
const CMS_USER = process.env.CMS_USER || 'admin'; const CMS_USER = process.env.CMS_USER;
const CMS_PASS = process.env.CMS_PASS || 'mozdit2026'; const CMS_PASS = process.env.CMS_PASS;
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);
}
function getClientAddress(req) {
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(':');
if (!CMS_USER || !CMS_PASS || login.length !== CMS_USER.length || password.length !== CMS_PASS.length) return false;
return crypto.timingSafeEqual(Buffer.from(login), Buffer.from(CMS_USER))
&& crypto.timingSafeEqual(Buffer.from(password), Buffer.from(CMS_PASS));
}
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;
}
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
// Basic Auth verification const clientAddress = getClientAddress(req);
const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; if (!securityConfigIsValid()) {
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':'); res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.');
if (login !== CMS_USER || password !== CMS_PASS) { return;
}
if (!hasValidCredentials(req)) {
const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS);
writeAudit('authentication_failed', { clientAddress, limited });
if (limited) {
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
res.end('Access denied'); res.end('Access denied');
return; return;
@@ -411,17 +526,48 @@ const server = http.createServer(async (req, res) => {
const fileKey = u.searchParams.get('file') || 'home'; const fileKey = u.searchParams.get('file') || 'home';
const activeFile = FILES[fileKey] ? fileKey : 'home'; const activeFile = FILES[fileKey] ? fileKey : 'home';
if (req.method === 'POST' && !hasValidCsrfToken(req)) {
writeAudit('csrf_rejected', { clientAddress, path: u.pathname, file: activeFile });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen vagy hiányzó CSRF token' }));
return;
}
// POST /save — JSON body // POST /save — JSON body
if (req.method === 'POST' && u.pathname === '/save') { if (req.method === 'POST' && u.pathname === '/save') {
let body = ''; let body = '';
req.on('data', c => body += c); let bodyTooLarge = false;
let bodySize = 0;
req.on('data', c => {
bodySize += c.length;
if (bodySize > MAX_REQUEST_BODY_BYTES) {
bodyTooLarge = true;
return;
}
body += c;
});
req.on('end', () => { req.on('end', () => {
try { try {
if (bodyTooLarge) {
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${MAX_REQUEST_BODY_BYTES} byte)` }));
return;
}
const data = JSON.parse(body); const data = JSON.parse(body);
fs.writeFileSync(FILES[activeFile], JSON.stringify(data, null, 2) + '\n', 'utf8'); const validation = validateContent(activeFile, data);
if (!validation.ok) {
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
return;
}
const backupFile = backupAndWriteAtomically(FILES[activeFile], data);
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile) }));
} catch (e) { } catch (e) {
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' }); res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message })); res.end(JSON.stringify({ ok: false, error: e.message }));
} }
@@ -431,18 +577,27 @@ const server = http.createServer(async (req, res) => {
// POST /publish — Git Commit & Push // POST /publish — Git Commit & Push
if (req.method === 'POST' && u.pathname === '/publish') { if (req.method === 'POST' && u.pathname === '/publish') {
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
return;
}
exec('git add . && git commit -m "content: frissítve a CMS-ből" && git push', { cwd: CONTENT_DIR }, (error, stdout, stderr) => { exec('git add . && git commit -m "content: frissítve a CMS-ből" && git push', { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
if (error) { if (error) {
// If there's nothing to commit, it's fine // If there's nothing to commit, it's fine
if (stdout.includes('nothing to commit') || stdout.includes('working tree clean')) { if (stdout.includes('nothing to commit') || stdout.includes('working tree clean')) {
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'no_changes' });
res.end(JSON.stringify({ ok: true, output: 'No changes to commit' })); res.end(JSON.stringify({ ok: true, output: 'No changes to commit' }));
} else { } else {
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'error' });
res.end(JSON.stringify({ ok: false, error: stderr || stdout || error.message })); res.end(JSON.stringify({ ok: false, error: stderr || stdout || error.message }));
} }
} else { } else {
// If a deploy.sh script exists, run it optionally in background // If a deploy.sh script exists, run it optionally in background
exec('cd ../../../ && ./deploy.sh production > deploy.log 2>&1 &'); exec('cd ../../../ && ./deploy.sh production > deploy.log 2>&1 &');
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'ok' });
res.end(JSON.stringify({ ok: true, output: stdout })); res.end(JSON.stringify({ ok: true, output: stdout }));
} }
}); });
@@ -459,10 +614,14 @@ const server = http.createServer(async (req, res) => {
} }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(HTML(activeFile, jsonData, message)); res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN));
}); });
server.listen(PORT, () => { if (require.main === module) {
if (!securityConfigIsValid()) {
throw new Error('CMS_USER és CMS_PASS nélkül a Content Editor nem indítható el.');
}
server.listen(PORT, '127.0.0.1', () => {
console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT}\n`); console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT}\n`);
console.log(' Szerkeszthető fájlok:'); console.log(' Szerkeszthető fájlok:');
Object.entries(FILE_LABELS).forEach(([k, l]) => { Object.entries(FILE_LABELS).forEach(([k, l]) => {
@@ -471,3 +630,13 @@ server.listen(PORT, () => {
}); });
console.log('\n Ctrl+C a leállításhoz\n'); console.log('\n Ctrl+C a leállításhoz\n');
}); });
}
module.exports = {
backupAndWriteAtomically,
validateContent,
hasValidCredentials,
hasValidCsrfToken,
securityConfigIsValid,
csrfToken: CSRF_TOKEN,
};
+24
View File
@@ -0,0 +1,24 @@
# Kapcsolatfelvételi üzenetek perzisztenciája
## Működés
A `POST /api/contact` érvényes, nem spamnek minősített üzenetet a MongoDB `contact_submissions` kollekciójába ment. A rekord a következő, feldolgozáshoz szükséges mezőket tartalmazza:
- név, e-mail cím, tárgy és üzenet;
- GDPR-hozzájárulás;
- `new` feldolgozási státusz;
- szerveroldali `createdAt` időbélyeg.
A végpont csak sikeres `insertOne` után ad 200-as választ, és visszaadja a `submissionId` azonosítót. Ha az adatbázis nem érhető el vagy az írás hibás, 500-as választ küld; a felhasználó nem kap hamis sikerjelzést.
## Ellenőrzések
- Unit teszt: sikeres mentésnél a beszúrandó dokumentum és válasz-ID ellenőrzése; MongoDB-hibánál 500-as válasz.
- Docker integrációs teszt: a `POST /api/contact` után a teszt azonosító alapján visszaolvassa a MongoDB-dokumentumot.
A Docker integráció a `docker-compose.dev.yml` szerinti `http://localhost:8080` alkalmazás- és `localhost:27018` MongoDB-portot használja. A futtatáshoz Docker daemon szükséges:
```sh
npm run docker:dev
npm run test:integration
```
+79
View File
@@ -0,0 +1,79 @@
# Content Editor strukturális helyreállítás
## Cél és hatókör
Ez a dokumentum a `MITHOME-30` feltárását és a kezdeti adathelyreállítást rögzíti. A Content Editor a `proto/src/content/**/*.json` fájlokat írja, ezért a hiba a megjelenő weboldal-tartalmat közvetlenül érinti.
## Talált hiba
A böngészős mentési kód minden mező értékét szövegként írta vissza. Mentés előtt kiürítette az összes tömböt, majd a `a.b[0].c` útvonalat törékeny reguláris kifejezéssel dolgozta fel. Emiatt:
- a tömbök `[]` értékre ürültek, az elemek pedig hibás, például `items[0]` nevű objektumkulcsokba kerültek;
- az objektumokat és beágyazott tömböket a felület `"[object Object]"` szöveggé alakíthatta;
- a számok és logikai értékek szöveggé alakultak;
- egyes kulcsok sérültek (`id` helyett `i.$1`, `description` helyett `0.$1escription`).
## Helyreállítás 2026-08-17
Az alábbi fájlok strukturálisan helyreálltak:
- `pages/home.json` a Git-történet utolsó ép változatából, a későbbi `https://mail.mozdit.hu` webmail-címet megtartva;
- `pages/about.json` a helyes tömbök és objektumok visszaállításával;
- `pages/services.json` a részletes szolgáltatás-specifikációk beágyazott objektumaival együtt;
- `pages/adatvedelem.json` és `pages/hasznalati-feltetelek.json` a későbbi, üzletileg releváns `Szigetszentmiklós` szöveg megtartásával.
Minden `src/content/pages/*.json` fájl JSON-szintaktikája és a korábbi sérülési mintákra végzett ellenőrzése rendben van. A teljes unit tesztcsomag 55 sikeres teszttel lefutott.
## A hiba megelőzése a kliensben
A `content-editor.js` mentési logikája most karakterenként elemzi az útvonalakat, ezért a HTML-sablon escape-elése nem módosíthatja a parser működését. A felület rekurzívan rendereli az objektumokat és a beágyazott tömböket; a boolean checkboxként, a szám number-inputként szerkeszthető. A mentés ezek típusát megőrzi.
A ténylegesen generált böngésző-kódot ellenőrző regressziós teszt:
```sh
node scripts/test-content-editor-serializer.js
```
A teszt egy beágyazott tömböket és objektumokat, valamint string-, boolean- és numerikus mezőket tartalmazó mintán ellenőrzi a mentési körutazást.
## Szerveroldali védelmi réteg
A `MITHOME-41` részeként a `proto/src/content/schema.js` az összes szerkeszthető content-fájl kötelező mezőit, típusait és megengedett kulcsait írja le. A Next.js loader és a Content Editor ezt az egy közös validátort használja.
A mentés csak sikeres validáció után történik. Az Editor legfeljebb 256 KiB-os request body-t fogad; hibás JSON vagy sémahiba `422` választ ad, és az eredeti fájlt érintetlenül hagyja. Érvényes mentéskor:
1. az eredeti JSON időbélyeges példánya a `.content-backups/` könyvtárba kerül;
2. az új tartalom egy célfájllal azonos könyvtárban létrehozott ideiglenes fájlba íródik;
3. az ideiglenes fájl atomikus átnevezéssel váltja fel a célfájlt.
A backup-könyvtár szándékosan nincs Gitben. A séma- és atomikus mentési tesztek:
```sh
node scripts/test-content-schema.js
node scripts/test-content-editor-save.js
```
## Hozzáférésvédelem
A Content Editor csak akkor indul el, ha a környezetben mindkét változó meg van adva:
```sh
CMS_USER=<egyedi-felhasználónév>
CMS_PASS=<erős-egyedi-jelszó>
```
Nincs beégetett alapértelmezett belépési adat. A szerver kizárólag a `127.0.0.1` címen figyel, ezért távoli használathoz Nginx/VPN vagy SSH tunnel szükséges. A böngésző minden módosító kéréshez szerver által kiadott CSRF-tokent küld.
Az ismételt hibás bejelentkezést és a publikálási kéréseket IP-alapú, 15 perces csúszóablakos limit védi. A mentések és publikálások minimális auditbejegyzést írnak a `.content-editor-audit.jsonl` fájlba (időpont, esemény, felhasználó, célfájl, eredmény); secretet és teljes contentet nem naplóz. Ez a fájl, valamint a backupok nincs Gitben.
A védelmi alapteszt:
```sh
node scripts/test-content-editor-security.js
```
## Következő védelmi lépések
1. A szerveroldali mentésnek sémaellenőrzést, atomikus írást és visszaállítható mentést kell használnia (`MITHOME-41`).
2. A publikálási folyamatot a natív, systemd-alapú kiadási eljáráshoz kell igazítani (`MITHOME-42`).
3. A Content Editor alapértelmezett hitelesítő adatait és a hiányzó CSRF-, audit- és rate-limit védelmet meg kell szüntetni (`MITHOME-43`).
+64
View File
@@ -0,0 +1,64 @@
# Staging UI smoke tesztterv
## Cél
A staging deploy után 23 perc alatt jelezze, ha a publikus weboldal alapvetően nem használható. A cél nem a teljes regresszió, hanem a kritikus útvonalak gyors, stabil ellenőrzése.
**Célkörnyezet:** `https://stage.mozdit.hu`
**Futás:** minden staging deploy után, valamint manuálisan kiadás előtt.
**Eszköz:** Playwright MCP feltáráshoz; az automatizált futtatáshoz Playwright Test alapú tesztfájl javasolt.
## Határok
- A smoke teszt csak a staging környezetet használja.
- Nem ment tartalmat, nem publikál és nem küld valódi kapcsolatfelvételi üzenetet.
- Nem támaszkodik a magyar marketing-szövegek teljes egyezésére; stabil szerepköröket, URL-eket, címeket és státuszkódokat ellenőriz.
- Az olyan részletes üzleti folyamatok, mint az üzenet tartós MongoDB-mentése, külön integrációs/E2E tesztbe tartoznak.
## Kötelező ellenőrzések
| Azonosító | Forgatókönyv | Várható eredmény |
| --- | --- | --- |
| SMOKE-01 | Health endpoint | `GET /api/health` 200-as választ és `status: "ok"` értéket ad. |
| SMOKE-02 | Kezdőlap renderelése | A `/` oldal betölt; látható a főcím, a fejléc navigációja, a kapcsolat CTA és a lábléc. Nincs böngésző-konzolhiba. |
| SMOKE-03 | Fő navigáció | A Kezdőlap, Rólunk, Szolgáltatások és Kapcsolat linkek a megfelelő publikus útvonalra vezetnek; minden céloldalon egyetlen `h1` található. |
| SMOKE-04 | Kritikus szolgáltatási tartalom | A kezdőlapon vagy a szolgáltatások oldalon látható legalább egy szolgáltatáskártya és a kapcsolat CTA. Ezzel korán észlelhető a content JSON vagy a Footer/Home integráció sérülése. |
| SMOKE-05 | Kapcsolati űrlap kliensoldali védelme | Üres űrlap elküldésekor a kötelező mezők hibajelzései megjelennek, és nem történik hálózati `POST /api/contact` kérés. |
## Opcionális, nem blokkoló ellenőrzések
- A dark-mode kapcsoló működik és az oldal olvasható marad.
- A webmail link külső URL-re mutat.
- A jogi oldalak elérhetők és tartalmaznak főcímet.
## Automatikus futtatási szerződés
1. A teszt a `BASE_URL=https://stage.mozdit.hu` változót használja; lokális cím nincs beégetve.
2. Az összes kötelező teszt legfeljebb 30 másodperc alatt lefut.
3. Sikertelenségkor a futás ment accessibility snapshotot, konzolhibákat és Playwright trace-t.
4. A CI a staging deploy után futtatja; sikertelenség esetén a staging kiadás hibásnak minősül, de production deployt csak explicit release szabály blokkol.
5. A teszt a `MITHOME-35` CI/staging feladathoz tartozik.
## Első manuális eredmény
2026-08-17-én Playwright MCP-vel a `SMOKE-01` és `SMOKE-02` sikeresen lefutott a stagingen: a kezdőlap renderelt, nem volt konzolhiba, a health endpoint `status: "ok"` választ adott.
## Megvalósítás és első automatizált futás
A tesztcsomag helye: `proto/e2e/staging-smoke.spec.ts`; a konfiguráció: `proto/playwright.config.ts`.
```sh
cd proto
npm run test:smoke:staging
```
Az első teljes automatizált futás 2026-08-17-én sikeres volt: mind az öt smoke teszt átment 12,2 másodperc alatt a `stage.mozdit.hu` környezeten.
## Gitea CI bekötés
A `.gitea/workflows/ci.yml` két további minőségi kaput tartalmaz:
- **Docker integration & API E2E:** minden normál CI futáskor elindítja a fejlesztői Compose stack-et, megvárja az alkalmazás health endpointját, majd futtatja az integration és API E2E Jest teszteket.
- **Staging Playwright smoke:** csak kézi Gitea indításkor vagy napi ütemezéssel fut a `https://stage.mozdit.hu` ellen. A Playwright HTML riport és a trace/screenshot output artifactként megmarad.
Automatikus staging vagy production deploy nincs ebben a workflow-ban. A staging deploy továbbra is az elkülönített szerveroldali deploy eljárás feladata; annak sikeres befejezése után kell a staging smoke futást elindítani.
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test'
test('SMOKE-01: health endpoint is available', async ({ request }) => {
const response = await request.get('/api/health')
expect(response.status()).toBe(200)
await expect(response).toBeOK()
await expect(response.json()).resolves.toMatchObject({ status: 'ok' })
})
test('SMOKE-02: homepage renders its critical shell without console errors', async ({ page }) => {
const consoleErrors: string[] = []
page.on('console', message => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.goto('/')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('navigation')).toBeVisible()
await expect(page.getByRole('link', { name: /beszéljük át/i })).toBeVisible()
await expect(page.locator('footer')).toBeVisible()
expect(consoleErrors).toEqual([])
})
test('SMOKE-03: main navigation opens every public core page', async ({ page }) => {
const targets = [
['Kezdőlap', '/'],
['Rólunk', '/rolunk'],
['Szolgáltatások', '/szolgaltatasok'],
['Kapcsolat', '/kapcsolat'],
] as const
await page.goto('/')
for (const [label, expectedPath] of targets) {
await page.getByRole('navigation').getByRole('link', { name: label }).click()
await expect(page).toHaveURL(new RegExp(`${expectedPath.replace('/', '\\/')}$`))
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
}
})
test('SMOKE-04: service content and contact CTA are visible', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Szolgáltatásaink' })).toBeVisible()
await expect(page.getByRole('link', { name: /kérjen ajánlatot/i })).toHaveAttribute('href', '/kapcsolat')
})
test('SMOKE-05: empty contact form is blocked client-side without API submission', async ({ page }) => {
let contactPostCount = 0
page.on('request', request => {
if (request.method() === 'POST' && new URL(request.url()).pathname === '/api/contact') contactPostCount++
})
await page.goto('/kapcsolat')
await page.getByRole('button', { name: 'Üzenet küldése' }).click()
await expect(page.getByRole('textbox', { name: 'Név *' })).toBeVisible()
await expect.poll(() => contactPostCount).toBe(0)
})
+63 -22
View File
@@ -16,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.62.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.5", "@testing-library/jest-dom": "^6.5",
"@testing-library/react": "^16.0", "@testing-library/react": "^16.0",
@@ -635,7 +636,6 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
"integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -657,7 +657,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1844,7 +1843,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1861,7 +1859,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1878,7 +1875,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1895,7 +1891,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1912,7 +1907,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1929,7 +1923,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1946,7 +1939,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1963,7 +1955,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1980,7 +1971,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1997,7 +1987,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2014,7 +2003,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2031,7 +2019,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2048,7 +2035,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2065,7 +2051,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2082,7 +2067,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2096,7 +2080,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.3.tgz",
"integrity": "sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==", "integrity": "sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2112,7 +2095,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2129,7 +2111,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2146,7 +2127,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2361,6 +2341,22 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@protobufjs/aspromise": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -2876,7 +2872,6 @@
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -9556,6 +9551,52 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+2
View File
@@ -14,6 +14,7 @@
"test:browser": "jest --config jest.config.js src/__tests__/browser-integration.test.ts", "test:browser": "jest --config jest.config.js src/__tests__/browser-integration.test.ts",
"test:integration": "INTEGRATION_TESTS=1 jest --config jest.config.integration.js src/__tests__/integration.test.ts", "test:integration": "INTEGRATION_TESTS=1 jest --config jest.config.integration.js src/__tests__/integration.test.ts",
"test:e2e": "E2E_TESTS=1 jest --config jest.config.integration.js src/__tests__/e2e-docker.test.ts", "test:e2e": "E2E_TESTS=1 jest --config jest.config.integration.js src/__tests__/e2e-docker.test.ts",
"test:smoke:staging": "playwright test --config=playwright.config.ts",
"test:docker": "npm run test:integration && npm run test:e2e", "test:docker": "npm run test:integration && npm run test:e2e",
"test:all": "npm run test:unit && npm run test:browser && npm run test:docker", "test:all": "npm run test:unit && npm run test:browser && npm run test:docker",
"test:report": "npm test -- --json --outputFile=test-results.json --silent && node ../scripts/sync-test-management.js", "test:report": "npm test -- --json --outputFile=test-results.json --silent && node ../scripts/sync-test-management.js",
@@ -37,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.62.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.5", "@testing-library/jest-dom": "^6.5",
"@testing-library/react": "^16.0", "@testing-library/react": "^16.0",
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.BASE_URL || 'https://stage.mozdit.hu',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
+21 -3
View File
@@ -3,14 +3,14 @@
* These tests run against the real services in the Docker stack * These tests run against the real services in the Docker stack
*/ */
import { MongoClient } from 'mongodb' import { MongoClient, ObjectId } from 'mongodb'
const DOCKER_SERVICES = { const DOCKER_SERVICES = {
app: 'http://localhost:3000', app: 'http://localhost:8080',
mongoExpress: 'http://localhost:8081', mongoExpress: 'http://localhost:8081',
grafana: 'http://localhost:3001', grafana: 'http://localhost:3001',
loki: 'http://localhost:3100', loki: 'http://localhost:3100',
mongodb: 'mongodb://admin:password123@localhost:27017/admin' mongodb: 'mongodb://admin:password123@localhost:27018/mozdit?authSource=admin'
} }
describe('Docker Environment Integration Tests', () => { describe('Docker Environment Integration Tests', () => {
@@ -170,6 +170,24 @@ describe('Docker Environment Integration Tests', () => {
if (response.status === 200) { if (response.status === 200) {
expect(result).toHaveProperty('message', 'Üzenet sikeresen elküldve!') expect(result).toHaveProperty('message', 'Üzenet sikeresen elküldve!')
expect(result).toHaveProperty('timestamp') expect(result).toHaveProperty('timestamp')
expect(result).toHaveProperty('submissionId')
if (!mongoClient) {
mongoClient = new MongoClient(DOCKER_SERVICES.mongodb)
await mongoClient.connect()
}
const savedSubmission = await mongoClient
.db('mozdit')
.collection('contact_submissions')
.findOne({ _id: new ObjectId(result.submissionId) })
expect(savedSubmission).toEqual(expect.objectContaining({
name: contactData.name,
email: contactData.email,
subject: contactData.subject,
message: contactData.message,
gdprConsent: true,
status: 'new',
}))
} else { } else {
expect(result).toHaveProperty('error') expect(result).toHaveProperty('error')
expect(result.error).toContain('Túl sok') expect(result.error).toContain('Túl sok')
+20 -9
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getCollection } from '@/lib/mongodb'
interface ContactFormData { interface ContactFormData {
name: string name: string
@@ -8,6 +9,16 @@ interface ContactFormData {
gdprConsent: boolean gdprConsent: boolean
} }
interface ContactSubmission {
name: string
email: string
subject: string
message: string
gdprConsent: true
status: 'new'
createdAt: Date
}
// Simple spam protection - rate limiting by IP // Simple spam protection - rate limiting by IP
const rateLimitMap = new Map<string, { count: number; timestamp: number }>() const rateLimitMap = new Map<string, { count: number; timestamp: number }>()
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
@@ -93,20 +104,20 @@ export async function POST(request: NextRequest) {
) )
} }
// Log the contact form submission (in production, this would be sent via email or saved to database) const submissions = await getCollection<ContactSubmission>('contact_submissions')
console.log('Contact form submission:', { const submission: ContactSubmission = {
...sanitizedData, ...sanitizedData,
timestamp: new Date().toISOString(), gdprConsent: true,
ip: ip status: 'new',
}) createdAt: new Date(),
}
// TODO: In production, implement actual email sending const result = await submissions.insertOne(submission)
// For now, we'll just simulate success
return NextResponse.json( return NextResponse.json(
{ {
message: 'Üzenet sikeresen elküldve!', message: 'Üzenet sikeresen elküldve!',
timestamp: new Date().toISOString() timestamp: submission.createdAt.toISOString(),
submissionId: result.insertedId.toHexString()
}, },
{ status: 200 } { status: 200 }
) )
@@ -2,6 +2,9 @@
* Unit tests for Contact API route * Unit tests for Contact API route
* These tests focus on testing the business logic without complex mocking * These tests focus on testing the business logic without complex mocking
*/ */
import { POST } from './route'
const mockInsertOne = jest.fn()
// Mock the logger to avoid complex setup // Mock the logger to avoid complex setup
jest.mock('@/lib/logger', () => ({ jest.mock('@/lib/logger', () => ({
@@ -12,7 +15,57 @@ jest.mock('@/lib/logger', () => ({
}) })
})) }))
jest.mock('@/lib/mongodb', () => ({
getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })),
}))
describe('/api/contact Unit Tests', () => { describe('/api/contact Unit Tests', () => {
beforeEach(() => {
mockInsertOne.mockReset()
})
describe('MongoDB persistence', () => {
const validData = {
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'This is a test message with enough content',
gdprConsent: true,
}
it('persists a valid submission before reporting success', async () => {
mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } })
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
json: async () => validData,
} as any
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.submissionId).toBe('submission-123')
expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
...validData,
status: 'new',
createdAt: expect.any(Date),
}))
})
it('returns a server error when persistence fails', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable'))
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
json: async () => validData,
} as any
const response = await POST(request)
expect(response.status).toBe(500)
expect(mockInsertOne).toHaveBeenCalledTimes(1)
})
})
// TC-001: Email Format Validation Test (ZEE-48) // TC-001: Email Format Validation Test (ZEE-48)
describe('Input validation logic', () => { describe('Input validation logic', () => {
it('should validate required fields', () => { it('should validate required fields', () => {
+1 -1
View File
@@ -145,7 +145,7 @@ export default function Home() {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{pageContent.services.items.map((service: any, index: number) => ( {pageContent.services.items.map((service, index) => (
<div <div
key={service.id} key={service.id}
className="group card hover-lift" className="group card hover-lift"
+1 -1
View File
@@ -32,7 +32,7 @@ export default function ServicesPage() {
{/* Services Grid */} {/* Services Grid */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{content.pages.home.services.items.map((service: any) => ( {content.pages.home.services.items.map((service) => (
<div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow"> <div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow">
<div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6"> <div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6">
<span className="text-2xl">{service.icon}</span> <span className="text-2xl">{service.icon}</span>
+17 -8
View File
@@ -23,6 +23,7 @@ import type {
ContactPageContent, ContactPageContent,
LegalPageContent LegalPageContent
} from './types' } from './types'
import { validateContent } from './schema'
// Import JSON files // Import JSON files
import commonJson from './common.json' import commonJson from './common.json'
@@ -33,14 +34,22 @@ import contactJson from './pages/contact.json'
import adatvedelemJson from './pages/adatvedelem.json' import adatvedelemJson from './pages/adatvedelem.json'
import hasznalatiFeltetelekJson from './pages/hasznalati-feltetelek.json' import hasznalatiFeltetelekJson from './pages/hasznalati-feltetelek.json'
// Type assertions for JSON imports function loadValidatedContent<T>(fileKey: string, value: unknown): T {
const common: CommonContent = commonJson as CommonContent const result = validateContent(fileKey, value)
const home: HomePageContent = homeJson as HomePageContent if (!result.ok) {
const about: AboutPageContent = aboutJson as AboutPageContent throw new Error(`Érvénytelen content JSON (${fileKey}): ${result.errors.join('; ')}`)
const services: ServicesPageContent = servicesJson as ServicesPageContent }
const contact: ContactPageContent = contactJson as ContactPageContent return value as T
const adatvedelem: LegalPageContent = adatvedelemJson as LegalPageContent }
const hasznalatiFeltetelek: LegalPageContent = hasznalatiFeltetelekJson as LegalPageContent
// Runtime validation prevents malformed JSON from silently reaching the UI.
const common = loadValidatedContent<CommonContent>('common', commonJson)
const home = loadValidatedContent<HomePageContent>('home', homeJson)
const about = loadValidatedContent<AboutPageContent>('about', aboutJson)
const services = loadValidatedContent<ServicesPageContent>('services', servicesJson)
const contact = loadValidatedContent<ContactPageContent>('contact', contactJson)
const adatvedelem = loadValidatedContent<LegalPageContent>('adatvedelem', adatvedelemJson)
const hasznalatiFeltetelek = loadValidatedContent<LegalPageContent>('hasznalatiFeltetelek', hasznalatiFeltetelekJson)
// Combined content object // Combined content object
export const content: SiteContent = { export const content: SiteContent = {
+18 -24
View File
@@ -1,55 +1,49 @@
{ {
"meta": { "meta": {
"0": {
"$1escription": "Ismerje meg a mozdIT Bt. történetét, küldetését és értékeit. Több mint 20 éve nyújtunk megbízható IT szolgáltatásokat."
},
"title": "Rólunk", "title": "Rólunk",
"description": "Ismerje meg a mozdIT Bt. történetét, küldetését és értékeit. Több mint 20 éve nyújtunk megbízható IT szolgáltatásokat.", "description": "Ismerje meg a mozdIT Bt. történetét, küldetését és értékeit. Több mint 20 éve nyújtunk megbízható IT szolgáltatásokat.",
"ogDescription": "Ismerje meg a mozdIT Bt. történetét, küldetését és értékeit." "ogDescription": "Ismerje meg a mozdIT Bt. történetét, küldetését és értékeit."
}, },
"hero": { "hero": {
"title": "Rólunk", "title": "Rólunk",
"subtitle": "Több mint 20 éve biztosítunk megbízható IT infrastruktúrát." "subtitle": "Több mint 20 éve biztosítunk megbízható IT infrastruktúrát és személyes ügyfélszolgálatot"
}, },
"story": { "story": {
"title": "Történetünk", "title": "Történetünk",
"paragraphs": [], "paragraphs": [
"paragraphs[0]": "Kezdetben néhány ügyfél weboldalának üzemeltetésével indultunk, ma pedig számos domain és email fiók működését biztosítjuk. Növekedésünk során mindig szem előtt tartottuk az alapelveinket: megbízhatóság, személyes kapcsolat és műszaki kiválóság.", "Kezdetben néhány ügyfél weboldalának üzemeltetésével indultunk, ma pedig számos domain és email fiók működését biztosítjuk. Növekedésünk során mindig szem előtt tartottuk az alapelveinket: megbízhatóság, személyes kapcsolat és műszaki kiválóság.",
"paragraphs[1]": "Ez egy kis cég, egy dedikált szerverünk van, és ezen szolgáltatunk webtárhelyet, domain-fenntartást és e-mail szolgáltatásokat kis- és középvállalkozásoknak, illetve magánszemélyeknek.", "Ez egy kis cég, egy dedikált szerverünk van, és ezen szolgáltatunk webtárhelyet, domain-fenntartást és e-mail szolgáltatásokat kis- és középvállalkozásoknak, illetve magánszemélyeknek.",
"paragraphs[2]": "Kis csapat, nagy elköteleződés. Saját dedikált infrastruktúránkon garantáljuk az adatbiztonságot és a folyamatos rendelkezésre állást. Nálunk nincs technoblabla: személyes ügyfélszolgálat, emberi lépték és mindig elérhető válaszok várják." "Kis csapat, nagy elköteleződés. Saját dedikált infrastruktúránkon garantáljuk az adatbiztonságot és a folyamatos rendelkezésre állást. Nálunk nincs technoblabla: személyes ügyfélszolgálat, emberi lépték és mindig elérhető válaszok várják."
]
}, },
"mission": { "mission": {
"title": "Küldetésünk és értékeink", "title": "Küldetésünk és értékeink",
"subtitle": "Minden nap azért dolgozunk, hogy ügyfeleink digitális jelenléte biztonságos, stabil és hatékony legyen.", "subtitle": "Minden nap azért dolgozunk, hogy ügyfeleink digitális jelenléte biztonságos, stabil és hatékony legyen.",
"values": [], "values": [
"values[0]": { {
"0": {
"$1escription": "99.9% uptime és 24/7 monitoring biztosítja, hogy szolgáltatásaink mindig elérhetők legyenek."
},
"title": "Megbízhatóság", "title": "Megbízhatóság",
"description": "99.9% uptime és 24/7 monitoring biztosítja, hogy szolgáltatásaink mindig elérhetők legyenek.",
"icon": "🛡️" "icon": "🛡️"
}, },
"values[1]": { {
"0": {
"$1escription": "Minden ügyfél számít számunkra. Személyre szabott megoldásokat kínálunk."
},
"title": "Személyes kapcsolat", "title": "Személyes kapcsolat",
"description": "Minden ügyfél számít számunkra. Személyre szabott megoldásokat kínálunk.",
"icon": "👥" "icon": "👥"
}, },
"values[2]": { {
"0": {
"$1escription": "Korszerű technológiák és bevált gyakorlatok alkalmazásával biztosítjuk a legmagasabb színvonalú szolgáltatást."
},
"title": "Műszaki kiválóság", "title": "Műszaki kiválóság",
"description": "Korszerű technológiák és bevált gyakorlatok alkalmazásával biztosítjuk a legmagasabb színvonalú szolgáltatást.",
"icon": "⚡" "icon": "⚡"
} }
]
}, },
"team": { "team": {
"title": "Szakértő csapat", "title": "Szakértő csapat",
"subtitle": "Tapasztalt IT szakemberek, akik elkötelezetten és megbízhatóan dolgoznak az ügyfeleink sikeréért.", "subtitle": "Tapasztalt IT szakemberek, akik elkötelezetten és megbízhatóan dolgoznak az ügyfeleink sikeréért.",
"paragraphs": [], "paragraphs": [
"paragraphs[0]": "Csapatunk tagjai több mint 20 éves tapasztalattal rendelkeznek a maguk területén, és folyamatosan követik a technológiai újdonságokat.", "Csapatunk tagjai több mint 20 éves tapasztalattal rendelkeznek a maguk területén, és folyamatosan követik a technológiai újdonságokat.",
"paragraphs[1]": "Hiszünk abban, hogy a jó kommunikáció és a műszaki tudás együtt teremti meg a tökéletes ügyfélélményt." "Hiszünk abban, hogy a jó kommunikáció és a műszaki tudás együtt teremti meg a tökéletes ügyfélélményt."
]
}, },
"cta": { "cta": {
"title": "Legyen Ön is elégedett ügyfelünk!", "title": "Legyen Ön is elégedett ügyfelünk!",
+12 -26
View File
@@ -1,45 +1,31 @@
{ {
"title": "Adatvédelmi Tájékoztató", "title": "Adatvédelmi Tájékoztató",
"lastUpdated": "2026. április 26.", "lastUpdated": "2026. április 26.",
"sections": [], "sections": [
"lastUp": { {
"$1ate": { "id": "bevezetes",
"$1": "2026. április 26."
}
},
"sections[0]": {
"i": {
"$1": "bevezetes"
},
"title": "1. Bevezetés", "title": "1. Bevezetés",
"content": "A mozdIT Bt. (székhely: Szigetszentmiklós, Magyarország; e-mail: info@mozdit.hu) mint adatkezelő elkötelezett a felhasználók személyes adatainak védelme iránt. Jelen tájékoztató célja, hogy az Európai Unió Általános Adatvédelmi Rendelete (GDPR) előírásainak megfelelően átlátható módon bemutassa cégünk adatkezelési gyakorlatát." "content": "A mozdIT Bt. (székhely: Szigetszentmiklós, Magyarország; e-mail: info@mozdit.hu) mint adatkezelő elkötelezett a felhasználók személyes adatainak védelme iránt. Jelen tájékoztató célja, hogy az Európai Unió Általános Adatvédelmi Rendelete (GDPR) előírásainak megfelelően átlátható módon bemutassa cégünk adatkezelési gyakorlatát."
}, },
"sections[1]": { {
"i": { "id": "adatkezeles-celja",
"$1": "adatkezeles-celja"
},
"title": "2. Milyen adatokat kezelünk, mi a céljuk és a jogalapjuk?", "title": "2. Milyen adatokat kezelünk, mi a céljuk és a jogalapjuk?",
"content": "A szolgáltatásaink nyújtása során az alábbi adatokat kezeljük:\n\n• **Kapcsolatfelvétel és űrlapok:** Név, e-mail cím, telefonszám, a beküldött üzenet tartalma. (Cél: ügyfélkapcsolat, árajánlatadás. Jogalap: hozzájárulás és szerződést megelőző lépések.)\n• **Ügyfél-nyilvántartás és számlázás:** Számlázási név és cím, adószám, kapcsolattartó adatai. (Cél: szolgáltatás biztosítása, számlázás. Jogalap: jogi kötelezettség, szerződés teljesítése.)\n• **Webtárhely és e-mail szolgáltatás:** Technikai naplófájlok (IP címek, belépési időpontok), e-mail forgalmi adatok szűrése (spam, vírusok). (Cél: IT biztonság fenntartása, szolgáltatás biztosítása. Jogalap: szerződés teljesítése, jogos érdek.)\n• **Domain-regisztráció:** Tulajdonosi és kapcsolattartói adatok. (Cél: domain delegáció ügyintézése a regisztrátorok felé. Jogalap: szerződés teljesítése.)\n• **Sütik (Cookies):** Weboldalunk a megfelelő technikai működés érdekében alapvető (munkamenet) sütiket használ. Harmadik fél analitikai kódjait (pl. Meta pixel) jelenleg nem használjuk az alapvető nyomkövetéshez." "content": "A szolgáltatásaink nyújtása során az alábbi adatokat kezeljük:\n\n• **Kapcsolatfelvétel és űrlapok:** Név, e-mail cím, telefonszám, a beküldött üzenet tartalma. (Cél: ügyfélkapcsolat, árajánlatadás. Jogalap: hozzájárulás és szerződést megelőző lépések.)\n• **Ügyfél-nyilvántartás és számlázás:** Számlázási név és cím, adószám, kapcsolattartó adatai. (Cél: szolgáltatás biztosítása, számlázás. Jogalap: jogi kötelezettség, szerződés teljesítése.)\n• **Webtárhely és e-mail szolgáltatás:** Technikai naplófájlok (IP címek, belépési időpontok), e-mail forgalmi adatok szűrése (spam, vírusok). (Cél: IT biztonság fenntartása, szolgáltatás biztosítása. Jogalap: szerződés teljesítése, jogos érdek.)\n• **Domain-regisztráció:** Tulajdonosi és kapcsolattartói adatok. (Cél: domain delegáció ügyintézése a regisztrátorok felé. Jogalap: szerződés teljesítése.)\n• **Sütik (Cookies):** Weboldalunk a megfelelő technikai működés érdekében alapvető (munkamenet) sütiket használ. Harmadik fél analitikai kódjait (pl. Meta pixel) jelenleg nem használjuk az alapvető nyomkövetéshez."
}, },
"sections[2]": { {
"i": { "id": "adattovabbitas",
"$1": "adattovabbitas"
},
"title": "3. Adattovábbítás és adatfeldolgozók", "title": "3. Adattovábbítás és adatfeldolgozók",
"content": "A személyes adatokat bizalmasan kezeljük, és alapvetően a mozdIT Bt. saját, dedikált infrastruktúráján tároljuk. Harmadik félnek csak jogszabályi kötelezettség alapján (pl. NAV részére könyvelési okokból), illetve a domain-regisztrációs szolgáltatások keretében továbbítunk adatokat az illetékes regisztrátorok (pl. ISZT) felé." "content": "A személyes adatokat bizalmasan kezeljük, és alapvetően a mozdIT Bt. saját, dedikált infrastruktúráján tároljuk. Harmadik félnek csak jogszabályi kötelezettség alapján (pl. NAV részére könyvelési okokból), illetve a domain-regisztrációs szolgáltatások keretében továbbítunk adatokat az illetékes regisztrátorok (pl. ISZT) felé."
}, },
"sections[3]": { {
"i": { "id": "adatmegorzes",
"$1": "adatmegorzes"
},
"title": "4. Az adatkezelés időtartama", "title": "4. Az adatkezelés időtartama",
"content": "A számviteli bizonylatokat a Számviteli törvény értelmében 8 évig megőrizzük. A kapcsolatfelvételi űrlapon megadott, de szerződéssel nem végződő adatokat az ügy lezárását követő 1 év elteltével töröljük. A webszerverek technikai naplófájljait (logokat) a biztonsági és üzemeltetési feladatok ellátása céljából legfeljebb 30 napig őrizzük meg." "content": "A számviteli bizonylatokat a Számviteli törvény értelmében 8 évig megőrizzük. A kapcsolatfelvételi űrlapon megadott, de szerződéssel nem végződő adatokat az ügy lezárását követő 1 év elteltével töröljük. A webszerverek technikai naplófájljait (logokat) a biztonsági és üzemeltetési feladatok ellátása céljából legfeljebb 30 napig őrizzük meg."
}, },
"sections[4]": { {
"i": { "id": "jogok",
"$1": "jogok"
},
"title": "5. Az érintettek jogai", "title": "5. Az érintettek jogai",
"content": "Az érintettek bármikor tájékoztatást kérhetnek adataik kezeléséről, kérhetik azok helyesbítését, törlését (a kötelező adatkezelések kivételével) vagy zárolását. Kérésüket az info@mozdit.hu e-mail címre küldhetik el, amire indokolatlan késedelem nélkül, de legkésőbb egy hónapon belül válaszolunk. Jogosulatlan adatkezelés esetén a Nemzeti Adatvédelmi és Információszabadság Hatósághoz (NAIH) lehet fordulni panasszal." "content": "Az érintettek bármikor tájékoztatást kérhetnek adataik kezeléséről, kérhetik azok helyesbítését, törlését (a kötelező adatkezelések kivételével) vagy zárolását. Kérésüket az info@mozdit.hu e-mail címre küldhetik el, amire indokolatlan késedelem nélkül, de legkésőbb egy hónapon belül válaszolunk. Jogosulatlan adatkezelés esetén a Nemzeti Adatvédelmi és Információszabadság Hatósághoz (NAIH) lehet fordulni panasszal."
} }
]
} }
@@ -1,45 +1,31 @@
{ {
"title": "Használati Feltételek (ÁSZF)", "title": "Használati Feltételek (ÁSZF)",
"lastUpdated": "2026. április 26.", "lastUpdated": "2026. április 26.",
"sections": [], "sections": [
"lastUp": { {
"$1ate": { "id": "bevezetes",
"$1": "2026. április 26."
}
},
"sections[0]": {
"i": {
"$1": "bevezetes"
},
"title": "1. Általános rendelkezések", "title": "1. Általános rendelkezések",
"content": "Jelen Általános Szerződési Feltételek (ÁSZF) a mozdIT Bt. (székhely: Szigetszentmiklós, Magyarország; e-mail: info@mozdit.hu) által nyújtott webtárhely, e-mail szolgáltatás és domain regisztráció szolgáltatásainak igénybevételére vonatkozó szabályokat tartalmazzák. A szolgáltatások megrendelésével és használatával az Ügyfél elfogadja jelen feltételeket." "content": "Jelen Általános Szerződési Feltételek (ÁSZF) a mozdIT Bt. (székhely: Szigetszentmiklós, Magyarország; e-mail: info@mozdit.hu) által nyújtott webtárhely, e-mail szolgáltatás és domain regisztráció szolgáltatásainak igénybevételére vonatkozó szabályokat tartalmazzák. A szolgáltatások megrendelésével és használatával az Ügyfél elfogadja jelen feltételeket."
}, },
"sections[1]": { {
"i": { "id": "szolgaltatasok",
"$1": "szolgaltatasok"
},
"title": "2. Szolgáltatások leírása", "title": "2. Szolgáltatások leírása",
"content": "A mozdIT Bt. dedikált szerverein nyújt webtárhely, e-mail fiók és DNS szolgáltatásokat. A szolgáltatások garantált rendelkezésre állása 99,9% éves szinten. A szolgáltató automatikus biztonsági mentéseket végez, de javasolja az Ügyfél számára is az adatai rendszeres mentését." "content": "A mozdIT Bt. dedikált szerverein nyújt webtárhely, e-mail fiók és DNS szolgáltatásokat. A szolgáltatások garantált rendelkezésre állása 99,9% éves szinten. A szolgáltató automatikus biztonsági mentéseket végez, de javasolja az Ügyfél számára is az adatai rendszeres mentését."
}, },
"sections[2]": { {
"i": { "id": "ugyfel-kotelezettsegei",
"$1": "ugyfel-kotelezettsegei"
},
"title": "3. Az Ügyfél kötelezettségei", "title": "3. Az Ügyfél kötelezettségei",
"content": "Az Ügyfél köteles a szolgáltatásokat rendeltetésszerűen használni. Szigorúan tilos a tárhelyen és e-mail fiókokban illegális, szerzői jogot sértő tartalmak elhelyezése, valamint spam (kéretlen reklámlevél) tömeges küldése. A Szolgáltató fenntartja a jogot, hogy jogsértés vagy hálózatot veszélyeztető tevékenység (pl. DDoS forrás) észlelése esetén a szolgáltatást előzetes értesítés nélkül azonnal szüneteltesse vagy megszüntesse." "content": "Az Ügyfél köteles a szolgáltatásokat rendeltetésszerűen használni. Szigorúan tilos a tárhelyen és e-mail fiókokban illegális, szerzői jogot sértő tartalmak elhelyezése, valamint spam (kéretlen reklámlevél) tömeges küldése. A Szolgáltató fenntartja a jogot, hogy jogsértés vagy hálózatot veszélyeztető tevékenység (pl. DDoS forrás) észlelése esetén a szolgáltatást előzetes értesítés nélkül azonnal szüneteltesse vagy megszüntesse."
}, },
"sections[3]": { {
"i": { "id": "dijazas",
"$1": "dijazas"
},
"title": "4. Díjazás és fizetési feltételek", "title": "4. Díjazás és fizetési feltételek",
"content": "A szolgáltatások díjai a felek által megkötött egyedi megállapodás vagy elfogadott árajánlat alapján fizetendők. A számlázás elektronikus úton történik. A fizetési határidő elmulasztása esetén a Szolgáltató jogosult a szolgáltatás szüneteltetésére, melyről az Ügyfelet előzetesen e-mailben értesíti." "content": "A szolgáltatások díjai a felek által megkötött egyedi megállapodás vagy elfogadott árajánlat alapján fizetendők. A számlázás elektronikus úton történik. A fizetési határidő elmulasztása esetén a Szolgáltató jogosult a szolgáltatás szüneteltetésére, melyről az Ügyfelet előzetesen e-mailben értesíti."
}, },
"sections[4]": { {
"i": { "id": "felelosseg",
"$1": "felelosseg"
},
"title": "5. Felelősségkorlátozás", "title": "5. Felelősségkorlátozás",
"content": "A Szolgáltató mindent megtesz az adatok biztonsága és a folyamatos üzemeltetés érdekében, ugyanakkor nem vállal felelősséget a vis maior, illetve a harmadik felek által okozott (pl. hálózati) kimaradások miatti esetleges adatvesztésért vagy elmaradt haszonért." "content": "A Szolgáltató mindent megtesz az adatok biztonsága és a folyamatos üzemeltetés érdekében, ugyanakkor nem vállal felelősséget a vis maior, illetve a harmadik felek által okozott (pl. hálózati) kimaradások miatti esetleges adatvesztésért vagy elmaradt haszonért."
} }
]
} }
+50 -70
View File
@@ -1,119 +1,99 @@
{ {
"hero": { "hero": {
"0": { "title": "Megbízható web és emailszolgáltatás személyre szabott támogatással",
"$1escription": "A mozdIT Bt. célja, hogy megbízható, támogató szolgáltatásokkal segítse ügyfeleit a digitális térben való sikerben."
},
"title": "Web és e-mail testreszabott szolgáltatásokkal",
"subtitle": "Kis ügyfélkör, nagy figyelem: stabil tárhely, üzembiztos levelezés és DNS adminisztráció — gyors reakcióval.", "subtitle": "Kis ügyfélkör, nagy figyelem: stabil tárhely, üzembiztos levelezés és DNS adminisztráció — gyors reakcióval.",
"description": "A mozdIT Bt. célja, hogy megbízható, támogató szolgáltatásokkal segítse ügyfeleit a digitális térben való sikerben.", "description": "A mozdIT Bt. célja, hogy megbízható, támogató szolgáltatásokkal segítse ügyfeleit a digitális térben való sikerben.",
"trustBullets": [], "trustBullets": [
"Saját dedikált infrastruktúra",
"Webtárhely, domain, e-mail egy helyen",
"Kifejezetten kisvállalkozásokra szabva"
],
"cta": { "cta": {
"primary": { "primary": {
"text": "Beszéljük át, mire van szüksége", "text": "Beszéljük át, mire van szüksége",
"href": "/kapcsolat", "href": "/kapcsolat",
"external": "false" "external": false
}, },
"secondary": { "secondary": {
"text": "Webmail Ugrás", "text": "Webmail Ugrás",
"href": "https://mail.mozdit.hu", "href": "https://mail.mozdit.hu",
"external": true "external": true
},
"secon": {
"$1ary": {
"text": "mozdIT Webmail",
"href": "https://mail.mozdit.hu",
"external": "true"
} }
} }
}, },
"trustBullets[0]": "Saját dedikált infrastruktúra",
"trustBullets[1]": "Webtárhely, domain, e-mail egy helyen",
"trustBullets[2]": "Kisvállalkozásokra szabva"
},
"about": { "about": {
"title": "Miért érdemes minket választani?", "title": "Miért érdemes minket választani?",
"usps": [], "usps": [
"usps[0]": { {
"0": { "id": "egyszeru",
"$1escription": "Nincs technoblabla. Átlátható és könnyen használható rendszereket biztosítunk, hogy Ön a saját üzletére fókuszálhasson."
},
"i": {
"$1": "egyszeru"
},
"title": "Egyszerűen kezelhető szolgáltatások", "title": "Egyszerűen kezelhető szolgáltatások",
"description": "Nincs technoblabla. Átlátható és könnyen használható rendszereket biztosítunk, hogy Ön a saját üzletére fókuszálhasson.",
"icon": "🎯" "icon": "🎯"
}, },
"usps[1]": { {
"0": { "id": "nemtomeg",
"$1escription": "Egyedi figyelem és rugalmas, személyre szabott csomagok, amelyek tökéletesen igazodnak a vállalkozása igényeihez."
},
"i": {
"$1": "nemtomeg"
},
"title": "Nem tömegszolgáltatás", "title": "Nem tömegszolgáltatás",
"description": "Egyedi figyelem és rugalmas, személyre szabott csomagok, amelyek tökéletesen igazodnak a vállalkozása igényeihez.",
"icon": "👤" "icon": "👤"
}, },
"usps[2]": { {
"0": { "id": "egykezben",
"$1escription": "Domain, webtárhely és üzleti e-mail szolgáltatások zökkenőmentes kezelése, mindent egyetlen megbízható helyről."
},
"i": {
"$1": "egykezben"
},
"title": "Minden egy kézben", "title": "Minden egy kézben",
"description": "Domain, webtárhely és üzleti e-mail szolgáltatások zökkenőmentes kezelése, mindent egyetlen megbízható helyről.",
"icon": "🧩" "icon": "🧩"
}, },
"usps[3]": { {
"0": { "id": "modern_tech",
"$1escription": "TLS/SSL védelemmel, modern biztonsági protokollokkal és gyors (SSD) kiszolgálással biztosítjuk a maximális teljesítményt."
},
"i": {
"$1": "modern_tech"
},
"title": "Modern és Biztonságos", "title": "Modern és Biztonságos",
"description": "TLS/SSL védelemmel, modern biztonsági protokollokkal és gyors (SSD) kiszolgálással biztosítjuk a maximális teljesítményt.",
"icon": "🚀" "icon": "🚀"
} }
]
}, },
"services": { "services": {
"title": "Szolgáltatásaink", "title": "Szolgáltatásaink",
"subtitle": "Komplex megoldásokat kínálunk, amelyek tökéletesen igazodnak a vállalkozás igényeihez.", "subtitle": "Komplex megoldásokat kínálunk, amelyek tökéletesen igazodnak a vállalkozás igényeihez.",
"items": [], "items": [
"items[0]": { {
"0": { "id": "hosting",
"$1escription": "Stabil és gyors tárhely szolgáltatás saját infrastruktúrán, biztonsági mentésekkel és magas rendelkezésre állással."
},
"i": {
"$1": "hosting"
},
"title": "Webtárhely (Hosting)", "title": "Webtárhely (Hosting)",
"description": "Stabil és gyors tárhely szolgáltatás saját infrastruktúrán, biztonsági mentésekkel és magas rendelkezésre állással.",
"icon": "🔥", "icon": "🔥",
"features": "99.9% uptime garancia,SSD alapú gyors kiszolgálás,24/7 rendszeres monitoring,Automatikus backup rendszer", "features": [
"99.9% uptime garancia",
"SSD alapú gyors kiszolgálás",
"24/7 rendszeres monitoring",
"Automatikus backup rendszer"
],
"ctaText": "További információk" "ctaText": "További információk"
}, },
"items[1]": { {
"0": { "id": "email",
"$1escription": "Üzleti, saját domain alapú levelezés magas szintű spamszűréssel, bárhonnan elérhető webmail felülettel."
},
"i": {
"$1": "email"
},
"title": "E-mail szolgáltatás", "title": "E-mail szolgáltatás",
"description": "Üzleti, saját domain alapú levelezés magas szintű spamszűréssel, bárhonnan elérhető webmail felülettel.",
"icon": "📧", "icon": "📧",
"features": "Saját domain alapú e-mail címek,Biztonságos webmail és IMAP/POP3,Erős spam- és vírusvédelem,Nagy tárterület", "features": [
"Saját domain alapú e-mail címek",
"Biztonságos webmail és IMAP/POP3",
"Erős spam- és vírusvédelem",
"Nagy tárterület"
],
"ctaText": "További információk" "ctaText": "További információk"
}, },
"items[2]": { {
"0": { "id": "domain",
"$1escription": "Teljes körű domain regisztráció, kezelés és biztonságos névszerver (DNS) adminisztráció."
},
"i": {
"$1": "domain"
},
"title": "Domain és DNS", "title": "Domain és DNS",
"description": "Teljes körű domain regisztráció, kezelés és biztonságos névszerver (DNS) adminisztráció.",
"icon": "🌐", "icon": "🌐",
"features": "Magyar és nemzetközi domainek,Biztonságos DNS kezelés,Email routing (SPF, DKIM, DMARC),Gyors ügyintézés", "features": [
"Magyar és nemzetközi domainek",
"Biztonságos DNS kezelés",
"Email routing (SPF, DKIM, DMARC)",
"Gyors ügyintézés"
],
"ctaText": "További információk" "ctaText": "További információk"
} }
]
}, },
"cta": { "cta": {
"title": "Kérjen személyre szabott ajánlatot", "title": "Kérjen személyre szabott ajánlatot",
+65 -55
View File
@@ -1,41 +1,8 @@
{ {
"0": {
"$1etails": {
"title": "Részletes szolgáltatásleírás",
"subtitle": "Minden szolgáltatásunk mögött évtizedes tapasztalat és modern technológia áll",
"services[0]": {
"0": {
"$1escription": "Weboldalak biztonságos és gyors üzemeltetése SSD tárolással, automatikus biztonsági mentéssel és 24/7 monitoringgal. Támogatjuk a PHP, Python, Node.js technológiákat és MySQL/PostgreSQL adatbázisokat."
},
"icon": "🌐",
"title": "Web Hosting részletesen",
"specs": "[object Object]"
},
"services[1]": {
"0": {
"$1escription": "Professzionális email fiókok saját domain névvel, spam szűréssel és vírusvédelemmel. Webmail felület és IMAP/POP3/SMTP támogatás minden népszerű email klienssel."
},
"icon": "✉️",
"title": "Email szolgáltatás részletesen",
"specs": "[object Object]"
},
"services[2]": {
"0": {
"$1escription": "Teljes DNS kezelés domain regisztrációval, átvitellel és professzionális beállításokkal. Gyors propagáció és megbízható névszerverek világszerte."
},
"icon": "⚙️",
"title": "DNS adminisztráció részletesen",
"specs": "[object Object]"
}
}
},
"meta": { "meta": {
"0": {
"$1escription": "Webhosting, email szolgáltatás és DNS adminisztráció. Ismerje meg részletes szolgáltatásainkat."
},
"title": "Szolgáltatások", "title": "Szolgáltatások",
"description": "Webhosting, email szolgáltatás és DNS adminisztráció professzionális szinten. Ismerje meg részletes szolgáltatásainkat.", "description": "Webhosting, email szolgáltatás és DNS adminisztráció professzionális szinten. Ismerje meg részletes szolgáltatásainkat.",
"ogDescription": "Webhosting, email szolgáltatás és DNS adminisztráció." "ogDescription": "Webhosting, email szolgáltatás és DNS adminisztráció professzionális szinten."
}, },
"hero": { "hero": {
"title": "Szolgáltatásaink", "title": "Szolgáltatásaink",
@@ -44,41 +11,84 @@
"details": { "details": {
"title": "Részletes szolgáltatásleírás", "title": "Részletes szolgáltatásleírás",
"subtitle": "Minden szolgáltatásunk mögött évtizedes tapasztalat és modern technológia áll", "subtitle": "Minden szolgáltatásunk mögött évtizedes tapasztalat és modern technológia áll",
"services": [] "services": [
{
"icon": "🌐",
"title": "Web Hosting részletesen",
"description": "Weboldalak biztonságos és gyors üzemeltetése SSD tárolással, automatikus biztonsági mentéssel és 24/7 monitoringgal. Támogatjuk a PHP, Python, Node.js technológiákat és MySQL/PostgreSQL adatbázisokat.",
"specs": {
"title": "Technikai specifikációk:",
"items": [
"SSD tárhely 10GB-tól 500GB-ig",
"Havi adatforgalom: korlátlan",
"SSL tanúsítványok (Let's Encrypt vagy prémium)",
"CDN integráció a gyorsabb betöltésért",
"Automatikus napi biztonsági mentés",
"cPanel vagy egyedi admin felület"
]
}
},
{
"icon": "✉️",
"title": "Email szolgáltatás részletesen",
"description": "Professzionális email fiókok saját domain névvel, spam szűréssel és vírusvédelemmel. Webmail felület és IMAP/POP3/SMTP támogatás minden népszerű email klienssel.",
"specs": {
"title": "Email funkciók:",
"items": [
"Korlátlan email fiókok létrehozása",
"5GB-50GB tárhelyet fiókként",
"Webmail hozzáférés (Roundcube/SOGo)",
"Mobilalkalmazás szinkronizáció",
"Spam és vírusszűrés",
"Email továbbítás és automatikus válaszok",
"Backup és archiválás"
]
}
},
{
"icon": "⚙️",
"title": "DNS adminisztráció részletesen",
"description": "Teljes DNS kezelés domain regisztrációval, átvitellel és professzionális beállításokkal. Gyors propagáció és megbízható névszerverek világszerte.",
"specs": {
"title": "DNS szolgáltatások:",
"items": [
"Domain regisztráció (.hu, .com, .eu, stb.)",
"Domain átvitel más szolgáltatótól",
"DNS rekord kezelés (A, CNAME, MX, TXT)",
"Subdomain beállítások",
"Redirect és forwarding szolgáltatások",
"DNSSEC támogatás",
"API hozzáférés fejlesztőknek"
]
}
}
]
}, },
"support": { "support": {
"title": "Műszaki támogatás", "title": "Műszaki támogatás",
"subtitle": "Minden szolgáltatásunkhoz teljes körű műszaki támogatást biztosítunk", "subtitle": "Minden szolgáltatásunkhoz teljes körű műszaki támogatást biztosítunk",
"channels": [], "channels": [
"channels[0]": { {
"0": {
"$1escription": "24 órán belüli válasz"
},
"icon": "✉️", "icon": "✉️",
"title": "Email támogatás" "title": "Email támogatás",
}, "description": "24 órán belüli válasz"
"channels[1]": {
"0": {
"$1escription": "Munkaidőben elérhető"
}, },
{
"icon": "📞", "icon": "📞",
"title": "Telefonos segítség" "title": "Telefonos segítség",
}, "description": "Munkaidőben elérhető"
"channels[2]": {
"0": {
"$1escription": "Azonnali beavatkozás"
}, },
{
"icon": "🚨", "icon": "🚨",
"title": "Sürgős esetek" "title": "Sürgős esetek",
"description": "Azonnali beavatkozás"
} }
]
}, },
"cta": { "cta": {
"title": "Kezdjük el a közös munkát!", "title": "Kezdjük el a közös munkát!",
"subtitle": "Vegye fel velünk a kapcsolatot ingyenes konzultációért és egyedi ajánlatért.", "subtitle": "Vegye fel velünk a kapcsolatot ingyenes konzultációért és egyedi ajánlatért.",
"primaryButton": "Kapcsolatfelvétel", "primaryButton": "Kapcsolatfelvétel",
"secondaryButton": "Webmail belépés", "secondaryButton": "Webmail belépés"
"secon": {
"$1aryButton": "Webmail belépés"
}
} }
} }
+102
View File
@@ -0,0 +1,102 @@
// Shared runtime schema for the content JSON files.
// Kept dependency-free so it can run in both Next.js and content-editor.js.
const string = { type: 'string' };
const boolean = { type: 'boolean' };
const array = items => ({ type: 'array', items });
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
const ctaLink = object({ text: string, href: string, external: boolean });
const iconText = object({ icon: string, title: string, description: string });
const legal = object({ id: string, title: string, content: string });
const schemas = {
common: object({
buttons: object({ contact: string, learnMore: string, webmail: string, sendMessage: string }),
labels: object({ required: string, features: string }),
validation: object({ required: string, invalidEmail: string, minLength: string }),
}),
home: object({
hero: object({ title: string, subtitle: string, description: string, trustBullets: array(string), cta: object({ primary: ctaLink, secondary: ctaLink }) }),
about: object({ title: string, usps: array(object({ id: string, title: string, description: string, icon: string })) }),
services: object({ title: string, subtitle: string, items: array(object({ id: string, title: string, description: string, icon: string, features: array(string), ctaText: string })) }),
cta: object({ title: string, subtitle: string, button: string }),
serviceFeatures: object({ title: string }),
}),
about: object({
meta: object({ title: string, description: string, ogDescription: string }),
hero: object({ title: string, subtitle: string }),
story: object({ title: string, paragraphs: array(string) }),
mission: object({ title: string, subtitle: string, values: array(iconText) }),
team: object({ title: string, subtitle: string, paragraphs: array(string) }),
cta: object({ title: string, subtitle: string, button: string }),
}),
services: object({
meta: object({ title: string, description: string, ogDescription: string }),
hero: object({ title: string, subtitle: string }),
details: object({ title: string, subtitle: string, services: array(object({ icon: string, title: string, description: string, specs: object({ title: string, items: array(string) }) })) }),
support: object({ title: string, subtitle: string, channels: array(iconText) }),
cta: object({ title: string, subtitle: string, primaryButton: string, secondaryButton: string }),
}),
contact: object({
meta: object({ title: string, description: string }),
hero: object({ title: string, subtitle: string }),
form: object({
title: string, successMessage: string, errorMessage: string,
fields: object({
name: object({ label: string, placeholder: string, error: string }),
email: object({ label: string, placeholder: string, errorRequired: string, errorInvalid: string }),
subject: object({ label: string, placeholder: string, error: string }),
message: object({ label: string, placeholder: string, errorRequired: string, errorMinLength: string }),
gdpr: object({ label: string, error: string }),
}),
submitButton: string, submittingButton: string,
}),
info: object({
title: string,
email: object({ title: string, responseTime: string }),
company: object({ title: string }),
webmail: object({ title: string, linkText: string, subtitle: string }),
}),
faq: object({ title: string, items: array(object({ question: string, answer: string })) }),
}),
adatvedelem: object({ title: string, lastUpdated: string, sections: array(legal) }),
hasznalatiFeltetelek: object({ title: string, lastUpdated: string, sections: array(legal) }),
};
function validateNode(value, schema, path, errors) {
if (schema.type === 'string' || schema.type === 'boolean') {
if (typeof value !== schema.type) errors.push(`${path}: ${schema.type} érték szükséges`);
return;
}
if (schema.type === 'array') {
if (!Array.isArray(value)) {
errors.push(`${path}: tömb szükséges`);
return;
}
value.forEach((item, index) => validateNode(item, schema.items, `${path}[${index}]`, errors));
return;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
errors.push(`${path}: objektum szükséges`);
return;
}
for (const key of schema.required) {
if (!(key in value)) errors.push(`${path}.${key}: kötelező mező hiányzik`);
}
for (const key of Object.keys(value)) {
if (!(key in schema.properties)) errors.push(`${path}.${key}: nem engedélyezett mező`);
}
for (const [key, child] of Object.entries(schema.properties)) {
if (key in value) validateNode(value[key], child, `${path}.${key}`, errors);
}
}
function validateContent(fileKey, data) {
const schema = schemas[fileKey];
if (!schema) return { ok: false, errors: [`Ismeretlen content fájl: ${fileKey}`] };
const errors = [];
validateNode(data, schema, '$', errors);
return { ok: errors.length === 0, errors };
}
module.exports = { schemas, validateContent };
+26 -1
View File
@@ -28,6 +28,32 @@ export interface TechSpec {
// Page-specific content types // Page-specific content types
export interface HomePageContent { export interface HomePageContent {
hero: {
title: string
subtitle: string
description: string
trustBullets: string[]
cta: {
primary: CTAContent
secondary: CTAContent
}
}
about: {
title: string
usps: Array<IconTextItem & { id: string }>
}
services: {
title: string
subtitle: string
items: Array<{
id: string
title: string
description: string
icon: string
features: string[]
ctaText: string
}>
}
cta: { cta: {
title: string title: string
subtitle: string subtitle: string
@@ -188,4 +214,3 @@ export interface SiteContent {
hasznalatiFeltetelek: LegalPageContent hasznalatiFeltetelek: LegalPageContent
} }
} }
+3 -3
View File
@@ -1,4 +1,4 @@
import { MongoClient, Db } from 'mongodb' import { MongoClient, Db, Collection, Document } from 'mongodb'
if (!process.env.MONGODB_URI) { if (!process.env.MONGODB_URI) {
throw new Error('Please add MONGODB_URI to your environment variables') throw new Error('Please add MONGODB_URI to your environment variables')
@@ -37,9 +37,9 @@ export async function getDb(): Promise<Db> {
return client.db(process.env.MONGODB_DB || 'mozdit') return client.db(process.env.MONGODB_DB || 'mozdit')
} }
export async function getCollection(collectionName: string) { export async function getCollection<T extends Document = Document>(collectionName: string): Promise<Collection<T>> {
const db = await getDb() const db = await getDb()
return db.collection(collectionName) return db.collection<T>(collectionName)
} }
// Health check for MongoDB connection // Health check for MongoDB connection
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { backupAndWriteAtomically } = require('../content-editor');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mozdit-content-save-'));
try {
const target = path.join(tempDir, 'home.json');
const backupDir = path.join(tempDir, 'backups');
const original = { title: 'Eredeti', enabled: true };
const replacement = { title: 'Új', enabled: false };
fs.writeFileSync(target, JSON.stringify(original) + '\n');
const backupFile = backupAndWriteAtomically(target, replacement, backupDir);
assert.deepEqual(JSON.parse(fs.readFileSync(target, 'utf8')), replacement);
assert.deepEqual(JSON.parse(fs.readFileSync(backupFile, 'utf8')), original);
assert.equal(fs.readdirSync(tempDir).some(name => name.endsWith('.tmp')), false);
console.log('Content Editor atomic save test: OK');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
const assert = require('assert/strict');
process.env.CMS_USER = 'test-editor';
process.env.CMS_PASS = 'not-a-real-secret';
const {
hasValidCredentials,
hasValidCsrfToken,
securityConfigIsValid,
csrfToken,
} = require('../content-editor');
const authorization = Buffer.from('test-editor:not-a-real-secret').toString('base64');
assert.equal(securityConfigIsValid(), true);
assert.equal(hasValidCredentials({ headers: { authorization: `Basic ${authorization}` } }), true);
assert.equal(hasValidCredentials({ headers: { authorization: 'Basic invalid' } }), false);
assert.equal(hasValidCredentials({ headers: {} }), false);
assert.equal(hasValidCsrfToken({ headers: { 'x-csrf-token': csrfToken } }), true);
assert.equal(hasValidCsrfToken({ headers: { 'x-csrf-token': 'invalid-token' } }), false);
assert.equal(hasValidCsrfToken({ headers: {} }), false);
console.log('Content Editor security guard test: OK');
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Regressziós teszt a Content Editor böngészőben futó serializeréhez.
* A szerver által generált tényleges JavaScriptet futtatja minimális DOM-mal,
* így a szerkesztő és a teszt nem két eltérő implementációt vizsgál.
*/
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { createRequire } = require('module');
const source = fs.readFileSync('content-editor.js', 'utf8')
.replace('if (require.main === module) {', 'globalThis.renderContentEditor = HTML;\nif (false) {');
const editorRequire = createRequire(path.join(process.cwd(), 'content-editor.js'));
const serverContext = { require: editorRequire, console, process, Buffer, module: { exports: {} }, __dirname: process.cwd(), globalThis: {} };
vm.createContext(serverContext);
new vm.Script(source).runInContext(serverContext);
const fixture = {
title: 'Teszt',
enabled: true,
limit: 42,
sections: [{ id: 'first', items: ['egy', 'kettő'], settings: { visible: false, weight: 1 } }],
};
const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null);
const browserSource = [...html.matchAll(/<script(?: [^>]*)?>([\s\S]*?)<\/script>/g)].at(-1)[1]
.replace("render(DATA, document.getElementById('editor'));", '')
.replace("const toast = document.querySelector('.toast');", 'const toast = null;');
const fields = [
{ dataset: { path: 'title', type: 'string' }, value: 'Módosított' },
{ dataset: { path: 'enabled', type: 'boolean' }, checked: true },
{ dataset: { path: 'limit', type: 'number' }, value: '99' },
{ dataset: { path: 'sections[0].id', type: 'string' }, value: 'first' },
{ dataset: { path: 'sections[0].items[0]', type: 'string' }, value: 'egy' },
{ dataset: { path: 'sections[0].items[1]', type: 'string' }, value: 'kettő' },
{ dataset: { path: 'sections[0].settings.visible', type: 'boolean' }, checked: false },
{ dataset: { path: 'sections[0].settings.weight', type: 'number' }, value: '3' },
];
const document = {
getElementById: id => id === 'page-data' ? { textContent: JSON.stringify(fixture) } : {},
querySelectorAll: selector => selector === '[data-path]' ? fields : [],
querySelector: () => null,
};
const browserContext = { document, console, setTimeout, fetch: async () => ({ json: async () => ({ ok: true }) }) };
vm.createContext(browserContext);
new vm.Script(`${browserSource}\nglobalThis.__collect = collect;`).runInContext(browserContext);
assert.deepStrictEqual(JSON.parse(JSON.stringify(browserContext.__collect())), {
title: 'Módosított',
enabled: true,
limit: 99,
sections: [{ id: 'first', items: ['egy', 'kettő'], settings: { visible: false, weight: 3 } }],
});
console.log('Content Editor serializer regression test: OK');
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
const assert = require('assert/strict');
const fs = require('fs');
const { validateContent } = require('../proto/src/content/schema');
const files = {
common: 'proto/src/content/common.json',
home: 'proto/src/content/pages/home.json',
about: 'proto/src/content/pages/about.json',
services: 'proto/src/content/pages/services.json',
contact: 'proto/src/content/pages/contact.json',
adatvedelem: 'proto/src/content/pages/adatvedelem.json',
hasznalatiFeltetelek: 'proto/src/content/pages/hasznalati-feltetelek.json',
};
for (const [key, file] of Object.entries(files)) {
const result = validateContent(key, JSON.parse(fs.readFileSync(file, 'utf8')));
assert.equal(result.ok, true, `${key}: ${result.errors.join('; ')}`);
}
const invalidHome = JSON.parse(fs.readFileSync(files.home, 'utf8'));
invalidHome.hero.trustBullets = [];
invalidHome.services['items[0]'] = invalidHome.services.items[0];
assert.deepEqual(validateContent('home', invalidHome).ok, false);
assert.match(validateContent('home', invalidHome).errors.join(' '), /nem engedélyezett mező/);
console.log('Content schema validation test: OK');