Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
63 lines
7.9 KiB
JavaScript
63 lines
7.9 KiB
JavaScript
/**
|
|
* test/e2e/fake-webmaker.mjs — Webmaker 의 포털 연동 면(/api/internal/ariweb/*, /api/auth/sso)만 흉내 내는 HTTP 서버.
|
|
*
|
|
* 응답 모양은 modules/webmaker/server.js 의 handleAriwebInternal 과 portal/webmaker/adapter.mjs 가 기대하는 것을 따른다.
|
|
* - 서명 검증: lib/serviceauth(키 id 'portal', 비밀 = 포털 보관소 'sso:webmaker')
|
|
* - 프로젝트는 메모리. 저장본(versionList)은 시험 전용 끝점 POST /_test/projects/:id/version 으로 추가한다(빌더에서 저장한 것을 흉내).
|
|
* - export 는 index.html(표식 marker-<프로젝트>-v<n> 포함)을 담은 tar.gz 스트림 + X-Ariweb-Version/X-Ariweb-Commit 헤더.
|
|
* - /api/auth/sso?ticket= : HMAC 검증 뒤 302(세션 쿠키 흉내). 1회용 nonce.
|
|
*/
|
|
import http from 'node:http';
|
|
import { mkdirSync, writeFileSync, createReadStream, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
import { verifyRequest } from '../../lib/serviceauth.mjs';
|
|
import { tarGz } from './util.mjs';
|
|
|
|
export async function startFakeWebmaker(port, { secret, workDir }) {
|
|
const projects = new Map(); let seq = 0; const calls = []; const usedNonces = new Set(); const credits = new Map();
|
|
mkdirSync(workDir, { recursive: true });
|
|
const summary = (p) => ({ id: p.id, title: p.title, ownerId: p.ownerId, status: p.status, mode: p.mode, stage: p.stage, rev: p.rev, createdAt: p.createdAt, updatedAt: p.updatedAt, lastChange: p.versionList.at(-1) ? { no: p.versionList.at(-1).no, kind: p.versionList.at(-1).kind, at: p.versionList.at(-1).at, message: p.versionList.at(-1).message } : null, versions: p.versionList.length, link: p.link, hasContent: p.versionList.length > 0 });
|
|
const full = (p) => ({ ...summary(p), versionList: p.versionList.map((v) => ({ no: v.no, kind: v.kind, message: v.message, at: v.at, commit: v.commit, files: 1 })), git: { provider: 'gitea', enabled: true, remote: 'webmaker/sites', mainBranch: `sites/${p.id}/main`, mainUrl: `http://127.0.0.1:${port}/gitea/webmaker/sites/src/branch/sites/${p.id}/main` }, liveUrl: null, deployState: null, domains: [] });
|
|
const srv = http.createServer((req, res) => {
|
|
let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', async () => {
|
|
const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
calls.push({ m: req.method, p });
|
|
// 시험 전용: 저장본 추가(빌더에서 저장한 것을 흉내) — 서명 없음(127.0.0.1 전용)
|
|
if (p.startsWith('/_test/projects/') && req.method === 'POST') { const id = p.split('/')[3]; const pr = projects.get(id); if (!pr) return j(404, { error: 'no project' }); const no = pr.versionList.length + 1; pr.versionList.push({ no, kind: no === 1 ? 'create' : 'update', message: `저장본 ${no}`, at: new Date().toISOString(), commit: `c${no}-${id.slice(0, 6)}` }); pr.rev++; pr.updatedAt = new Date().toISOString(); return j(200, { ok: true, no }); }
|
|
if (p === '/_test/state') return j(200, { projects: [...projects.values()].map(full), calls: calls.length });
|
|
if (p === '/api/auth/sso') {
|
|
const t = String(url.searchParams.get('ticket') || ''); const [payload, sig] = t.split('.'); if (!payload || !sig) return j(400, { error: 'bad_ticket' });
|
|
const want = createHmac('sha256', secret).update(payload).digest('base64url'); const a = Buffer.from(want), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return j(401, { error: 'bad_signature' });
|
|
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return j(400, { error: 'bad_payload' }); }
|
|
if (d.exp < Date.now()) return j(401, { error: 'expired' }); if (usedNonces.has(d.nonce)) return j(401, { error: 'ticket_used' }); usedNonces.add(d.nonce);
|
|
res.writeHead(302, { Location: `/?project=${encodeURIComponent(d.projectId || '')}&site=${encodeURIComponent(d.siteId || '')}`, 'Set-Cookie': `wh_session=fake-${d.login}; Path=/; HttpOnly` }); return res.end();
|
|
}
|
|
if (!p.startsWith('/api/internal/ariweb/')) return j(404, { error: 'not_found' });
|
|
const v = verifyRequest({ method: req.method, path: p + url.search, headers: req.headers, rawBody: raw }, (id) => (id === 'portal' ? secret : null)); if (!v.ok) return j(401, { error: 'service_auth_failed', reason: v.reason });
|
|
let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) {}
|
|
const [kind, id, action] = p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent);
|
|
if (kind === 'health') return j(200, { ok: true, version: '1.7.3-fake', product: 'AI Webmaker(fake)', portalUrl: null });
|
|
if (kind === 'credits' && id) { const c = credits.get(id) || { balance: 12000, used: 3000, granted: 15000 }; return j(200, { ok: true, credit: { userId: id, ...c, updatedAt: new Date().toISOString(), entries: 2 }, unit: '원' }); }
|
|
if (kind !== 'projects') return j(404, { error: 'not_found' });
|
|
if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); return j(200, { ok: true, projects: [...projects.values()].filter((x) => x.ownerId === owner).map(summary) }); }
|
|
if (!id && req.method === 'POST') { const pid = (++seq).toString(16).padStart(32, '0'); const pr = { id: pid, title: String(b.title || ''), ownerId: String(b.owner || '').toLowerCase(), status: 'idle', mode: b.mode || 'quick', stage: b.mode === 'ai' ? 'input' : null, rev: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), versionList: [], link: b.link && b.link.siteId ? { ...b.link, linkedAt: new Date().toISOString() } : null }; projects.set(pid, pr); return j(200, { ok: true, project: summary(pr) }); }
|
|
const pr = projects.get(id); if (!pr) return j(404, { error: '프로젝트가 없습니다' });
|
|
if (!action && req.method === 'GET') return j(200, { ok: true, project: full(pr) });
|
|
if (action === 'link' && req.method === 'POST') { pr.link = { ...b, linkedAt: new Date().toISOString() }; return j(200, { ok: true, link: pr.link }); }
|
|
if (action === 'link' && req.method === 'DELETE') { const had = !!pr.link; pr.link = null; return j(200, { ok: true, removed: had }); }
|
|
if (action === 'source' && req.method === 'GET') return j(200, { ok: true, source: { ...full(pr).git, repoBytes: 2048, siteBytes: 4096, uploadBytes: 0, lfsBytes: 0, lfsSupported: false, changes: pr.versionList.map((x) => ({ ...x, pushed: true, branch: `sites/${pr.id}/changes/${String(x.no).padStart(4, '0')}-${x.kind}`, url: null })), remoteStatus: { ok: true } } });
|
|
if (action === 'export' && req.method === 'GET') {
|
|
const vno = url.searchParams.get('version'); const ch = vno ? pr.versionList.find((x) => String(x.no) === String(vno)) : pr.versionList.at(-1); if (vno && !ch) return j(404, { error: '해당 저장본이 없습니다' });
|
|
const dir = join(workDir, `export-${pr.id}-${Date.now()}`); mkdirSync(dir, { recursive: true }); const n = ch ? ch.no : 0;
|
|
writeFileSync(join(dir, 'index.html'), `<!doctype html><title>${pr.title}</title><h1>${pr.title}</h1><p>marker-${pr.id.slice(0, 8)}-v${n}</p>`); writeFileSync(join(dir, 'ariweb.json'), JSON.stringify({ runtime: 'static' }));
|
|
const file = join(workDir, `export-${pr.id}-${Date.now()}.tar.gz`); tarGz(dir, file); rmSync(dir, { recursive: true, force: true });
|
|
res.writeHead(200, { 'Content-Type': 'application/gzip', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': ch ? ch.commit : '' }); createReadStream(file).pipe(res); return;
|
|
}
|
|
j(404, { error: 'not_found' });
|
|
});
|
|
});
|
|
await new Promise((r) => srv.listen(port, '127.0.0.1', r));
|
|
return { url: `http://127.0.0.1:${port}`, projects, calls, addVersion: async (id) => { const r = await fetch(`http://127.0.0.1:${port}/_test/projects/${id}/version`, { method: 'POST' }); return r.json(); }, close: () => new Promise((r) => srv.close(r)) };
|
|
}
|