Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
115
apps/web/e2e/voice.spec.ts
Normal file
115
apps/web/e2e/voice.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue