Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

115
apps/web/e2e/voice.spec.ts Normal file
View file

@ -0,0 +1,115 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { 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();
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {
return page.evaluate(
({ wsPath }) =>
new Promise<WsResult>((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();
});
});