/** * test/hal-reauth-banner.test.mjs — Ariweb Suite 0.2.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`): 운영자 콘솔 고위험 재인증 안내 위치 회귀 테스트. * * 계약 * 1) 일반 화면(운영 현황·서버·계정 …)에는 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 **없다** — 로그인 직후에도, 10분이 지난 뒤에도, 화면을 옮겨 다녀도. * 2) 그 안내는 backend 가 고위험 요청을 `reauth_required` 로 거부한 바로 그 때 여는 재인증 창(modal) 안에만 보인다. 계정 화면의 수동 "재인증" 단추는 그대로 두되 고위험 안내를 붙이지 않는다. * 3) backend 의 requireRecent·CSRF·3단계 확인은 그대로다: 10분이 지나면 고위험 API 는 403 reauth_required, 재인증 뒤에만 통과. * 4) 재인증 창을 취소하면 원래 고위험 요청은 실행되지 않고, 재인증에 성공하면 원 요청을 **딱 한 번** 재시도한다(두 번째도 거부되면 다시 묻지 않고 오류를 그대로 올린다). * * 브라우저 스크립트(hal/ui/public/ops.js)는 DOM 이 있어야 돌므로, 재인증 흐름·창 문구는 순수 helper(hal/ui/public/reauth.mjs)로 분리해 Node 에서 검사하고, * ops.js 가 실제로 그 helper 를 쓰는지는 정적 계약(소스 텍스트)으로 못 박는다. */ import { test, eq, ok, tmpDir, done } from '../lib/test.mjs'; import { invoke } from '../lib/http.mjs'; import { createHalDeps } from '../hal/deps.mjs'; import { buildApp } from '../hal/app.mjs'; import * as core from '../hal/core.mjs'; import * as ops from '../hal/ops.mjs'; import { mkdirSync, readFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); /** 실행마다 다른 운영자 비밀번호 fixture(저장소에 정적 비밀번호 literal 을 두지 않는다). correct 와 wrong 은 서로 다름이 보장된다 */ const makeFixturePasswords = () => { const correct = 'fx-' + randomBytes(12).toString('hex'); let wrong; do { wrong = 'fx-' + randomBytes(12).toString('hex'); } while (wrong === correct); return { correct, wrong }; }; const BANNER = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다'; function makeHal() { const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true }); const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } }; const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }); return { d, app: buildApp(d) }; } async function loginOp(h) { const pw = makeFixturePasswords(); ops.createAccount(h.d, { login: 'ozops', password: pw.correct, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw.correct } }); ok(r.json.ok, JSON.stringify(r.json)); return { pw, as: (method, path, body) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; } /** 화면 HTML 의 전역 배너 영역(
…
)만 떼어 낸다 */ const alerts = (html) => (html.match(/
([\s\S]*?)<\/div>
/) || ['', ''])[1]; await test('일반 화면에는 고위험 재인증 전역 배너가 없다 — 로그인 직후·10분 경과·화면 이동 모두', async () => { const h = makeHal(); const op = await loginOp(h); // 로그인 직후(재인증 유효): 배너 없음 let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes(BANNER), '로그인 직후 운영 현황에 고위험 배너 없음'); ok(!alerts(r.text).includes('data-act="reauth"'), '배너 영역에 재인증 링크 없음'); // 10분 경과(재인증 만료): 그래도 일반 화면에는 배너 없음 h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString()); for (const path of ['/ops/', '/ops/servers', '/ops/releases', '/ops/jobs', '/ops/recovery', '/ops/customers']) { r = await op.as('GET', path); eq(r.status, 200, path); ok(!r.text.includes(BANNER), `${path}: 10분 경과 뒤에도 고위험 배너 없음`); ok(!alerts(r.text).includes('data-act="reauth"'), `${path}: 배너 영역에 재인증 링크 없음`); ok(!r.text.includes('고위험 작업('), `${path}: 단순 조회 화면에 고위험 안내 문구 없음`); } // 계정 화면: 수동 재인증 단추는 유지, 고위험 안내는 붙이지 않는다 r = await op.as('GET', '/ops/account'); eq(r.status, 200); ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지'); ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음'); // 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님 ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지'); }); await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => { const h = makeHal(); const op = await loginOp(h); const s = core.createServer(h.d, { name: '웹1', role: 'web' }); h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString()); let r = await op.as('POST', `/ops/api/servers/${s.id}/delete`, { confirmImpact: true, confirmIdentifiers: [s.id], confirmExecute: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required'); eq(r.json.detail && r.json.detail.action, 'reauth'); ok(core.getServer(h.d, s.id), '거부된 고위험 요청은 실행되지 않음(서버 그대로)'); r = await op.as('PATCH', `/ops/api/servers/${s.id}`, { status: 'isolated' }); eq(r.json.error, 'reauth_required'); // 재인증 자체도 CSRF 가 필요하다 r = await invoke(h.app, 'POST', '/ops/api/reauth', { body: { password: op.pw.correct }, cookies: { aw_ops: (await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: op.pw.correct } })).cookies.aw_ops } }); eq(r.status, 403); eq(r.json.error, 'csrf'); r = await op.as('POST', '/ops/api/reauth', { password: op.pw.wrong }); ok(r.status >= 400, '틀린 비밀번호 재인증 거부'); r = await op.as('POST', '/ops/api/reauth', { password: op.pw.correct }); eq(r.status, 200); r = await op.as('GET', '/ops/api/me'); ok(r.json.recentlyAuthed, '재인증 뒤 recentlyAuthed'); // 3단계 확인은 재인증과 별개로 여전히 요구된다 r = await op.as('POST', `/ops/api/servers/${s.id}/delete`, {}); ok(r.status >= 400 && r.json.error !== 'reauth_required', `3단계 확인 없이는 거부(${r.json.error})`); ok(core.getServer(h.d, s.id), '서버 그대로'); }); await test('재인증 helper(순수): 고위험 안내는 reauth_required 로 거부된 때의 재인증 창에만, 수동 재인증 창에는 없음', async () => { const H = await import('../hal/ui/public/reauth.mjs'); eq(H.REAUTH_REQUIRED, 'reauth_required'); eq(H.HIGH_RISK_NOTICE, BANNER, '안내 문구는 옛 전역 배너 문구와 같다(위치만 옮김)'); const manual = H.reauthPrompt(); ok(manual && manual.title && manual.okLabel, '수동 재인증 창 제목·단추'); eq(manual.note, '', '수동 재인증 창에는 고위험 안내 없음'); const forced = H.reauthPrompt({ required: true }); eq(forced.note, BANNER, '거부된 때의 재인증 창에는 고위험 안내'); eq(forced.title, manual.title); }); await test('재인증 helper(순수): 취소하면 원 요청 미실행·오류 전파, 성공하면 딱 한 번 재시도, 다른 오류는 재인증을 묻지 않음', async () => { const H = await import('../hal/ui/public/reauth.mjs'); const denied = () => Object.assign(new Error('고위험 작업은 최근 10분 안의 재인증이 필요합니다'), { code: 'reauth_required', status: 403 }); // 1) 취소 → 원 요청은 1번(거부된 호출)만, 재인증 창은 required 로 1번, 오류는 그대로 전파 let runs = 0; let prompts = []; let err = null; try { await H.callWithReauth(() => { runs++; throw denied(); }, async (o) => { prompts.push(o); return false; }); } catch (e) { err = e; } eq(runs, 1, '취소하면 원 요청을 다시 보내지 않음'); eq(prompts.length, 1); eq(prompts[0].required, true); eq(err && err.code, 'reauth_required', '원래 오류가 호출자에게 전파'); // 2) 성공 → 원 요청 2번(거부 + 재시도 1번), 결과는 재시도 값 runs = 0; prompts = []; const out = await H.callWithReauth(() => { runs++; if (runs === 1) throw denied(); return { ok: true, n: runs }; }, async (o) => { prompts.push(o); return true; }); eq(runs, 2, '재인증 성공 시 딱 한 번 재시도'); eq(out.n, 2); eq(prompts.length, 1); // 3) 재시도도 거부 → 다시 묻지 않고 오류 전파(무한 반복 금지) runs = 0; prompts = []; err = null; try { await H.callWithReauth(() => { runs++; throw denied(); }, async (o) => { prompts.push(o); return true; }); } catch (e) { err = e; } eq(runs, 2); eq(prompts.length, 1, '재시도가 또 거부돼도 재인증 창을 다시 열지 않음'); eq(err && err.code, 'reauth_required'); // 4) 다른 오류(권한 없음·검증 실패)는 재인증을 묻지 않는다 runs = 0; prompts = []; err = null; try { await H.callWithReauth(() => { runs++; throw Object.assign(new Error('x'), { code: 'bad_request' }); }, async (o) => { prompts.push(o); return true; }); } catch (e) { err = e; } eq(runs, 1); eq(prompts.length, 0); eq(err && err.code, 'bad_request'); // 5) 정상 응답은 그대로 eq((await H.callWithReauth(async () => 7, async () => { throw new Error('부르면 안 됨'); })), 7); }); await test('정적 계약: ui.mjs 에 전역 고위험 배너가 없고, ops.js 는 reauth helper 로 거부 시점 재인증·수동 재인증(안내 없음)을 처리한다', () => { const ui = readFileSync(join(ROOT, 'hal', 'ui.mjs'), 'utf8'); const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); ok(!ui.includes('고위험 작업('), 'hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음'); ok(!/if \(!recent\) banner\.push/.test(ui), '재인증 만료를 전역 배너로 바꾸는 코드가 없음'); ok(js.includes("from './reauth.mjs'"), 'ops.js 가 순수 helper 를 가져온다'); ok(js.includes('callWithReauth('), 'API 호출 재시도 흐름은 helper 가 담당'); ok(js.includes('reauthPrompt('), '재인증 창 문구는 helper 가 결정'); ok(/\breauth: \(\) => reauth\(\)/.test(js), '계정 화면 수동 재인증은 required 없이 호출'); ok(!js.includes('고위험 작업('), 'ops.js 는 안내 문구를 직접 품지 않는다(helper 단일 출처)'); }); done();