Files

182 lines
28 KiB
JavaScript
Raw Permalink Normal View History

/** test/sites.test.mjs — 사이트·배포 영역 회귀 테스트(가짜 HAL·가짜 Webmaker). */
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFakeHal } from '../portal/sites/fake-hal.mjs';
import { createFake as fakeWebmaker } from '../portal/webmaker/adapter.mjs';
import * as quota from '../lib/quota.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
async function setup({ sites = 2, disk = 10e9 } = {}) {
const deps = await makeDeps(); deps.hal = createFakeHal(); deps.webmaker = fakeWebmaker({ store: deps.store });
const who = seedGroup(deps); if (sites) quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'sites', amount: sites }); if (disk) quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'web_disk', amount: disk });
const app = await appFor(deps, ['sites', 'domains', 'source', 'webmaker'], who); return { deps, who, app, S: deps.sites, D: deps.domains };
}
await test('초안 생성은 사이트 수를 쓰지 않고 프로젝트를 만들어 연결한다 · 다음 단계 안내 · 목록 필터', async () => {
const { deps, who, app } = await setup();
const r = await call(app, 'POST', '/api/sites', { name: '우리 회사', purpose: '소개', projectMode: 'new', buildMode: 'ai' }); eq(r.status, 200, r.text); eq(r.json.site.lifecycle, 'draft'); eq(r.json.site.site_count_state, 'none'); ok(r.json.site.webmaker_project_id); ok(r.json.nextSteps.find((s) => s.id === 'import').unsupported);
eq(quota.figures(deps.store, who.group.id, 'sites').reserved, 0);
const p = deps.webmaker._projects.get(r.json.site.webmaker_project_id); eq(p.link.siteId, r.json.site.id); eq(p.mode, 'ai');
const l = await call(app, 'GET', '/api/sites?filter=draft'); eq(l.json.sites.length, 1); eq((await call(app, 'GET', '/api/sites?filter=live')).json.sites.length, 0);
// 기존 프로젝트 연결: 다른 사이트에 이미 연결된 프로젝트는 거부, 내 것이 아니면 거부
const other = await deps.webmaker.createProject({ login: 'someone-else', title: 'x' }); eq((await call(app, 'POST', '/api/sites', { name: 'B', projectMode: 'existing', projectId: other.id })).json.error, 'project_not_owned');
eq((await call(app, 'POST', '/api/sites', { name: 'C', projectMode: 'existing', projectId: r.json.site.webmaker_project_id })).json.error, 'project_linked');
eq((await call(app, 'POST', '/api/sites', { name: '' })).json.detail.field, 'name');
});
await test('배포 전 확인 → 최초 공개 배포는 사이트 수를 예약하고, 10단계 작업이 HAL 결과로 완료되며 대표 도메인 확인 뒤 사용 중으로 확정된다', async () => {
const { deps, who, app, S, D } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'A', projectMode: 'new' })).json.site;
let pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(!pc.ok); ok(pc.items.find((i) => i.id === 'version' && i.state === 'block')); ok(pc.items.find((i) => i.id === 'target' && i.state === 'block' && i.href.endsWith('/deploy/mode')));
deps.webmaker._addVersion(s.webmaker_project_id, 'create', '생성'); await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'managed' });
pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; eq(pc.ok, true, JSON.stringify(pc.items.filter((i) => i.state === 'block')));
const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: 'current' }); eq(d.status, 200, d.text); ok(d.json.created); eq(d.json.job.steps.length, 10);
eq(S.get(s.id).site_count_state, 'reserved'); eq(quota.figures(deps.store, who.group.id, 'sites').reserved, 1); ok(quota.allocationOf(deps.store, who.group.id, 'web_disk', 'site', s.id), '웹 디스크 기본 배정');
const again = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: 'current' }); eq(again.json.created, false, '같은 배포 요청은 하나');
await deps.jobs.drain(); const j = deps.jobs.get(d.json.job.id); eq(j.status, 'done', j.error); eq(j.steps.filter((x) => x.state === 'done').length, 10); eq(j.result.verify, 'needs_domain');
const site = S.get(s.id); eq(site.lifecycle, 'needs_domain'); eq(site.deploy_verify, 'needs_domain'); ok(site.current_version); eq(site.site_count_state, 'reserved', '대표 도메인 전에는 예약 유지'); eq(site.server_id, 'srv_web1');
const hc = deps.hal.calls.find((c) => c.kind === 'site.deploy'); ok(hc.payload.bundleUrl.includes('/internal/bundles/')); eq(hc.payload.external, false);
// 번들 다운로드 토큰
const tok = hc.payload.bundleUrl.split('/internal/bundles/')[1]; eq((await call(app, 'GET', `/internal/bundles/${tok}`)).status, 404, '배포가 끝나면 번들 파일은 지워진다'); eq((await call(app, 'GET', '/internal/bundles/bogus')).status, 404);
// 도메인 연결 → 소유 TXT 반영 → DNS 반영 → 인증서 → 외부 확인 → 대표 지정 → 공개 운영 중 + 사이트 수 사용 중
const dm = await call(app, 'POST', '/api/domains', { name: 'Example.com', purpose: 'web', siteId: s.id, makePrimary: true }); eq(dm.status, 200, dm.text); const dom = dm.json.domain; ok(dm.json.records.find((r) => r.type === 'TXT' && r.value.startsWith('ariweb-verify=')));
await deps.jobs.drain(); let dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'awaiting_external'); ok(/TXT/.test(dj.wait_reason));
deps.hal.script.dns.txt[`_ariweb-verify.example.com`] = `ariweb-verify=${dm.json.domain.ownershipToken}`; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'awaiting_external'); ok(/A 레코드가 아직/.test(dj.wait_reason), dj.wait_reason); eq(D.get(dom.id).ownership_state, 'verified');
deps.hal.script.dns.a = ['198.51.100.9']; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); ok(/값이 다릅니다/.test(dj.wait_reason), '잘못된 값 구분'); eq(D.get(dom.id).dns_state, 'wrong');
deps.hal.script.dns.a = ['203.0.113.10']; await call(app, 'POST', `/api/domains/${dom.id}/check`, {}); await deps.jobs.drain(); dj = deps.jobs.get(dm.json.job.id); eq(dj.status, 'done', dj.error);
const fin = D.get(dom.id); eq([fin.dns_state, fin.cert_state, !!fin.is_primary], ['ok', 'valid', true]);
const site2 = S.get(s.id); eq(site2.lifecycle, 'live'); eq(site2.deploy_verify, 'public'); eq(site2.primary_domain, 'example.com'); eq(site2.site_count_state, 'in_use'); eq(quota.figures(deps.store, who.group.id, 'sites').allocated, 1);
// 개요: 관리형 지표
const ov = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json; eq(ov.overview.kind, 'managed'); await deps.jobs.drain(); const ov2 = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json.overview; eq(ov2.visits24h, 800); eq(ov2.disk.limit, 1024 ** 3); ok(ov2.disk.used > 0); eq(ov2.health, 'ok');
eq((await call(app, 'GET', `/api/sites/${s.id}/usage`)).json.allocations.find((a) => a.resource === 'web_disk').usage.used, 145e6);
// 두 번째 도메인은 사이트 수를 더 쓰지 않는다 · 중복 도메인은 다른 그룹에서 거부
await call(app, 'POST', '/api/domains', { name: 'www2.example.com', purpose: 'web', siteId: s.id, startCheck: false }); eq(quota.figures(deps.store, who.group.id, 'sites').allocated, 1);
const other = seedGroup(deps, { email: 'b@example.test', name: 'B' }); const app2 = await appFor(deps, ['sites', 'domains'], other); const dup = await call(app2, 'POST', '/api/domains', { name: 'example.com', purpose: 'web' }); eq(dup.status, 409); eq(dup.json.error, 'domain_taken'); ok(!dup.text.includes('A'), '다른 고객 정보 비노출'); eq((await call(app2, 'GET', `/api/domains/${dom.id}`)).status, 404, '다른 그룹 도메인은 404');
});
await test('마지막 사이트 수 1개를 두 사이트가 동시에 배포 요청하면 하나만 성공하고 다른 쪽은 부족 사유', async () => {
const { deps, app } = await setup({ sites: 1 });
const mk = async (n) => { const s = (await call(app, 'POST', '/api/sites', { name: n, projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id); return s; };
const a = await mk('A'); const b = await mk('B');
const [ra, rb] = await Promise.all([call(app, 'POST', `/api/sites/${a.id}/deploy`, {}), call(app, 'POST', `/api/sites/${b.id}/deploy`, {})]);
const okc = [ra, rb].filter((r) => r.status === 200); const bad = [ra, rb].find((r) => r.status !== 200); eq(okc.length, 1); eq(bad.status, 409); eq(bad.json.error, 'precheck_failed'); ok(bad.json.detail.items.find((i) => i.id === 'sites' && i.state === 'block' && i.href === '/billing'), JSON.stringify(bad.json.detail.items));
});
await test('배포 실패: 이전 버전 유지 · 실패 단계·문의 번호 · 재시도 · 되돌리기 · 중간 결과 정리와 예약 반환', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'A', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id, 'create', 'v1');
await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '1' }); await deps.jobs.drain(); eq(S.get(s.id).current_version, 'v1');
// 두 번째 배포 실패 → 이전 버전 유지, 실패 단계 표시
deps.webmaker._addVersion(s.webmaker_project_id, 'update', 'v2'); deps.hal.script.failKinds.add('site.deploy');
const d2 = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '2' }); await deps.jobs.drain(); let j = deps.jobs.get(d2.json.job.id); eq(j.status, 'failed'); ok(/내부 확인 실패/.test(j.error)); eq(S.get(s.id).current_version, 'v1', '이전 버전 유지');
const deps2 = (await call(app, 'GET', `/api/sites/${s.id}/deployments`)).json; eq(deps2.deployments[0].status, 'failed'); eq(deps2.deployments[0].result.failedStep, 'internal_check'); ok(deps2.deployments[0].job.canRetry);
const n = deps.store.all('SELECT * FROM notifications WHERE group_id=?', who.group.id); ok(n.find((x) => /배포 실패/.test(x.title) && x.body.includes(j.id)), '문의 번호 포함 알림');
// 재시도 성공
deps.hal.script.failKinds.delete('site.deploy'); const rt = await call(app, 'POST', `/api/sites/${s.id}/jobs/${j.id}/retry`); eq(rt.status, 200); await deps.jobs.drain(); j = deps.jobs.get(j.id); eq(j.status, 'done', j.error); eq(S.get(s.id).current_version, 'v2');
// 되돌리기 → v1, 외부 확인 포함
const rb = await call(app, 'POST', `/api/sites/${s.id}/rollback`); eq(rb.status, 200, rb.text); await deps.jobs.drain(); eq(deps.jobs.get(rb.json.job.id).status, 'done'); eq(S.get(s.id).current_version, 'v1'); ok(deps.hal.calls.find((c) => c.kind === 'site.rollback' && c.payload.toVersion === 'v1'));
// 최초 배포 실패 → 예약 반환 예정 + 정리하고 반환
const t = (await call(app, 'POST', '/api/sites', { name: 'T', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(t.webmaker_project_id); deps.hal.script.failKinds.add('site.deploy');
await call(app, 'POST', `/api/sites/${t.id}/deploy`, {}); await deps.jobs.drain(); let ts = S.get(t.id); eq(ts.site_count_state, 'reserved'); ok(ts.return_due_at); ok(ts.pendingCleanup); ok(!ts.current_version);
deps.hal.script.failKinds.delete('site.deploy'); const cl = await call(app, 'POST', `/api/sites/${t.id}/cleanup`); eq(cl.status, 200, cl.text); await deps.jobs.drain(); ts = S.get(t.id); eq(ts.site_count_state, 'none'); eq(ts.lifecycle, 'building'); eq(deps.store.get('SELECT state FROM quota_reservations WHERE subject_id=?', t.id).state, 'returned'); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.payload.siteId === t.id));
// 자동 반환(sweep): 반환 예정 시각이 지난 예약
const u = (await call(app, 'POST', '/api/sites', { name: 'U', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(u.webmaker_project_id); deps.hal.script.failKinds.add('site.deploy'); await call(app, 'POST', `/api/sites/${u.id}/deploy`, {}); await deps.jobs.drain(); deps.hal.script.failKinds.delete('site.deploy');
eq(S.sweep(Date.now() + 25 * 3600 * 1000).returned, 1); eq(S.get(u.id).site_count_state, 'none');
// 프로세스 재시작 복구: 진행 중 작업이 이어진다
deps.hal.script.pendingKinds.add('site.deploy'); const d3 = await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '2' }); await deps.jobs.drain(); j = deps.jobs.get(d3.json.job.id); eq(j.status, 'queued', 'HAL 진행 중이면 다시 깨어날 예정'); ok(/진행 중/.test(j.wait_reason));
{ const hc3 = deps.hal.calls.filter((c) => c.kind === 'site.deploy').pop(); const tok3 = hc3.payload.bundleUrl.split('/internal/bundles/')[1]; const b3 = await call(app, 'GET', `/internal/bundles/${tok3}`); eq(b3.status, 200, '진행 중에는 Agent 가 번들을 받을 수 있다'); }
deps.store.run('UPDATE jobs SET status=\'running\' WHERE id=?', j.id); eq(deps.jobs.recover(), 1); const hid = deps.hal.calls.filter((c) => c.kind === 'site.deploy').pop(); deps.hal.script.pendingKinds.delete('site.deploy'); deps.hal._finish([...deps.hal.jobs.values()].find((x) => x.idempotencyKey.startsWith(`portal:${j.id}:`)).id);
deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', new Date(0).toISOString(), j.id); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.hal.calls.filter((c) => c.kind === "site.deploy" && c.idempotencyKey.startsWith(`portal:${j.id}:`)).length, 1, "재시작 뒤에도 HAL 작업은 하나");
});
await test('외부 배포: 동의 기록 → Agent 토큰 발급(1회 표시) → 연결 → 배포 → 개요에 관리형 지표 없음 · 제거 · 메일 미지원 안내', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'X', projectMode: 'new', deployMode: 'external' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
const mi = (await call(app, 'GET', `/api/sites/${s.id}/mode`)).json; ok(mi.external.notProvided.includes('메일(외부 메일 배포 미지원)')); eq(mi.consent.agreed, false);
eq((await call(app, 'POST', `/api/sites/${s.id}/external`, {})).json.error, 'consent_required');
eq((await call(app, 'POST', `/api/sites/${s.id}/consent`, { version: mi.consent.version, agree: true })).status, 200); eq(S.get(s.id).consent_version, mi.consent.version);
const tk = await call(app, 'POST', `/api/sites/${s.id}/external`, { label: '고객 서버' }); eq(tk.status, 200, tk.text); ok(tk.json.token.startsWith('xat_')); ok(tk.json.installCommand.includes(tk.json.token)); ok(!JSON.stringify(deps.store.all('SELECT * FROM external_agents')).includes(tk.json.token), '토큰 저장 안 함');
let pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(pc.items.find((i) => i.id === 'target' && i.state === 'block'));
deps.hal._connectAgent(tk.json.agent.hal_agent_id); const ex = (await call(app, 'GET', `/api/sites/${s.id}/external`)).json; eq(ex.agent.status, 'connected');
pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; eq(pc.ok, true, JSON.stringify(pc.items.filter((i) => i.state === 'block'))); ok(!pc.items.find((i) => i.id === 'capacity'), '외부는 관리형 용량 확인 없음');
const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); eq(d.status, 200, d.text); await deps.jobs.drain(); eq(deps.jobs.get(d.json.job.id).status, 'done'); const hc = deps.hal.calls.find((c) => c.kind === 'site.deploy'); eq(hc.target, tk.json.agent.hal_agent_id); eq(hc.payload.external, true);
const ov = (await call(app, 'GET', `/api/sites/${s.id}/overview`)).json.overview; eq(ov.kind, 'external'); eq(ov.disk, undefined); eq(ov.health, undefined); ok(ov.notProvided.includes('백업 보호 상태')); eq(ov.agent.status, 'connected'); ok(ov.lastDeployResult.ok);
eq((await call(app, 'GET', `/api/sites`)).json.sites[0].health, 'not_provided');
const rm = await call(app, 'POST', `/api/sites/${s.id}/external/remove`); eq(rm.status, 200); await deps.jobs.drain(); eq(S.get(s.id).current_version, null); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.target === tk.json.agent.hal_agent_id));
await call(app, 'DELETE', `/api/sites/${s.id}/external`); eq(S.externalAgentFor(s.id), null);
});
await test('배포 방식 변경(운영 중)은 새 위치 준비→별도 배포→검증→도메인 전환→이전 위치 정리 작업이며 실패하면 현재 위치 유지', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'M', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain(); eq(S.get(s.id).server_id, 'srv_web1');
await call(app, 'POST', `/api/sites/${s.id}/consent`, { version: S.EXTERNAL_CONSENT.version, agree: true }); const tk = await call(app, 'POST', `/api/sites/${s.id}/external`, {}); deps.hal._connectAgent(tk.json.agent.hal_agent_id);
deps.hal.script.failKinds.add('site.deploy'); let r = await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'external' }); eq(r.status, 200, r.text); ok(r.json.job); await deps.jobs.drain(); eq(deps.jobs.get(r.json.job.id).status, 'failed'); eq(S.get(s.id).deploy_mode, 'managed', '실패하면 현재 위치 유지'); eq(deps.store.get('SELECT status FROM deploy_mode_changes').status, 'failed');
deps.hal.script.failKinds.delete('site.deploy'); r = await call(app, 'POST', `/api/sites/${s.id}/mode`, { mode: 'external' }); await deps.jobs.drain(); const j = deps.jobs.get(r.json.job.id); eq(j.status, 'done', j.error); eq(j.steps.map((x) => x.id), ['prepare_new', 'deploy_new', 'verify_new', 'switch_domain', 'cleanup_old']); eq(S.get(s.id).deploy_mode, 'external'); ok(deps.hal.calls.find((c) => c.kind === 'site.remove' && c.target === 'srv_web1' && c.payload.keepData === true));
});
await test('설정 변경: 이름 필수·유지보수 모드는 영향 안내 + vhost 작업 · 다른 그룹은 404 · 권한 없는 구성원 403 · 생명주기 훅·사이트 수 반환', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none' })).json.site;
eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: '' })).json.detail.field, 'name');
const up = await call(app, 'PATCH', `/api/sites/${s.id}`, { name: '새 이름', maintenance: true, searchVisible: false }); eq(up.status, 200); eq(up.json.site.name, '새 이름'); eq(up.json.site.search_visible, 0);
// viewer 는 조회만
const now = new Date().toISOString(); deps.store.run('INSERT INTO users(id, email, display_name, created_at, updated_at) VALUES(?,?,?,?,?)', 'u_v', 'v@example.test', 'V', now, now); deps.store.run('INSERT INTO memberships(group_id, user_id, role, joined_at) VALUES(?,?,\'viewer\',?)', who.group.id, 'u_v', now);
const appV = await appFor(deps, ['sites'], { user: deps.store.get('SELECT * FROM users WHERE id=\'u_v\''), group: who.group, membership: deps.store.get('SELECT * FROM memberships WHERE user_id=\'u_v\'') });
eq((await call(appV, 'PATCH', `/api/sites/${s.id}`, { name: 'x' })).status, 403); eq((await call(appV, 'POST', `/api/sites/${s.id}/deploy`, {})).status, 403); eq((await call(appV, 'POST', '/api/sites', { name: 'n' })).status, 403);
// 사이트별 권한으로 배포 허용
deps.store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, updated_at) VALUES(?,?,?,?)', s.id, 'u_v', JSON.stringify(['view', 'deploy']), now); eq((await call(appV, 'GET', `/api/sites/${s.id}/precheck`)).status, 200);
S.setLifecycle(s.id, 'paused', { actor: who.user }); eq(S.get(s.id).lifecycle, 'paused'); eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: 'y' })).status, 200); S.setLifecycle(s.id, 'cancel_scheduled'); eq((await call(app, 'PATCH', `/api/sites/${s.id}`, { name: 'z' })).json.error, 'site_not_modifiable');
ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'site.lifecycle.paused\'').immutable);
await rejects(() => Promise.resolve().then(() => S.setLifecycle(s.id, 'bogus')), 'bad_lifecycle');
});
await test('Webmaker 프로젝트 삭제 영향(서명 호출): 운영 중이면 차단, 초안이면 연결 해제 안내 · 서명 없으면 401', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'P', projectMode: 'new', deployMode: 'managed' })).json.site; const pid = s.webmaker_project_id;
const secret = deps.webmaker.secret(); deps.vault.set('service:webmaker', secret);
const signed = (body) => call(app, 'POST', '/internal/webmaker/project-impact', body, Object.fromEntries(Object.entries(signHeaders('webmaker', secret, 'POST', '/internal/webmaker/project-impact', JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v])));
eq((await call(app, 'POST', '/internal/webmaker/project-impact', { projectId: pid })).status, 401);
let r = await signed({ projectId: pid, siteId: s.id }); eq(r.status, 200, r.text); eq(r.json.impact.blocked, false); eq(r.json.impact.lifecycle, 'draft');
deps.webmaker._addVersion(pid); await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain();
r = await signed({ projectId: pid }); eq(r.json.impact.blocked, true); eq(r.json.impact.deployments, 1); ok(/운영/.test(r.json.impact.message));
eq(S.impactForProject('nope').blocked, false);
});
await test('Source 옵션: 권리 안에서 활성화 → 저장소 정보·배포 revision 대응 → 해제/만료는 30일 read-only → 삭제 완료(복구 불가) · export', async () => {
const { deps, who, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'G', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id, 'create', '생성');
let r = await call(app, 'GET', `/api/source/${s.id}`); eq(r.json.option, null); eq(r.json.entitlement.purchased, 0);
eq((await call(app, 'POST', `/api/source/${s.id}/activate`)).status, 409, '권리 없음');
quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'source', amount: 1 }); eq((await call(app, 'POST', `/api/source/${s.id}/activate`)).json.option.status, 'active');
await call(app, 'POST', `/api/sites/${s.id}/deploy`, { versionRef: '1' }); await deps.jobs.drain();
r = (await call(app, 'GET', `/api/source/${s.id}`)).json; eq(r.source.provider, 'gitea'); eq(r.source.changes.length, 1); eq(r.lastDeployedRevision, 'c1'); eq(r.deployedCommits.c1, '저장본 #1 생성 · 1'.slice(0, 0) || r.deployedCommits.c1); ok(r.cloneInfo.includes('git clone'));
const ex = await call(app, 'GET', `/api/source/${s.id}/export`); eq(ex.status, 200);
// 두 번째 사이트는 권리 부족
const s2 = (await call(app, 'POST', '/api/sites', { name: 'H', projectMode: 'new' })).json.site; eq((await call(app, 'POST', `/api/source/${s2.id}/activate`)).json.error, 'quota_insufficient');
// 해제 → read-only 30일 → sweep 로 삭제
const un = await call(app, 'POST', `/api/source/${s.id}/unlink`); eq(un.json.option.status, 'readonly'); ok(un.json.option.delete_after); eq(quota.figures(deps.store, who.group.id, 'source').allocated, 0); eq(deps.webmaker._projects.get(s.webmaker_project_id).link, null);
eq((await call(app, 'POST', `/api/source/${s2.id}/activate`)).status, 200, '반환된 권리로 다른 사이트 활성화');
eq(deps.source.sweep(Date.now() + 31 * 86400000).deleted, 1); eq(deps.source.get(s.id).status, 'deleted'); eq((await call(app, 'GET', `/api/source/${s.id}/export`)).status, 409);
ok(deps.store.all('SELECT title FROM notifications').some((n) => /삭제 완료/.test(n.title)));
});
await test('도메인: 대표 지정은 DNS·HTTPS 정상일 때만 · 인증서 실패는 정상 HTTPS 로 표시하지 않음 · 해제하면 도메인 설정 필요 · 만료 임박 갱신', async () => {
const { deps, app, S, D } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'D', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id); await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); await deps.jobs.drain();
const dm = (await call(app, 'POST', '/api/domains', { name: 'bad-cert.test.example', purpose: 'web', siteId: s.id, startCheck: false })).json.domain;
eq((await call(app, 'POST', `/api/domains/${dm.id}/primary`, {})).json.error, 'domain_not_ready');
deps.hal.script.dns.txt[`_ariweb-verify.bad-cert.test.example`] = `ariweb-verify=${dm.ownershipToken}`; deps.hal.script.dns.a = ['203.0.113.10']; deps.hal.script.results['domain.cert'] = { issued: [], failed: [{ domain: 'bad-cert.test.example', reason: 'DNS-01 challenge failed' }] };
const ck = await call(app, 'POST', `/api/domains/${dm.id}/check`, {}); await deps.jobs.drain(); const j = deps.jobs.get(ck.json.job.id); eq(j.status, 'failed'); eq(D.get(dm.id).cert_state, 'failed'); ok(/challenge/.test(D.get(dm.id).cert_error));
eq((await call(app, 'POST', `/api/domains/${dm.id}/primary`, {})).json.error, 'domain_not_ready');
delete deps.hal.script.results['domain.cert']; await call(app, 'POST', `/api/domains/${dm.id}/jobs/${j.id}/retry`, {}); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done', deps.jobs.get(j.id).error); eq(D.get(dm.id).cert_state, 'valid');
// 외부 확인에서 TLS 무효 → failed
deps.hal.script.results['site.check'] = { checks: [{ url: 'https://bad-cert.test.example/', status: 200, ok: true, ms: 10, tls: { valid: false } }] }; const ck2 = await call(app, 'POST', `/api/domains/${dm.id}/check`, {}); await deps.jobs.drain(); eq(deps.jobs.get(ck2.json.job.id).status, 'failed'); eq(D.get(dm.id).cert_state, 'failed'); delete deps.hal.script.results['site.check'];
const ck3 = await call(app, 'POST', `/api/domains/${dm.id}/check`, { makePrimary: true }); await deps.jobs.drain(); eq(deps.jobs.get(ck3.json.job.id).status, 'done'); eq(S.get(s.id).primary_domain, 'bad-cert.test.example'); eq(S.get(s.id).lifecycle, 'live');
const lst = (await call(app, 'GET', `/api/domains?site=${s.id}`)).json.domains; eq(lst[0].isPrimary, true); eq(lst[0].certState, 'valid');
// 만료 임박 → expiring_soon + 갱신 작업
deps.store.run('UPDATE domains SET cert_expires_at=? WHERE id=?', new Date(Date.now() + 5 * 86400000).toISOString(), dm.id); eq(D.sweep().renew, 1); eq(D.get(dm.id).cert_state, 'expiring_soon');
// 해제 → 사이트는 도메인 설정 필요
const rel = await call(app, 'DELETE', `/api/domains/${dm.id}`); eq(rel.status, 409, '확인 작업 진행 중'); await deps.jobs.drain(); eq((await call(app, 'DELETE', `/api/domains/${dm.id}`)).json.wasPrimary, true); eq(S.get(s.id).lifecycle, 'needs_domain'); eq(S.get(s.id).primary_domain, null);
eq((await call(app, 'POST', '/api/domains', { name: 'not a domain' })).json.detail.field, 'name');
});
await test('HAL 장애: 사전 확인은 "정보 확인 지연" 으로 신규 배포를 막고 기존 상태는 유지 · 작업은 재시도 대기', async () => {
const { deps, app, S } = await setup();
const s = (await call(app, 'POST', '/api/sites', { name: 'H', projectMode: 'new', deployMode: 'managed' })).json.site; deps.webmaker._addVersion(s.webmaker_project_id);
deps.hal.script.unreachable = true; const pc = (await call(app, 'GET', `/api/sites/${s.id}/precheck`)).json.precheck; ok(!pc.ok); ok(/정보 확인 지연/.test(pc.items.find((i) => i.id === 'target').text));
deps.hal.script.unreachable = false; const d = await call(app, 'POST', `/api/sites/${s.id}/deploy`, {}); deps.hal.script.unreachable = true; await deps.jobs.drain(); const j = deps.jobs.get(d.json.job.id); eq(j.status, 'queued'); ok(j.attempt >= 1); ok(Date.parse(j.next_run_at) > Date.now());
deps.hal.script.unreachable = false; deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', new Date(0).toISOString(), j.id); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done');
const mi = (await call(app, 'GET', `/api/sites/${s.id}/mode`)).json; eq(mi.managed.halStale, false);
});
done();