Files

43 lines
7.8 KiB
JavaScript

/** 흐름 1·2: 가입 → 확인 메일 → 이메일 확인 → 개인 그룹 → 팀 그룹·초대·수락. 이후 흐름이 쓰는 고객 클라이언트(state.alice / state.bob)를 만든다. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
export const BUDGET_MS = 40000;
/** 가입 + 이메일 확인 + CSRF 까지 끝낸 고객 클라이언트 */
export async function signupVerified(env, email, name) {
const c = env.customer();
const r = await c.post('/api/auth/signup', { email, displayName: name, password: 'pw-' + email.split('@')[0] + '-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(`가입 실패 ${r.status} ${r.text.slice(0, 200)}`);
const cs0 = await c.get('/api/auth/csrf'); c.setCsrf(cs0.json.csrf); // 가입 직후 세션의 CSRF(변경 API 에 필요)
const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const token = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!token) throw new Error('확인 메일 토큰 없음');
const v = await c.post('/api/auth/verify-email', { token }); if (v.status !== 200) throw new Error(`이메일 확인 실패 ${v.text.slice(0, 200)}`);
const cs = await c.get('/api/auth/csrf'); c.setCsrf(cs.json.csrf); c.email = email; return c;
}
export async function run({ env, report, state }) {
const s = steps();
try {
const email = 'alice@e2e.localhost';
const alice = await s.run('가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급', async () => { const c = env.customer(); const r = await c.post('/api/auth/signup', { email, displayName: '앨리스', password: 'pw-alice-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { status: r.status, hasSession: !!c.cookie('aw_session') }; }) && env.customer();
const r0 = await alice.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const cs0 = await alice.get('/api/auth/csrf'); alice.setCsrf(cs0.json.csrf); alice.email = email;
await s.run('확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)', async () => { const r = await alice.post('/api/groups', { name: '팀' }); if (r.status !== 403 || r.json.error !== 'email_unverified') throw new Error(`${r.status} ${r.json?.error}`); return r.json.error; });
const token = await s.run('확인 메일 수신(발신 포획)에서 토큰 추출', async () => { const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('메일 없음'); return { subject: m.subject }; }) && (env.mail.last(email, /token=/).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1];
await s.run('이메일 확인(1회용) → 재사용 거부', async () => { const a = await alice.post('/api/auth/verify-email', { token }); const b = await alice.post('/api/auth/verify-email', { token }); if (a.status !== 200 || b.json?.error !== 'token_used') throw new Error(`${a.status}/${b.json?.error}`); return { first: a.status, second: b.json.error }; });
const me = await s.run('내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01', async () => { const r = await alice.get('/api/auth/me'); const g = await alice.get('/api/group'); if (!r.json.user.email_verified_at || r.json.group.kind !== 'personal') throw new Error(JSON.stringify(r.json).slice(0, 200)); return { verified: true, groupKind: r.json.group.kind, role: r.json.role, alias: g.json.members?.[0]?.alias }; });
await s.run('중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)', async () => { const r = await env.customer().post('/api/auth/signup', { email: 'ALICE@e2e.localhost', displayName: 'x', password: 'pw-dup-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 409) throw new Error(String(r.status)); return r.json.error; });
await s.run('로그인 기기 목록 · 다른 세션 종료', async () => { const other = env.customer(); await other.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const l = await alice.get('/api/account/sessions'); const n = l.json.sessions.length; const rv = await alice.post('/api/account/sessions/revoke-others'); const me2 = await other.get('/api/auth/me'); if (!(n >= 2) || me2.json.user) throw new Error('세션 종료 안 됨'); return { sessionsBefore: n, revoked: rv.json.revoked }; });
state.alice = alice; state.aliceEmail = email;
report.flow(1, 'verified_local', { steps: s.list, note: '가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료', evidence: { me } });
} catch (e) { report.flow(1, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '가입·이메일 확인 흐름 실패', file: 'portal/auth/api.mjs', expected: '가입→확인→개인 그룹', actual: e.message, repro: '10-identity.mjs 흐름 1', flows: [1] }); return; }
const s2 = steps();
try {
const alice = state.alice;
const teamId = await s2.run('팀 그룹 만들기 → 현재 그룹 전환', async () => { const r = await alice.post('/api/groups', { name: 'E2E 팀' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const me = await alice.get('/api/auth/me'); if (me.json.group.id !== r.json.groupId) throw new Error('전환 안 됨'); return r.json.groupId; });
const bob = await signupVerified(env, 'bob@e2e.localhost', '밥');
const link = await s2.run('이메일 초대(7일) → 초대 메일 링크', async () => { const r = await alice.post('/api/group/invites', { email: 'bob@e2e.localhost', role: 'member' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const m = await waitFor(() => env.mail.last('bob@e2e.localhost', /invite\//), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/invite\/([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('초대 메일 없음'); return t; });
await s2.run('초대 수락(1회용) → 별칭 user-02 · 역할 member', async () => { const r = await bob.post('/api/invites/accept', { token: link }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const g = await bob.get('/api/group'); const again = await bob.post('/api/invites/accept', { token: link }); if (g.json.group.id !== teamId || again.status === 200) throw new Error('수락 상태 이상'); return { alias: g.json.me.alias, role: g.json.me.role, reuse: again.json?.error }; });
await s2.run('권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할', async () => { const r = await bob.patch('/api/group', { name: 'x' }); if (r.status !== 403) throw new Error(String(r.status)); return r.json.detail; });
await s2.run('그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음', async () => { const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); const a = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: personal.id }); const b = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: teamId }); return { team: a.json.summary?.sites, personal: b.json.summary?.sites }; });
state.bob = bob; state.teamId = teamId; await alice.post('/api/groups/switch', { groupId: teamId });
report.flow(2, 'verified_local', { steps: s2.list, note: '팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리' });
} catch (e) { report.flow(2, 'failed', { steps: s2.list, note: e.message }); report.bug({ title: '그룹·초대 흐름 실패', file: 'portal/groups/api.mjs', expected: '팀 그룹·초대·수락', actual: e.message, repro: '10-identity.mjs 흐름 2', flows: [2] }); }
}