Files
AriWeb-Suite/test/e2e/flows/40-mail.mjs

48 lines
12 KiB
JavaScript

/** 흐름 10·11·12·17·33: 사이트 대표 메일·메일함(가짜 AriMail) → DNS 확인·활성화(외부 대기) → 왕복 확인(로컬 불가) → 독립 메일 → 사용량 경고/100% → 전환 점검표. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
import { cancelDomainJob } from './30-sites.mjs';
export const BUDGET_MS = 60000;
const alloc = { mailboxes: 6, storageBytes: 2 * 1024 ** 3, monthlyMessages: 500 };
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [10, 11, 12, 17, 33]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
let svcId = null;
{ const s = steps(); try {
const svc = await s.run('사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const d = env.am.state.domains.get('aw-e2e.localhost'); const cfg = env.am.state.configs.get('aw-e2e.localhost') || {}; return { id: r.json.service.id, status: r.json.service.status, tenant: !!d, ariwebKind: d?.ariweb?.kind, limitAccounts: cfg.LIMIT_ACCOUNTS, siteCountAllocated: (await alice.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated }; });
svcId = svc.id; state.mailServiceId = svcId;
await s.run('같은 사이트에 두 번째 대표 메일 거부', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc: { mailboxes: 1 } }); return r.json?.error; });
const mb = await s.run('메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const again = await alice.get(`/api/mail/services/${svcId}/mailboxes/${r.json.mailbox.id}`); const leaked = JSON.stringify(again.json).includes(r.json.initialPassword); return { id: r.json.mailbox.id, address: r.json.mailbox.address, passwordShownOnce: !!r.json.initialPassword, mustChange: r.json.mailbox.mustChangePassword, imapHost: r.json.program?.imap?.host, leakedOnReload: leaked, inArimail: !!(env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim') }; });
state.mailboxId = mb.id;
await s.run('초대 방식 메일함(링크 만료·재발송·사용 완료 상태)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.e2e.localhost' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const token = r.json.invite.url.split('/').pop(); const acc = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); const again = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); return { inviteState: r.json.mailbox.inviteState, accepted: acc.status, reuse: again.json?.error }; });
const dns = await s.run('DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/dns-check`); return { ok: r.json.result?.ok, missing: r.json.result?.summary?.missing, items: (r.json.result?.items || []).map((i) => i.kind + ':' + i.state) }; });
const act = await s.run('활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등', async () => { const a = await alice.post(`/api/mail/services/${svcId}/activate`); const b = await alice.post(`/api/mail/services/${svcId}/activate`); const j = await waitFor(async () => { const x = await alice.get(`/api/mail/services/${svcId}/job`); return x.json?.job && x.json.job.status !== 'queued' && x.json.job.status !== 'running' ? x.json.job : null; }, { timeoutMs: 20000, intervalMs: 500 }); return { status: j?.status, waitReason: j?.waitReason, sameJob: a.json?.job?.id === b.json?.job?.id, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; });
state.mailJobId = act && (await alice.get(`/api/mail/services/${svcId}/job`)).json?.job?.id;
await s.run('보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/security`); return { items: (r.json.report?.items || []).map((i) => i.id + ':' + i.state), records: (r.json.report?.records || []).length }; });
report.needsInfra('메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)');
report.flow(10, 'partial', { steps: s.list, note: '대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요', evidence: { svc, mb, dns, act } });
} catch (e) { report.flow(10, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '사이트 대표 메일·메일함 흐름 실패', file: 'portal/mail/api.mjs', route: 'POST /api/mail/services, /mailboxes', expected: '서비스·메일함 생성', actual: e.message, repro: '40-mail.mjs', flows: [10] }); } }
report.flow(11, 'not_verifiable_locally', { note: '외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨' });
// ---- 12 독립 메일
{ const s = steps(); try {
const d = await s.run('메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)', async () => { const r = await alice.post('/api/domains', { name: 'solo-e2e.localhost', purpose: 'mail', startCheck: false }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const c = await cancelDomainJob(alice, r.json.domain.id); return { id: r.json.domain.id, ownership: r.json.domain.ownership_state, jobCancelled: c }; });
const r = await s.run('독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)', async () => { const x = await alice.post('/api/mail/services', { kind: 'standalone', domain: 'solo-e2e.localhost', alloc: { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (x.status !== 200) throw new Error(`${x.status} ${x.text.slice(0, 300)}`); const u = (await alice.get('/api/usage')).json.resources; return { id: x.json.service.id, kind: x.json.service.kind, mailServicesAllocated: u.find((y) => y.resource === 'mail_services')?.allocated, sitesAllocated: u.find((y) => y.resource === 'sites')?.allocated }; });
state.standaloneMailId = r.id; const list = await alice.get('/api/mail/services');
report.flow(12, 'verified_local', { steps: s.list, note: '독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구', evidence: { r, listCount: list.json.services?.length, noBackupNote: list.json.notes?.noBackup } });
} catch (e) { report.flow(12, 'partial', { steps: s.list, note: e.message }); } }
// ---- 17 사용량 경고·100%(메일 저장 공간·월간 통수)
{ const s = steps(); try {
if (!svcId) throw new Error('메일 서비스 없음');
const warn = await s.run('가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시', async () => { env.am.state.usage.set('aw-e2e.localhost', { storageBytes: Math.round(0.96 * alloc.storageBytes), sent: 300, received: 250, monthReset: '2026-10-31T15:00:00.000Z' }); const kim = (env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim'); if (kim) kim.bytes = Math.round(0.97 * (kim.quotaBytes || 100 * 1024 ** 2)); const u = (await alice.get(`/api/mail/services/${svcId}/usage?refresh=1`)).json; const ev = await alice.post('/api/usage/evaluate', {}); u._evaluate = { status: ev.status, notified: ev.json?.notified?.length ?? ev.json?.result?.notified?.length, limited: ev.json?.limited?.length ?? ev.json?.result?.limited?.length }; return { storageLevel: u.figures?.storage?.level, monthlyLevel: u.figures?.monthly?.level, nextReset: u.nextReset, measuredAt: !!u.measuredAt, limit100: u.notes?.limit100?.monthly, countingRule: u.lastUsage?.countingRule, evaluate: u._evaluate }; });
const notif = await s.run('알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)', async () => { const r = await alice.get('/api/notifications'); const items = r.json.notifications || r.json.items || []; const usage = items.filter((n) => n.category === 'usage' || /%/.test(n.title)); return { count: usage.length, titles: usage.slice(0, 4).map((n) => n.title), mandatory: usage.some((n) => n.mandatory) }; });
const lim = await s.run('사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약', async () => { const r = await alice.get('/api/usage/limits'); const u = await alice.get('/api/usage'); return { limits: (r.json.limits || []).map((l) => l.resource + ':' + l.level), warnLevels: u.json.product?.warnLevels }; });
if (!notif.count) report.bug({ title: '메일 사용량 수집 뒤 서비스 단위 70/80/90/95/100 경고 알림이 생성되지 않음(수집과 경고 엔진 연결 누락 가능)', file: 'portal/mail/service.mjs (collectMailUsage) ↔ portal/usage/warnings.mjs (evaluate)', route: 'GET /api/mail/services/:id/usage?refresh=1 → POST /api/usage/evaluate', expected: '저장 공간 95%·월간 100% 수집 뒤 알림 센터에 경고/제한 알림(필수)', actual: { notifications: notif, limits: lim.limits, evaluate: warn.evaluate }, repro: '가짜 AriMail 사용량을 95%/100% 로 두고 refresh=1 수집 → /api/notifications 에 usage 알림 없음', flows: [17] });
report.flow(17, notif.count ? 'verified_local' : 'partial', { steps: s.list, note: '메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)', evidence: { warn, notif, lim } });
} catch (e) { report.flow(17, 'partial', { steps: s.list, note: e.message }); } }
// ---- 33 전환 점검표
{ const s = steps(); try {
const ck = await s.run('사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/convert`); return { canConvert: r.json.checklist?.canConvert, items: (r.json.checklist?.items || []).map((i) => i.id + ':' + (i.ok ? 'ok' : 'x')) }; });
const tr = await s.run('DNS 미확인 상태에서 전환 요청은 차단', async () => { const r = await alice.post(`/api/mail/services/${svcId}/convert`, { confirm: 'aw-e2e.localhost' }); return { status: r.status, error: r.json?.error }; });
report.flow(33, 'partial', { steps: s.list, note: '전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)', evidence: { ck, tr } });
} catch (e) { report.flow(33, 'partial', { steps: s.list, note: e.message }); } }
}