[verified] fix: show HAL risk warning only on action
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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 의 전역 배너 영역(<div class="alerts">…</div>)만 떼어 낸다 */
|
||||
const alerts = (html) => (html.match(/<div class="alerts">([\s\S]*?)<\/div><div id="body">/) || ['', ''])[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();
|
||||
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다', () => {
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.1(HAL 재인증 안내 위치 수정, 도입 모듈 변경 없음) 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.1');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
|
||||
Reference in New Issue
Block a user