164 lines
24 KiB
JavaScript
164 lines
24 KiB
JavaScript
/**
|
|||
|
|
* test/hal-040.test.mjs — Ariweb Suite 0.4.0(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`, Oz 2026-10-29): 통합 설정 관제.
|
||
|
|
*
|
||
|
|
* 계약(요약)
|
||
|
|
* 1) 설정 목록(catalog): 모듈별(webmaker·arimail·agent) 관리 항목 묶음. Webmaker 는 API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별 설정. AriMail 은 서버 단위 항목(도메인별 LIMIT_* 제외). Agent 는 heartbeatMs.
|
||
|
|
* 2) 저장소: scope global | server:<id>, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만. 유효값 = 전체 ← 서버 덮어쓰기. 형식·범위·알 수 없는 항목은 bad_setting.
|
||
|
|
* 3) 운영자 API: 조회(비밀값은 있음/없음만)·저장(재인증·CSRF)·삭제·적용(대상 비우면 배치 서버 전체, 없으면 no_targets). 감사 기록에 값 없음.
|
||
|
|
* 4) 적용 작업 settings.apply → settings.apply.target → Agent module.configure: Agent 는 자기 서버 범위의 설정만 받고(HMAC), 결과 hash 가 다르면 실패·상태 failed.
|
||
|
|
* 5) 화면·AI 도구·버전·문서·모듈 대응(Webmaker 1.9.0 · AriMail 1.1.0, halCompat 0.4.0).
|
||
|
|
*/
|
||
|
|
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
||
|
|
import { invoke } from '../lib/http.mjs';
|
||
|
|
import { signHeaders } from '../lib/serviceauth.mjs';
|
||
|
|
import { listAudit } from '../lib/audit.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 { SETTINGS_CATALOG, catalogFor, keyDef, MANAGED_MODULES } from '../hal/settings/catalog.mjs';
|
||
|
|
import * as S from '../hal/settings/index.mjs';
|
||
|
|
import { MIGRATIONS } from '../hal/schema.mjs';
|
||
|
|
import { TOOLS, toolByName, runTool, SKILL_DOC } from '../hal/ai/tools.mjs';
|
||
|
|
import * as R from '../hal/ui/render.mjs';
|
||
|
|
import { KINDS as AGENT_KINDS } from '../agent/jobs/index.mjs';
|
||
|
|
import { mkdirSync, readFileSync, existsSync } from 'node:fs';
|
||
|
|
import { join, dirname } from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import { randomBytes } from 'node:crypto';
|
||
|
|
|
||
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
|
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
||
|
|
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: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); } };
|
||
|
|
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
|
||
|
|
return { d, app: buildApp(d), dataDir };
|
||
|
|
}
|
||
|
|
async function loginOp(h) { const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok); return { pw, cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
||
|
|
async function addServer(h, { name, role }) {
|
||
|
|
const s = core.createServer(h.d, { name, role }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: s.id });
|
||
|
|
const r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: name, nginx: true, units: { webmaker: 'webmaker' }, appDirs: { webmaker: '/srv/webmaker' } }, version: '0.3.17' } }); ok(r.json.ok, JSON.stringify(r.json));
|
||
|
|
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted: 0, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
||
|
|
return { serverId: s.id, agentId: r.json.agentId, secret: r.json.secret };
|
||
|
|
}
|
||
|
|
function bootstrapDone(d, version) { core.claimLive(d, { instanceId: d.instanceId, version }); core.setBootstrap(d, { release_version: version, main_commit: 'abc', artifact_sha256: 'x', first_agent_id: d.store.get('SELECT id FROM agents LIMIT 1')?.id || 'ag_x', first_agent_registered_at: new Date().toISOString(), completed_at: new Date().toISOString() }); }
|
||
|
|
const placeModule = (h, module, serverId, version = '0.3.17') => h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', module, serverId, version, version, 'ok', 1, new Date().toISOString(), new Date().toISOString());
|
||
|
|
const agentGet = (h, a, path) => invoke(h.app, 'GET', path, { headers: Object.fromEntries(Object.entries(signHeaders(`agent:${a.agentId}`, a.secret, 'GET', path, '')).map(([k, v]) => [k.toLowerCase(), v])) });
|
||
|
|
/** Agent 흉내: 작업을 받아 결과를 낸다 */
|
||
|
|
async function runAgents(h, agents, result, maxMs = 8000) {
|
||
|
|
const t0 = Date.now(); const ps = []; const seen = [];
|
||
|
|
while (Date.now() - t0 < maxMs) { ps.push(h.d.jobs.tick().catch(() => {})); for (const a of agents) { const j = core.nextAgentJob(h.d, a.agentId); if (j) { seen.push(j); core.agentJobResult(h.d, j.id, a.agentId, await result(j, a)); } } if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) { await sleep(30); if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) break; } await sleep(10); }
|
||
|
|
await Promise.all(ps); return seen;
|
||
|
|
}
|
||
|
|
|
||
|
|
await test('1·2) 설정 목록·저장소: 묶음·형식, 전체/서버 범위, 비밀값은 Vault 에만, 유효값 합성·hash, 형식·범위·알 수 없는 항목 거부', async () => {
|
||
|
|
eq(MANAGED_MODULES, ['webmaker', 'arimail', 'agent']);
|
||
|
|
const wm = catalogFor('webmaker'); eq(wm.groups.map((g) => g.id), ['auth', 'pricing', 'models', 'service', 'server']);
|
||
|
|
const has = (m, k) => !!keyDef(m, k); for (const k of ['auth.mode', 'auth.apiKey', 'serviceApiKey', 'marginPct', 'fx.mode', 'tiers.basic', 'internalRef', 'ai.maxConcurrentJobs', 'service.allowSignup', 'service.publicBaseUrl', 'source.token', 'mail.adminPassword', 'endpoints.resendApiBase']) ok(has('webmaker', k), 'webmaker ' + k);
|
||
|
|
eq(keyDef('webmaker', 'marginPct').type, 'number'); eq(keyDef('webmaker', 'auth.apiKey').type, 'secret'); eq(keyDef('webmaker', 'service.publicBaseUrl').scope, 'server'); ok(keyDef('webmaker', 'ai.claudeBin').restart === true, '재시작 필요 표시');
|
||
|
|
const am = catalogFor('arimail'); for (const k of ['inbound', 'outbound', 'RESEND_API_KEY', 'DNS_PROVIDER', 'SMTP_RELAY_HOST', 'HARAKA_HOSTNAME', 'MAIL_FROM_NAME', 'USAGE_API_ALLOW', 'ARIWEB_SSO_SECRET']) ok(has('arimail', k), 'arimail ' + k);
|
||
|
|
ok(!am.groups.some((g) => g.keys.some((k) => /^LIMIT_/.test(k.key))), '도메인별 LIMIT_* 는 HAL 이 다루지 않는다'); eq(keyDef('arimail', 'RESEND_API_KEY').type, 'secret'); eq(keyDef('arimail', 'inbound').options.includes('haraka'), true);
|
||
|
|
ok(has('agent', 'heartbeatMs') && keyDef('agent', 'heartbeatMs').type === 'int');
|
||
|
|
ok(MIGRATIONS.some((m) => m.id === 'hal_0010_managed_settings' && /managed_settings_state/.test(m.sql)));
|
||
|
|
const h = makeHal(); const sec = rnd('sk'); const srv = core.createServer(h.d, { name: '웹1', role: 'web' });
|
||
|
|
const r1 = S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: '35', 'auth.mode': 'api-key', 'auth.apiKey': sec, 'service.allowSignup': 'false' }, by: 'op' });
|
||
|
|
eq(r1.changed.sort(), ['auth.apiKey', 'auth.mode', 'marginPct', 'service.allowSignup']);
|
||
|
|
const rows = h.d.store.all('SELECT * FROM managed_settings'); ok(!JSON.stringify(rows).includes(sec), '행에 비밀값 없음'); eq(h.d.vault.get('managed:global:webmaker:auth.apiKey'), sec);
|
||
|
|
const g = S.getSettings(h.d, 'webmaker', 'global'); eq(g.values.marginPct, 35); eq(g.values['service.allowSignup'], false); eq(g.values['auth.mode'], 'api-key'); eq(g.secrets['auth.apiKey'], true); ok(!('auth.apiKey' in g.values) && !JSON.stringify(g).includes(sec));
|
||
|
|
S.setSettings(h.d, { module: 'webmaker', scope: `server:${srv.id}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm.example.test' }, by: 'op' });
|
||
|
|
const e = S.effectiveSettings(h.d, 'webmaker', srv.id); eq(e.values.marginPct, 40); eq(e.sources.marginPct, 'server'); eq(e.values['auth.mode'], 'api-key'); eq(e.sources['auth.mode'], 'global'); eq(e.values['service.publicBaseUrl'], 'https://wm.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(e.hash)); ok(!JSON.stringify(e).includes(sec));
|
||
|
|
const es = S.effectiveSettings(h.d, 'webmaker', srv.id, { includeSecrets: true }); eq(es.values['auth.apiKey'], sec); eq(es.hash, e.hash, '비밀값 포함 여부와 무관하게 같은 hash(hash 는 비밀값을 포함해 계산)');
|
||
|
|
const other = core.createServer(h.d, { name: '웹2', role: 'web' }); const e2 = S.effectiveSettings(h.d, 'webmaker', other.id); eq(e2.values.marginPct, 35); ok(e2.hash !== e.hash);
|
||
|
|
for (const [vals, why] of [[{ marginPct: 'abc' }, '숫자 아님'], [{ marginPct: 5000 }, '범위 초과'], [{ nope: 1 }, '알 수 없는 항목'], [{ 'auth.mode': 'magic' }, 'enum 밖'], [{ 'ai.maxConcurrentJobs': 1.5 }, '정수 아님']]) { let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: vals, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', why); }
|
||
|
|
let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'service.publicBaseUrl': 'https://x' }, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', '서버별 항목은 전체 범위에 둘 수 없다');
|
||
|
|
code = null; try { S.setSettings(h.d, { module: 'nope', scope: 'global', values: {}, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'unknown_module');
|
||
|
|
S.deleteSetting(h.d, { module: 'webmaker', scope: 'global', key: 'auth.apiKey', by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false); eq(S.getSettings(h.d, 'webmaker', 'global').secrets['auth.apiKey'], false);
|
||
|
|
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': '' }, by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false, '빈 값은 비밀값 삭제');
|
||
|
|
S.setSettings(h.d, { module: 'arimail', scope: 'global', values: { inbound: 'haraka', SMTP_RELAY_PORT: 2525, MAIL_FROM_NAME: '아리메일' }, by: 'op' }); eq(S.getSettings(h.d, 'arimail', 'global').values.SMTP_RELAY_PORT, 2525);
|
||
|
|
S.setSettings(h.d, { module: 'agent', scope: 'global', values: { heartbeatMs: 45000 }, by: 'op' }); eq(S.effectiveSettings(h.d, 'agent', srv.id).values.heartbeatMs, 45000);
|
||
|
|
});
|
||
|
|
|
||
|
|
await test('3) 운영자 API: 목록·조회(비밀값 없음)·저장(CSRF·재인증)·삭제·적용 대상, 감사 기록에 값 없음', async () => {
|
||
|
|
const h = makeHal(); const op = await loginOp(h); const sec = rnd('sk');
|
||
|
|
let r = await op.as('GET', '/ops/api/settings'); eq(r.status, 200); eq(r.json.modules, ['webmaker', 'arimail', 'agent']); ok(r.json.catalog.webmaker.groups.length === 5 && r.json.catalog.arimail.groups.length >= 3, JSON.stringify(Object.keys(r.json.catalog)));
|
||
|
|
r = await invoke(h.app, 'PUT', '/ops/api/settings/webmaker', { body: { scope: 'global', values: { marginPct: 35 } }, cookies: { aw_ops: op.cookie } }); eq(r.status, 403); eq(r.json.error, 'csrf');
|
||
|
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec, 'tiers.premium': 'anthropic:claude-opus-5-5' } }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.changed.sort(), ['auth.apiKey', 'marginPct', 'tiers.premium']); ok(!r.text.includes(sec));
|
||
|
|
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35); eq(r.json.secrets['auth.apiKey'], true); ok(!r.text.includes(sec), '조회 응답에 비밀값 없음'); ok(r.json.catalog && r.json.catalog.groups, '응답에 목록');
|
||
|
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 'abc' } }); eq(r.status, 400); eq(r.json.error, 'bad_setting');
|
||
|
|
r = await op.as('PUT', '/ops/api/settings/nope', { scope: 'global', values: {} }); eq(r.status, 404); eq(r.json.error, 'unknown_module');
|
||
|
|
// 재인증 만료 → 저장·적용 거부(reauth_required), 조회는 허용
|
||
|
|
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||
|
|
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 36 } }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||
|
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||
|
|
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35);
|
||
|
|
r = await op.as('POST', '/ops/api/reauth', { password: op.pw }); eq(r.status, 200, JSON.stringify(r.json));
|
||
|
|
r = await op.as('DELETE', '/ops/api/settings/webmaker/auth.apiKey?scope=global'); eq(r.status, 200); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false);
|
||
|
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets', '배치된 서버가 없으면 즉시 no_targets');
|
||
|
|
const au = listAudit(h.d.store, { action: 'settings.set' }); ok(au.length >= 1 && !JSON.stringify(au).includes(sec), '감사 기록에 비밀값 없음'); ok(JSON.stringify(au).includes('auth.apiKey'), '키 이름은 남긴다');
|
||
|
|
});
|
||
|
|
|
||
|
|
await test('4) 적용 작업: settings.apply → 서버별 → Agent module.configure(자기 범위만, HMAC) → 상태 ok / hash 불일치 failed / 변경 뒤 upToDate=false', async () => {
|
||
|
|
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const web2 = await addServer(h, { name: '웹2', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.4.0');
|
||
|
|
placeModule(h, 'webmaker', web.serverId); placeModule(h, 'webmaker', web2.serverId); placeModule(h, 'arimail', mail.serverId);
|
||
|
|
const sec = rnd('sk'); await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec } }); await op.as('PUT', '/ops/api/settings/webmaker', { scope: `server:${web.serverId}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm1.example.test' } });
|
||
|
|
// Agent 설정 API: 자기 서버 범위, 비밀값 포함, 다른 서버의 덮어쓰기는 보이지 않음, 서명 없으면 거부
|
||
|
|
let r = await agentGet(h, web, '/agent/v1/settings/webmaker'); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.settings.module, 'webmaker'); eq(r.json.settings.serverId, web.serverId); eq(r.json.settings.values.marginPct, 40); eq(r.json.settings.values['auth.apiKey'], sec); eq(r.json.settings.values['service.publicBaseUrl'], 'https://wm1.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(r.json.settings.hash));
|
||
|
|
const hash1 = r.json.settings.hash;
|
||
|
|
r = await agentGet(h, web2, '/agent/v1/settings/webmaker'); eq(r.json.settings.values.marginPct, 35); ok(!('service.publicBaseUrl' in r.json.settings.values)); ok(r.json.settings.hash !== hash1);
|
||
|
|
r = await invoke(h.app, 'GET', '/agent/v1/settings/webmaker'); eq(r.status, 401);
|
||
|
|
r = await agentGet(h, web, '/agent/v1/settings/nope'); eq(r.status, 404);
|
||
|
|
// 적용(대상 비움 → webmaker 가 배치된 서버 전체)
|
||
|
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [], restart: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, web2.serverId].sort()); eq(r.json.job.kind, 'settings.apply'); const jobId = r.json.job.id;
|
||
|
|
const seen = await runAgents(h, [web, web2, mail], async (j, a) => { eq(j.kind, 'module.configure'); eq(j.payload.module, 'webmaker'); eq(j.payload.settingsUrl, '/agent/v1/settings/webmaker'); eq(j.payload.unit, 'webmaker'); eq(j.payload.configDir, '/data/webmaker'); eq(j.payload.restart, true); ok(j.payload.hash && !JSON.stringify(j.payload).includes(sec), '작업 payload 에 비밀값 없음(Agent 가 직접 받는다)');
|
||
|
|
const s = await agentGet(h, a, j.payload.settingsUrl); return { status: 'done', result: { module: 'webmaker', hash: s.json.settings.hash, file: '/data/webmaker/hal-managed.json', restarted: true, health: { ok: true } } }; });
|
||
|
|
eq(seen.length, 2); const job = h.d.jobs.get(jobId); eq(job.status, 'done', JSON.stringify(h.d.jobs.list({ kind: 'settings.apply.target', limit: 5 }).map((c) => [c.status, c.error, c.error_code])));
|
||
|
|
const st = h.d.store.all('SELECT server_id, status, applied_hash FROM managed_settings_state WHERE module=? ORDER BY server_id', 'webmaker'); eq(st.length, 2); ok(st.every((x) => x.status === 'ok')); eq(st.find((x) => x.server_id === web.serverId).applied_hash, hash1);
|
||
|
|
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.status, 'ok'); eq(r.json.state.upToDate, true); eq(r.json.effective.values.marginPct, 40); ok(!r.text.includes(sec));
|
||
|
|
// 설정이 바뀌면 적용 상태는 최신이 아니다 → 운영 현황·목록에 보인다
|
||
|
|
await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { 'fx.spreadPct': 2 } }); // 웹1 은 marginPct 를 서버에서 덮어쓰므로 다른 전체 항목을 바꾼다
|
||
|
|
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.upToDate, false);
|
||
|
|
r = await op.as('GET', '/ops/api/settings'); ok(r.json.overview.webmaker && r.json.overview.webmaker.servers.length === 2 && r.json.overview.webmaker.stale === 2, JSON.stringify(r.json.overview.webmaker));
|
||
|
|
r = await op.as('GET', '/ops/api/overview'); eq(r.json.settings.stale, 2);
|
||
|
|
// hash 불일치 → 대상 작업 실패, 상태 failed, 부모 partial/실패
|
||
|
|
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [web.serverId], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); const j2 = r.json.job.id;
|
||
|
|
await runAgents(h, [web], async () => ({ status: 'done', result: { module: 'webmaker', hash: 'deadbeef', file: 'x', restarted: false, health: { ok: true } } }));
|
||
|
|
const job2 = h.d.jobs.get(j2); ok(job2.status === 'failed', job2.status); eq(h.d.store.get('SELECT status FROM managed_settings_state WHERE module=? AND server_id=?', 'webmaker', web.serverId).status, 'failed');
|
||
|
|
const child = h.d.jobs.list({ kind: 'settings.apply.target', limit: 10 }).find((x) => x.parent_id === j2); eq(child.error_code || child.errorCode, 'settings_hash_mismatch');
|
||
|
|
// 대상 지정: arimail 을 웹 서버에 적용하려 하면 거부
|
||
|
|
r = await op.as('POST', '/ops/api/settings/arimail/apply', { serverIds: [web.serverId], nonce: '3' }); eq(r.status, 400); eq(r.json.error, 'not_placed');
|
||
|
|
});
|
||
|
|
|
||
|
|
await test('5) 화면·AI 도구·정적 계약', async () => {
|
||
|
|
const h = makeHal(); const op = await loginOp(h);
|
||
|
|
let r = await op.as('GET', '/ops/settings'); eq(r.status, 200); ok(r.text.includes('통합 설정 관제') && r.text.includes('data-page="settings"') && r.text.includes('href="/ops/settings"'), '화면·메뉴');
|
||
|
|
const sec = rnd('sk'); const html = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'global', values: { marginPct: 35, 'auth.mode': 'api-key' }, secrets: { 'auth.apiKey': true, 'auth.oauthToken': false } });
|
||
|
|
ok(html.includes('name="marginPct"') && html.includes('value="35"'), '값'); ok(/name="auth\.apiKey"[^>]*type="password"|type="password"[^>]*name="auth\.apiKey"/.test(html), '비밀값은 비밀번호 입력'); ok(html.includes('설정됨') && html.includes('없음'), '비밀값 있음/없음'); ok(!html.includes(sec));
|
||
|
|
ok(!html.includes('name="service.publicBaseUrl"'), '전체 범위에는 서버별 항목이 없다'); ok(html.includes('selected') && html.includes('api-key'), 'enum 선택'); ok(html.includes('data-act="settings-save"'));
|
||
|
|
const html2 = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'server:srv_1', values: {}, secrets: {}, effective: { values: { marginPct: 35 }, sources: { marginPct: 'global' } } }); ok(html2.includes('name="service.publicBaseUrl"') && html2.includes('전체 값 35'), '서버 범위: 서버별 항목·전체 값 안내');
|
||
|
|
const stateHtml = R.settingsStateHtml([{ serverId: 'srv_1', serverName: '웹1', status: 'ok', upToDate: false, appliedAt: '2026-10-29T00:00:00Z', appliedHash: 'abc', error: null }]); ok(stateHtml.includes('웹1') && stateHtml.includes('적용 필요') && stateHtml.includes('data-act="settings-apply"'));
|
||
|
|
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); for (const s of ['settings:', "'settings-save'", "'settings-apply'", '/ops/api/settings']) ok(js.includes(s), 'ops.js ' + s);
|
||
|
|
// AI 도구
|
||
|
|
const names = TOOLS.map((t) => t.name); for (const n of ['hal_settings_get', 'hal_settings_set', 'hal_settings_apply']) ok(names.includes(n), n);
|
||
|
|
eq(toolByName('hal_settings_get').risk, 'read'); eq(toolByName('hal_settings_set').risk, 'high'); eq(toolByName('hal_settings_apply').risk, 'high');
|
||
|
|
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec }, by: 'op' });
|
||
|
|
const g = await runTool(h.d, toolByName('hal_settings_get'), { module: 'webmaker' }, { operatorId: 'op' }); eq(g.values.marginPct, 35); ok(g.confidential.some((x) => x.name === 'auth.apiKey' && x.set === true), JSON.stringify(g.confidential)); ok(!JSON.stringify(g).includes(sec));
|
||
|
|
let code = null; try { await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': 'x' } }, { operatorId: 'op' }); } catch (x) { code = x.code; } eq(code, 'secret_not_allowed', 'AI 도구는 비밀값을 받지 않는다');
|
||
|
|
const s2 = await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { marginPct: 31 } }, { operatorId: 'op' }); eq(s2.changed, ['marginPct']); eq(S.getSettings(h.d, 'webmaker', 'global').values.marginPct, 31);
|
||
|
|
ok(SKILL_DOC.includes('hal_settings_get') && SKILL_DOC.includes('hal_settings_apply'));
|
||
|
|
ok(AGENT_KINDS['module.configure'], 'Agent 작업 종류');
|
||
|
|
});
|
||
|
|
|
||
|
|
await test('버전·문서·모듈 대응(0.4.0 · Webmaker 1.9.0 · AriMail 1.1.0)', async () => {
|
||
|
|
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
|
||
|
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.0.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.0')); ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.0'));
|
||
|
|
ok(existsSync(join(ROOT, 'docs', 'HAL-SETTINGS.md')));
|
||
|
|
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite && wm.ariwebSuite.halCompat, '0.4.0');
|
||
|
|
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite && am.ariwebSuite.halCompat, '0.4.0');
|
||
|
|
ok(existsSync(join(ROOT, 'modules', 'webmaker', 'lib', 'hal-managed.js')) && existsSync(join(ROOT, 'modules', 'arimail', 'server', 'hal-managed.mjs')));
|
||
|
|
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'server.js'), 'utf8').includes('managed_by_hal')); ok(readFileSync(join(ROOT, 'modules', 'arimail', 'server', 'setup.mjs'), 'utf8').includes('managed_by_hal'));
|
||
|
|
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'README.md'), 'utf8').includes('HAL 0.4.0 대응'), 'Webmaker README(모듈 CHANGELOG 는 도입 제외 — 원본 저장소에 있음)'); ok(readFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), 'utf8').includes('1.1.0'));
|
||
|
|
const imp = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')); eq(imp.sources.find((s) => s.name === 'webmaker').upstream.version, '1.9.0'); eq(imp.sources.find((s) => s.name === 'arimail').upstream.version, '1.1.0');
|
||
|
|
});
|
||
|
|
done();
|