Files
AriWeb-Suite/test/groups.test.mjs
T

75 lines
8.3 KiB
JavaScript
Raw Normal View History

/** 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();