import { expect, test, type Page, type TestInfo } from "@playwright/test"; import { completeOnboarding, fetchAvailablePersona } from "./support"; interface WsResult { code: number; messages: string[]; } async function signInLearner(page: Page, testInfo: TestInfo, label: string) { const res = await page.request.post("/api/auth/dev-login", { data: { email: `voice.${label}.${testInfo.project.name}.${testInfo.workerIndex}@hs.ac.kr`, role: "learner", display_name: `Voice ${label}`, }, }); expect(res.ok(), await res.text()).toBeTruthy(); await completeOnboarding(page, { legal_name: `Voice ${label}`, affiliation: "한신대학교", department: "상담심리학과", grade_level: "3학년", phone: "010-4444-4444", contact_address: "경기도 오산시 한신대학교", nickname: `Voice ${label}`, self_introduction: "음성 WebSocket 경계 검증용 E2E 사용자입니다.", }); } async function openVoiceSocket(page: Page, path: string): Promise { return page.evaluate( ({ wsPath }) => new Promise((resolve) => { const url = new URL(wsPath, window.location.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(url.href); const messages: string[] = []; const timeout = window.setTimeout(() => { ws.close(); resolve({ code: -1, messages }); }, 5000); ws.onmessage = (event) => { messages.push(String(event.data)); }; ws.onclose = (event) => { window.clearTimeout(timeout); resolve({ code: event.code, messages }); }; ws.onerror = () => { messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" })); }; }), { wsPath: path }, ); } function parsedMessages(result: WsResult) { return result.messages.map((message) => JSON.parse(message) as { type: string; detail?: string }); } test.describe("voice websocket auth boundary", () => { test("advertises only voice presets accepted by user preferences", async ({ page }, testInfo) => { await signInLearner(page, testInfo, "presets"); const presetsResponse = await page.request.get("/api/users/me/voice-presets"); expect(presetsResponse.ok(), await presetsResponse.text()).toBeTruthy(); const presets = (await presetsResponse.json()) as { id: string; voice_id: string }[]; const ids = presets.map((preset) => preset.id); expect(ids).toEqual(["soft-young-fem", "calm-adult-male", "warm-adult-fem", "neutral"]); expect(ids).not.toContain("calm-adult-fem"); expect(ids).not.toContain("steady-adult-male"); for (const id of ids) { const saveResponse = await page.request.patch("/api/users/me/preferences", { data: { voice_preset_id: id }, }); expect(saveResponse.ok(), await saveResponse.text()).toBeTruthy(); expect(await saveResponse.json()).toMatchObject({ voice_preset_id: id }); } const unsupported = await page.request.patch("/api/users/me/preferences", { data: { voice_preset_id: "calm-adult-fem" }, }); expect(unsupported.status(), await unsupported.text()).toBe(422); }); test("rejects unauthenticated websocket clients before degraded voice handling", async ({ page }) => { await page.goto("/login"); const result = await openVoiceSocket(page, "/api/voice/ws?persona_code=UNAUTHENTICATED"); const messages = parsedMessages(result); expect(result.code).toBe(1008); expect(messages).toContainEqual({ type: "error", detail: "not authenticated" }); expect(messages.some((message) => message.type === "degraded")).toBeFalsy(); }); test("rejects binding another learner's session id", async ({ page }, testInfo) => { await signInLearner(page, testInfo, "owner"); const persona = await fetchAvailablePersona(page); const start = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic" }, }); expect(start.ok(), await start.text()).toBeTruthy(); const { session_id } = (await start.json()) as { session_id: string }; await signInLearner(page, testInfo, "other"); await page.goto("/learn"); const result = await openVoiceSocket( page, `/api/voice/ws?session_id=${encodeURIComponent(session_id)}`, ); const messages = parsedMessages(result); expect(result.code).toBe(1008); expect(messages).toContainEqual({ type: "error", detail: "session does not belong to user", }); expect(messages.some((message) => message.type === "degraded")).toBeFalsy(); }); test("rejects an existing voice session after consent withdrawal @single-run", async ({ page, }, testInfo) => { await signInLearner(page, testInfo, "withdrawn"); const persona = await fetchAvailablePersona(page); const start = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic" }, }); expect(start.ok(), await start.text()).toBeTruthy(); const { session_id } = (await start.json()) as { session_id: string }; const withdrawn = await page.request.delete("/api/auth/consent"); expect(withdrawn.ok(), await withdrawn.text()).toBeTruthy(); await page.goto("/login"); const result = await openVoiceSocket( page, `/api/voice/ws?session_id=${encodeURIComponent(session_id)}`, ); const messages = parsedMessages(result); expect(result.code).toBe(1008); expect(messages).toContainEqual({ type: "error", detail: "consent_required" }); expect(messages.some((message) => message.type === "degraded")).toBeFalsy(); }); });