125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
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<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();
|
|
});
|
|
});
|