diff --git a/PROJECT_AUDIT_2026-03-03.md b/PROJECT_AUDIT_2026-03-03.md index b3a253e..5b7fe0e 100644 --- a/PROJECT_AUDIT_2026-03-03.md +++ b/PROJECT_AUDIT_2026-03-03.md @@ -103,7 +103,13 @@ ## 中优问题(P2) -### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`) +### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`)【已完成】 +- 修复状态:✅ 已完成(2026-03-03) +- 修复内容: + - `useRoomPolling` 改为显式接收 `userId`,SSE 连接带上 `?userId=...`; + - `room/[id]/page` 传入当前用户 ID; + - `GET /api/room/[id]/events` 强制 `userId` 必填并做成员校验(无 userId 返回 401,非成员返回 403); + - 更新 hook 测试覆盖 SSE URL 与缺失 userId 场景。 - 证据: - `src/app/api/room/[id]/events/route.ts:14-18`(仅在 query 存在 `userId` 时校验) - `src/hooks/useRoomPolling.ts:32`(实际建立 SSE 连接时未携带 `userId`) diff --git a/src/app/api/room/[id]/events/route.ts b/src/app/api/room/[id]/events/route.ts index 01d4108..a1bd85d 100644 --- a/src/app/api/room/[id]/events/route.ts +++ b/src/app/api/room/[id]/events/route.ts @@ -12,15 +12,25 @@ export async function GET( const url = new URL(req.url); const userId = url.searchParams.get("userId"); + if (!userId) { + return new Response(JSON.stringify({ error: "missing_user_id" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } - if (userId) { - const data = await getRoomData(id); - if (data && !data.users.includes(userId)) { - return new Response(JSON.stringify({ error: "not_a_member" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } + const data = await getRoomData(id); + if (!data) { + return new Response(JSON.stringify({ error: "room_not_found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + if (!data.users.includes(userId)) { + return new Response(JSON.stringify({ error: "not_a_member" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); } const encoder = new TextEncoder(); diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index 393adb7..0b36f9e 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -28,7 +28,7 @@ export default function RoomPage() { const { userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, notFound, mutate, creatorId, locked, users, userProfiles, scene, - } = useRoomPolling(roomId); + } = useRoomPolling(roomId, userId || undefined); useEffect(() => { if (!roomId) return; diff --git a/src/hooks/useRoomPolling.test.ts b/src/hooks/useRoomPolling.test.ts index caed6d4..90b0d1a 100644 --- a/src/hooks/useRoomPolling.test.ts +++ b/src/hooks/useRoomPolling.test.ts @@ -1,15 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; -let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType }[] = []; +let esInstances: { + url: string; + onmessage?: (e: MessageEvent) => void; + onerror?: () => void; + onopen?: () => void; + close: ReturnType; +}[] = []; class MockEventSource { - onmessage: ((e: MessageEvent) => void) | null = null; - onerror: (() => void) | null = null; - onopen: (() => void) | null = null; + url: string; + onmessage: ((e: MessageEvent) => void) | undefined; + onerror: (() => void) | undefined; + onopen: (() => void) | undefined; close = vi.fn(); - constructor(_url: string) { + constructor(url: string) { + this.url = url; esInstances.push(this); } } @@ -52,7 +60,7 @@ afterEach(() => { describe("useRoomPolling", () => { it("returns room data from SWR", () => { - const { result } = renderHook(() => useRoomPolling("ROOM01")); + const { result } = renderHook(() => useRoomPolling("ROOM01", "user-1")); expect(result.current.userCount).toBe(2); expect(result.current.users).toEqual(["user-1", "user-2"]); @@ -60,12 +68,18 @@ describe("useRoomPolling", () => { }); it("creates EventSource connection", () => { - renderHook(() => useRoomPolling("ROOM01")); + renderHook(() => useRoomPolling("ROOM01", "user-1")); expect(esInstances.length).toBeGreaterThan(0); + expect(esInstances[0].url).toContain("/api/room/ROOM01/events?userId=user-1"); + }); + + it("does not create EventSource when userId is missing", () => { + renderHook(() => useRoomPolling("ROOM01")); + expect(esInstances.length).toBe(0); }); it("returns defaults when no roomId", () => { - const { result } = renderHook(() => useRoomPolling(undefined)); + const { result } = renderHook(() => useRoomPolling(undefined, "user-1")); expect(result.current.userCount).toBe(2); }); @@ -78,12 +92,12 @@ describe("useRoomPolling", () => { mutate: vi.fn(), } as never); - const { result } = renderHook(() => useRoomPolling("ROOM01")); + const { result } = renderHook(() => useRoomPolling("ROOM01", "user-1")); expect(result.current.notFound).toBe(true); }); it("cleans up EventSource on unmount", () => { - const { unmount } = renderHook(() => useRoomPolling("ROOM01")); + const { unmount } = renderHook(() => useRoomPolling("ROOM01", "user-1")); const es = esInstances[esInstances.length - 1]; unmount(); expect(es.close).toHaveBeenCalled(); diff --git a/src/hooks/useRoomPolling.ts b/src/hooks/useRoomPolling.ts index d54820a..5caa639 100644 --- a/src/hooks/useRoomPolling.ts +++ b/src/hooks/useRoomPolling.ts @@ -10,7 +10,10 @@ function backoffMs(attempt: number): number { return Math.min(2000 * Math.pow(2, attempt) + Math.random() * 500, 60_000); } -export function useRoomPolling(roomId: string | undefined) { +export function useRoomPolling( + roomId: string | undefined, + userId?: string, +) { const { data, error, isLoading, mutate } = useSWR( roomId ? `/api/room/${roomId}` : null, fetcher, @@ -28,8 +31,10 @@ export function useRoomPolling(roomId: string | undefined) { const notFound = error?.message === "NOT_FOUND"; useEffect(() => { - if (!roomId) return; - const es = new EventSource(`/api/room/${roomId}/events`); + if (!roomId || !userId) return; + const es = new EventSource( + `/api/room/${roomId}/events?userId=${encodeURIComponent(userId)}`, + ); const clearFallback = () => { if (fallbackRef.current) { @@ -68,7 +73,7 @@ export function useRoomPolling(roomId: string | undefined) { es.close(); clearFallback(); }; - }, [roomId, mutate]); + }, [roomId, userId, mutate]); return { userCount: data?.userCount ?? 0,