포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0f11424224
commit
c90ac27596
@@ -0,0 +1,100 @@
|
||||
/** test/auth.test.mjs — 가입·확인·로그인·잠금·세션·재설정·MFA·CSRF 회귀 테스트(실제 쿠키 흐름). */
|
||||
import { test, eq, ok, done } from '../lib/test.mjs';
|
||||
import { makeDeps, appFor, call } from './helpers.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import * as totp from '../portal/auth/totp.mjs';
|
||||
|
||||
const mails = []; const mailer = { send: async (m) => { mails.push(m); return { ok: true, mode: 'test' }; }, mode: () => 'test' };
|
||||
const deps = await makeDeps({ mailer });
|
||||
const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui', 'help']);
|
||||
const AREAS_OK = true;
|
||||
/** 로그인 상태의 호출 도우미: 세션 쿠키 + CSRF */
|
||||
function client(cookie, csrf) { return (method, path, body) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': csrf || '', accept: 'application/json' } }); }
|
||||
async function session(r) { const cookie = r.cookies.aw_session; const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); return client(cookie, c.json.csrf); }
|
||||
const linkToken = (m, kind) => (m.text.match(kind === 'verify' ? /token=([A-Za-z0-9_-]+)/ : /reset-password\/([A-Za-z0-9_-]+)/) || [])[1];
|
||||
|
||||
await test('가입 → 확인 메일 → 이메일 확인 → 개인 그룹과 별칭 user-01 → 대시보드 접근', async () => {
|
||||
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'alice@example.test', displayName: '앨리스', password: 'pw-example-1', consents: { terms: true, privacy: true } } });
|
||||
eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session, '가입 즉시 세션'); eq(mails.length, 1); ok(/token=/.test(mails[0].text));
|
||||
const api = await session(r);
|
||||
let me = await api('GET', '/api/auth/me'); eq(me.json.user.email_verified_at, null); eq(me.json.group.kind, 'personal'); eq(me.json.role, 'owner');
|
||||
// 확인 전에는 verified 필수 API 거부(그룹 만들기)
|
||||
r = await api('POST', '/api/groups', { name: '팀' }); eq(r.status, 403); eq(r.json.error, 'email_unverified');
|
||||
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.status, 200);
|
||||
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.json.error, 'token_used', '한 번만');
|
||||
me = await api('GET', '/api/auth/me'); ok(me.json.user.email_verified_at);
|
||||
const g = await api('GET', '/api/group'); eq(g.json.members[0].alias, 'user-01'); eq(g.json.members[0].role, 'owner');
|
||||
const d = await api('GET', '/api/dashboard'); eq(d.status, 200); eq(d.json.summary.sites.purchased, 0);
|
||||
});
|
||||
await test('중복 가입: 존재를 드러내지 않는 일반 안내 / 필수 동의 없으면 거부', async () => {
|
||||
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'ALICE@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true, privacy: true } } }); eq(r.status, 409); eq(r.json.error, 'signup_unavailable'); ok(!/이미 가입|존재/.test(r.json.message) || /로그인/.test(r.json.message));
|
||||
r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'bob@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true } } }); eq(r.json.error, 'consent_required');
|
||||
});
|
||||
await test('로그인 실패 메시지 동일 / 5회 실패 → 15분 잠금(해제 시각 표시) / HTML 미로그인 → /login 리디렉션', async () => {
|
||||
let r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'nobody@example.test', password: 'x' } }); const msg1 = r.json.message;
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } }); eq(r.json.message, msg1, '존재 여부 비노출');
|
||||
for (let i = 0; i < 4; i++) r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } });
|
||||
eq(r.status, 429); eq(r.json.error, 'locked'); ok(r.json.detail.lockUntil); ok(/KST/.test(r.json.message));
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); eq(r.status, 429, '맞는 비밀번호도 잠금 중에는 거부');
|
||||
deps.store.run('DELETE FROM login_attempts');
|
||||
r = await invoke(app, 'GET', '/sites', { headers: { accept: 'text/html' } }); eq(r.status, 302); ok(r.headers.Location.startsWith('/login?next=%2Fsites'));
|
||||
r = await invoke(app, 'GET', '/api/dashboard'); eq(r.status, 401);
|
||||
});
|
||||
await test('로그인 → 기기 목록 → 개별 종료 → 다른 모든 세션 종료 → 비밀번호 변경이 다른 세션 무효화 / CSRF 거부', async () => {
|
||||
const a = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' }, headers: { 'user-agent': 'Mozilla/5.0 (Windows) Chrome/120' } }); eq(a.status, 200);
|
||||
const b = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const c = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } });
|
||||
const A = await session(a); const B = await session(b); const C = await session(c);
|
||||
let r = await A('GET', '/api/account/sessions'); ok(r.json.sessions.length >= 3); ok(r.json.sessions.some((s) => /Windows/.test(s.label)));
|
||||
const bCur = (await B('GET', '/api/account/sessions')).json.current;
|
||||
r = await A('DELETE', '/api/account/sessions/' + bCur); eq(r.json.revoked, 1); r = await B('GET', '/api/auth/me'); eq(r.json.user, null, '종료된 세션');
|
||||
// CSRF: 세션은 있는데 헤더가 틀리면 403
|
||||
r = await invoke(app, 'POST', '/api/account/sessions/revoke-others', { headers: { cookie: `aw_session=${a.cookies.aw_session}`, 'x-csrf': 'wrong' } }); eq(r.status, 403); eq(r.json.error, 'csrf');
|
||||
r = await A('POST', '/api/account/sessions/revoke-others'); ok(r.json.revoked >= 1); r = await C('GET', '/api/auth/me'); eq(r.json.user, null);
|
||||
// 비밀번호 변경 → 다른 세션 종료(이 세션 유지)
|
||||
const d = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const D = await session(d);
|
||||
r = await A('POST', '/api/account/password', { current: 'pw-example-1', next: 'pw-example-9' }); eq(r.status, 200); r = await D('GET', '/api/auth/me'); eq(r.json.user, null); r = await A('GET', '/api/auth/me'); ok(r.json.user);
|
||||
r = await A('POST', '/api/account/password', { current: 'bad', next: 'pw-example-9' }); eq(r.json.error, 'wrong_password');
|
||||
});
|
||||
await test('비밀번호 재설정: 요청 응답 동일 · 링크 상태(valid→used) · 재설정 후 모든 세션 종료', async () => {
|
||||
const n0 = mails.length; let r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'ghost@example.test' } }); const msg = r.json.message; eq(mails.length, n0, '없는 계정은 메일 없음');
|
||||
r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'alice@example.test' } }); eq(r.json.message, msg); eq(mails.length, n0 + 1);
|
||||
const tok = linkToken(mails[n0], 'reset'); ok(tok); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'valid');
|
||||
const live = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-9' } }); const L = await session(live);
|
||||
r = await invoke(app, 'POST', '/api/auth/reset', { body: { token: tok, password: 'pw-example-3' } }); eq(r.status, 200);
|
||||
r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used'); r = await invoke(app, 'GET', '/reset-password/' + tok, { headers: { accept: 'text/html' } }); ok(/이미 사용한/.test(r.text));
|
||||
r = await L('GET', '/api/auth/me'); eq(r.json.user, null, '재설정 뒤 기존 세션 종료');
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.status, 200);
|
||||
deps.store.run('UPDATE email_tokens SET expires_at=? WHERE token_hash IS NOT NULL AND kind=\'reset\'', new Date(0).toISOString()); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used');
|
||||
});
|
||||
await test('MFA: 등록(코드 확인) → 복구 코드 1회 표시 → 로그인 2단계 → 같은 코드 재사용 거부 → 복구 코드 1회용 → 해제', async () => {
|
||||
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const A = await session(l);
|
||||
let r = await A('POST', '/api/account/mfa/enable', { code: '000000' }); eq(r.json.error, 'mfa_not_pending');
|
||||
r = await A('POST', '/api/account/mfa/setup'); const secret = r.json.secret.replace(/ /g, ''); ok(/^otpauth:\/\/totp\//.test(r.json.otpauth));
|
||||
r = await A('POST', '/api/account/mfa/enable', { code: '123456' }); eq(r.json.error, 'mfa_bad_code');
|
||||
r = await A('POST', '/api/account/mfa/enable', { code: totp.currentCode(secret) }); eq(r.status, 200); eq(r.json.recoveryCodes.length, 10); const rc = r.json.recoveryCodes;
|
||||
ok(!JSON.stringify(deps.store.get('SELECT recovery_json FROM users WHERE email=?', 'alice@example.test')).includes(rc[0]), '복구 코드는 해시만 저장');
|
||||
// 로그인: 1단계 → preauth → 2단계
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.json.mfaRequired, true); ok(!r.cookies.aw_session); const pre = r.json.preauth;
|
||||
/** 등록·이전 로그인에 쓴 카운터를 지워 현재 구간 코드를 다시 쓸 수 있게 한다(시간 경계에 따른 흔들림 방지) */
|
||||
const rewind = () => { const u = deps.store.get('SELECT mfa_json FROM users WHERE email=?', 'alice@example.test'); const m = JSON.parse(u.mfa_json); m.lastCounter = null; deps.store.run('UPDATE users SET mfa_json=? WHERE email=?', JSON.stringify(m), 'alice@example.test'); };
|
||||
rewind(); const code = totp.currentCode(secret);
|
||||
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code: 'bad' } }); eq(r.status, 401);
|
||||
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session);
|
||||
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); ok(/preauth_/.test(r.json.error), '사전 인증 토큰 1회용');
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code } }); eq(r.status, 401, '같은 TOTP 코드 재사용 거부');
|
||||
// 복구 코드
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const pre2 = r.json.preauth; r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre2, code: rc[0] } }); eq(r.status, 200); eq(r.json.recoveryLeft, 9);
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code: rc[0] } }); eq(r.status, 401, '복구 코드 1회용');
|
||||
// 해제: 비밀번호+코드
|
||||
rewind(); const l2 = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: l2.json.preauth, code: totp.currentCode(secret) } }); eq(r.status, 200, JSON.stringify(r.json)); const B = await session(r);
|
||||
r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: '000000' }); eq(r.json.error, 'mfa_bad_code');
|
||||
rewind(); r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: totp.currentCode(secret) }); eq(r.status, 200, JSON.stringify(r.json));
|
||||
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); ok(r.cookies.aw_session, '해제 뒤 바로 로그인');
|
||||
});
|
||||
await test('화면 렌더: 로그인·가입·내 계정·보안·기기·도움말 HTML 이 열린다', async () => {
|
||||
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const h = { cookie: `aw_session=${l.cookies.aw_session}`, accept: 'text/html' };
|
||||
for (const p of ['/account', '/account/security', '/account/sessions', '/help', '/notifications', '/activity', '/group', '/']) { const r = await invoke(app, 'GET', p, { headers: h }); eq(r.status, 200, p); ok(/<html lang="ko">/.test(r.text), p); }
|
||||
for (const p of ['/login', '/signup', '/reset-password']) { const r = await invoke(app, 'GET', p, { headers: { accept: 'text/html' } }); eq(r.status, 200, p); }
|
||||
const r = await invoke(app, 'GET', '/login', { headers: h }); eq(r.status, 302, '로그인 상태면 /login 은 대시보드로');
|
||||
});
|
||||
done();
|
||||
@@ -0,0 +1,57 @@
|
||||
/** test/dashboard.test.mjs — 대시보드 요약·주의 항목 순서·최근 활동 / 알림 센터 API / 스케줄러 기본 작업. */
|
||||
import { test, eq, ok, done } from '../lib/test.mjs';
|
||||
import { makeDeps, appFor, seedGroup, call } from './helpers.mjs';
|
||||
import { grantEntitlement, reserve, commit, recordUsage, setAllocation } from '../lib/quota.mjs';
|
||||
import { notify } from '../lib/notify.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
|
||||
const deps = await makeDeps({ mailer: { send: async () => ({ ok: true }), mode: () => 'test' } });
|
||||
const who = seedGroup(deps); const other = seedGroup(deps, { email: 'o@example.test', name: '남' });
|
||||
deps.jobs.register('deploy.site', { title: '배포', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { if (job.subject.fail) throw new Error('boom'); ctx.waitExternal('외부 확인'); } });
|
||||
const app = await appFor(deps, ['auth', 'dashboard', 'notifications'], who);
|
||||
const g = who.group.id; const now = new Date().toISOString();
|
||||
|
||||
await test('요약: 사이트 수(구매·사용 중·예약)·초안·건강·배포 방식·도메인·메일·백업·작업·비용', async () => {
|
||||
grantEntitlement(deps.store, { groupId: g, resource: 'sites', amount: 3 }); grantEntitlement(deps.store, { groupId: g, resource: 'web_disk', amount: 1000 });
|
||||
const s1 = newId('s_'); const s2 = newId('s_');
|
||||
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s1, g, 'A', 'live', 'managed', 'ok', now, now);
|
||||
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s2, g, 'B', 'draft', null, 'unknown', now, now);
|
||||
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('s_'), other.group.id, 'X', 'live', 'external', 'down', now, now);
|
||||
commit(deps.store, reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1 }).id); reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s2 });
|
||||
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'a.example.test', s1, 'verified', 'ok', 'ok', now, now);
|
||||
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'b.example.test', s1, 'pending', 'pending', 'none', now, now);
|
||||
deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('ms_'), g, 'site_bound', s1, 'A 메일', 'done', now, now);
|
||||
deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, consecutive_failures, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('bo_'), s1, g, 'active', '{}', '{}', 3, now, now);
|
||||
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-1', '[]', 10000, 1000, 11000, 'awaiting_payment', now, now);
|
||||
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, approved_at, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-2', '[]', 20000, 2000, 22000, 'approved', now, now, now);
|
||||
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subjectType: 'site', subjectId: s1 }); await deps.jobs.drain();
|
||||
const r = await call(app, 'GET', '/api/dashboard'); eq(r.status, 200); const s = r.json.summary;
|
||||
eq([s.sites.purchased, s.sites.inUse, s.sites.reserved, s.sites.unallocated, s.sites.live], [3, 1, 1, 1, 1]); eq(s.drafts, 1); eq(s.health, { ok: 1, warn: 0, bad: 0 }); eq(s.deploy, { managed: 1, external: 0 });
|
||||
eq(s.domains, { total: 2, action: 1 }); eq(s.mail, { services: 1, mailboxes: 0 }); eq(s.backup, 1); eq(s.jobs, [{ kind: 'deploy', label: '배포', n: 1 }]); eq([s.cost.estimated, s.cost.awaiting], [22000, 11000]);
|
||||
});
|
||||
await test('주의 항목: 필수 알림 → 결제 대기 → 도메인 → 사용량 → 실패 작업 → 백업 순, 각각 링크 / 다른 그룹 항목 없음', async () => {
|
||||
notify(deps.store, { groupId: g, category: 'security', severity: 'critical', mandatory: true, title: '보안 경고', link: '/account/security' });
|
||||
notify(deps.store, { groupId: other.group.id, category: 'security', severity: 'critical', mandatory: true, title: '남의 경고', link: '/x' });
|
||||
recordUsage(deps.store, { groupId: g, resource: 'web_disk', subjectType: 'site', subjectId: 'any', used: 960 });
|
||||
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subject: { fail: true }, subjectType: 'site', subjectId: 'sX' }); await deps.jobs.drain();
|
||||
const r = await call(app, 'GET', '/api/dashboard'); const a = r.json.attention;
|
||||
eq(a[0].text, '보안 경고'); eq(a[0].href, '/account/security'); ok(!a.some((x) => x.text === '남의 경고'));
|
||||
const kinds = a.map((x) => x.kind); ok(kinds.indexOf('notification') < kinds.indexOf('order')); ok(a.find((x) => x.kind === 'usage' && /95%/.test(x.text) && x.severity === 'critical')); ok(a.find((x) => x.kind === 'job' && x.severity === 'critical')); ok(a.find((x) => x.kind === 'backup' && /3회/.test(x.text))); ok(a.find((x) => x.kind === 'domain' && /소유 확인/.test(x.text)));
|
||||
ok(a.every((x) => x.href && x.href.startsWith('/')), '모든 항목에 조치 링크');
|
||||
});
|
||||
await test('최근 활동: 사용자/자동 구분, 인증 로그 제외 / 알림 API: 필터·읽음·사건 반복·필수', async () => {
|
||||
audit(deps.store, { actorType: 'user', actorId: who.user.id, actorLabel: who.user.email, groupId: g, action: 'deploy.start' }); audit(deps.store, { actorType: 'system', groupId: g, action: 'backup.run', automatic: true }); audit(deps.store, { actorType: 'user', actorId: who.user.id, groupId: g, action: 'auth.login' });
|
||||
let r = await call(app, 'GET', '/api/dashboard'); const rec = r.json.recent; eq(rec.length, 2); eq(rec[0].automatic, true); eq(rec[1].automatic, false);
|
||||
notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90%' }); notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90% 다시' });
|
||||
r = await call(app, 'GET', '/api/notifications?category=usage'); eq(r.json.items.length, 1); eq(r.json.items[0].count, 2); ok(r.json.unread >= 2);
|
||||
r = await call(app, 'GET', '/api/notifications'); ok(r.json.items.find((n) => n.mandatory && n.title === '보안 경고')); ok(!r.json.items.find((n) => n.title === '남의 경고'));
|
||||
const id = r.json.items[0].id; r = await call(app, 'POST', `/api/notifications/${id}/read`); eq(r.json.changed, 1); r = await call(app, 'GET', '/api/notifications?unread=1'); ok(!r.json.items.find((n) => n.id === id));
|
||||
r = await call(app, 'POST', '/api/notifications/read-all'); r = await call(app, 'GET', '/api/notifications?unread=1'); eq(r.json.items.length, 0);
|
||||
});
|
||||
await test('스케줄러: 만료 예약 자동 반환 + 알림, 작업 목록', async () => {
|
||||
deps.store.run('UPDATE quota_reservations SET expires_at=? WHERE state=\'reserved\'', new Date(0).toISOString());
|
||||
const r = await deps.scheduler.runNow('quota.expire'); eq(r.result, 1); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND incident_key LIKE \'reservation:%\'', g));
|
||||
ok(deps.scheduler.list().map((t) => t.name).includes('sessions.purge'));
|
||||
});
|
||||
done();
|
||||
@@ -0,0 +1,74 @@
|
||||
/** test/groups.test.mjs — 사용자 그룹: 전환 격리·초대 수락·역할·정지 영향 범위·소유권 이전·탈퇴·사이트별 권한·직접 접근 403. */
|
||||
import { test, eq, ok, done } from '../lib/test.mjs';
|
||||
import { makeDeps, appFor } from './helpers.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import { grantEntitlement } from '../lib/quota.mjs';
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
|
||||
const mails = []; const deps = await makeDeps({ mailer: { send: async (m) => { mails.push(m); return { ok: true }; }, mode: () => 'test' } });
|
||||
deps.jobs.register('demo.wait', { title: '데모 대기', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { ctx.waitExternal('대기'); } });
|
||||
const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui']);
|
||||
async function signup(email, name) { const r = await invoke(app, 'POST', '/api/auth/signup', { body: { email, displayName: name, password: 'pw-example-1', consents: { terms: true, privacy: true } } }); const tok = (mails.at(-1).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1]; await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: tok } }); return client(r.cookies.aw_session); }
|
||||
async function client(cookie) { const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); const f = (method, path, body, extra = {}) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': c.json.csrf, ...extra } }); f.cookie = cookie; return f; }
|
||||
const uid = (email) => deps.store.get('SELECT id FROM users WHERE email=?', email).id;
|
||||
const owner = await signup('owner@example.test', '오너'); const bob = await signup('bob@example.test', '밥'); const carol = await signup('carol@example.test', '캐럴');
|
||||
|
||||
let teamId;
|
||||
await test('팀 그룹 만들기 → 초대 → 다른 이메일로는 수락 불가 → 초대받은 사람이 수락 → 별칭 user-02', async () => {
|
||||
let r = await owner('POST', '/api/groups', { name: '오즈 팀' }); eq(r.status, 200); teamId = r.json.groupId;
|
||||
r = await owner('GET', '/api/auth/me'); eq(r.json.group.id, teamId, '만들면 그 그룹으로 전환');
|
||||
r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.status, 200); const link = mails.at(-1).text.match(/invite\/([A-Za-z0-9_-]+)/)[1];
|
||||
r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.json.error, 'invite_pending');
|
||||
r = await carol('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_email_mismatch');
|
||||
r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.status, 200); r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_invalid', '1회용');
|
||||
r = await bob('GET', '/api/group'); eq(r.json.group.id, teamId); eq(r.json.me.alias, 'user-02'); eq(r.json.me.role, 'member'); eq(r.json.invites.length, 0, '구성원에게는 초대 목록 없음');
|
||||
});
|
||||
await test('그룹 전환 격리: 같은 사용자가 두 그룹을 오가도 대시보드·알림·사이트가 섞이지 않는다', async () => {
|
||||
const personal = deps.store.get('SELECT g.id FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND g.kind=\'personal\'', uid('bob@example.test')).id;
|
||||
const now = new Date().toISOString();
|
||||
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), teamId, '팀 사이트', 'draft', now, now);
|
||||
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트', 'draft', now, now); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트2', 'draft', now, now);
|
||||
grantEntitlement(deps.store, { groupId: teamId, resource: 'sites', amount: 3 });
|
||||
let r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 1); eq(r.json.summary.sites.purchased, 3);
|
||||
r = await bob('POST', '/api/groups/switch', { groupId: personal }); eq(r.status, 200);
|
||||
r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 2); eq(r.json.summary.sites.purchased, 0);
|
||||
r = await bob('POST', '/api/groups/switch', { groupId: 'g_nope' }); eq(r.status, 403);
|
||||
r = await carol('POST', '/api/groups/switch', { groupId: teamId }); eq(r.status, 403, '구성원 아님');
|
||||
await bob('POST', '/api/groups/switch', { groupId: teamId });
|
||||
});
|
||||
await test('역할 없는 직접 API 접근 → 403 + 필요한 역할 / 역할 변경은 감사 기록(삭제 불가)', async () => {
|
||||
let r = await bob('PATCH', '/api/group', { name: 'x' }); eq(r.status, 403); eq(r.json.error, 'forbidden'); eq(r.json.detail.requiredRole, ['owner', 'admin']); eq(r.json.detail.yourRole, 'member');
|
||||
r = await bob('POST', '/api/group/invites', { email: 'dave@example.test' }); eq(r.status, 403);
|
||||
r = await owner('PATCH', '/api/group/members/' + uid('bob@example.test'), { role: 'admin' }); eq(r.status, 200);
|
||||
r = await owner('GET', '/api/audit?action=member.role'); eq(r.json.items[0].immutable, true); eq(r.json.items[0].detail.to, 'admin');
|
||||
r = await bob('PATCH', '/api/group', { name: '오즈 팀 2' }); eq(r.status, 200);
|
||||
r = await owner('PATCH', '/api/group/members/' + uid('owner@example.test'), { role: 'member' }); eq(r.json.error, 'owner_role');
|
||||
});
|
||||
await test('구성원 정지: 그 사람의 세션과 미실행 작업만 취소, 다른 구성원 작업·그룹 자산은 유지', async () => {
|
||||
const bobId = uid('bob@example.test'); const ownerId = uid('owner@example.test');
|
||||
const jb = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: bobId }).job; const jo = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: ownerId }).job; const jbOther = deps.jobs.enqueue({ kind: 'demo.wait', groupId: 'g_other', createdBy: bobId }).job;
|
||||
await deps.jobs.drain(); eq(deps.jobs.get(jb.id).status, 'awaiting_external');
|
||||
let r = await owner('GET', `/api/group/members/${bobId}/impact`); eq(r.json.impact.pendingJobs.length, 1); ok(r.json.impact.sessions >= 1);
|
||||
r = await owner('POST', `/api/group/members/${bobId}/suspend`); eq(r.status, 200); ok(r.json.sessionsRevoked >= 1);
|
||||
eq(deps.jobs.get(jb.id).status, 'cancelled'); eq(deps.jobs.get(jo.id).status, 'awaiting_external', '다른 구성원 작업 유지'); eq(deps.jobs.get(jbOther.id).status, 'awaiting_external', '다른 그룹 작업 유지');
|
||||
eq(deps.store.get('SELECT COUNT(*) AS n FROM sites WHERE group_id=?', teamId).n, 1, '그룹 자산 유지');
|
||||
r = await bob('GET', '/api/auth/me'); eq(r.json.user, null, '정지된 구성원의 그룹 세션 종료');
|
||||
r = await owner('POST', `/api/group/members/${bobId}/unsuspend`); eq(r.status, 200);
|
||||
});
|
||||
await test('사이트별 권한 설정·조회, 소유권 이전, 탈퇴 규칙', async () => {
|
||||
const bobId = uid('bob@example.test'); const site = deps.store.get('SELECT id FROM sites WHERE group_id=?', teamId);
|
||||
let r = await owner('PATCH', '/api/group/members/' + bobId, { role: 'member' }); eq(r.status, 200, '관리자는 암묵적 전체 권한이므로 구성원으로 되돌려 시험');
|
||||
r = await owner('PUT', `/api/sites/${site.id}/permissions/${bobId}`, { perms: ['view', 'deploy', 'bogus'] }); eq(r.json.perms, ['view', 'deploy']);
|
||||
r = await owner('GET', `/api/sites/${site.id}/permissions`); const row = r.json.members.find((m) => m.userId === bobId); eq(row.perms, ['view', 'deploy']);
|
||||
r = await owner('GET', `/api/sites/s_nope/permissions`); eq(r.status, 404);
|
||||
r = await owner('GET', '/api/audit?action=permission.site'); eq(r.json.items[0].immutable, true);
|
||||
// 탈퇴·이전
|
||||
r = await owner('POST', '/api/group/leave'); eq(r.json.error, 'owner_cannot_leave');
|
||||
r = await owner('POST', '/api/group/transfer-ownership', { userId: bobId }); eq(r.status, 200);
|
||||
r = await owner('GET', '/api/group'); eq(r.json.me.role, 'admin'); eq(r.json.group.ownerUserId, bobId);
|
||||
r = await owner('POST', '/api/group/leave'); eq(r.status, 200); r = await owner('GET', '/api/auth/me'); ok(r.json.group.id !== teamId, '탈퇴 뒤 다른 그룹으로');
|
||||
// 삭제 불가 기록·알림
|
||||
ok(deps.store.get('SELECT 1 AS x FROM audit_log WHERE action=\'ownership.group_transferred\' AND immutable=1'));
|
||||
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND mandatory=1', teamId));
|
||||
});
|
||||
done();
|
||||
Reference in New Issue
Block a user