From c54e9180466ade42ff96f073a37b3bddfc553b16 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Thu, 16 Jul 2026 00:20:36 +0800 Subject: [PATCH] fix(subscriptions): reject unscoped batch replacement --- docs/contracts/subscription.json | 2 +- .../api/schemas/request-user-mutation-schemas.ts | 13 ++++++++++++- .../src/app/api/calendar-subscriptions/test.ts | 16 ++++++++++++++++ tests/unit/api-schemas.test.ts | 13 +++++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/contracts/subscription.json b/docs/contracts/subscription.json index 3231f74ce..d47762916 100644 --- a/docs/contracts/subscription.json +++ b/docs/contracts/subscription.json @@ -79,7 +79,7 @@ "notes": [ "Preferred mutation endpoint for batch subscribe, unsubscribe, and replace/update flows.", "Accepts action plus sectionIds and/or mixed section/course codes.", - "action=set replaces only the requested semester when semesterId is present, preserving subscriptions from every other semester; without semesterId it retains the compatibility behavior of replacing the full set.", + "action=set requires semesterId and replaces only the requested semester, preserving subscriptions from every other semester.", "A semester-scoped action=set may omit sectionIds/codes to clear only that semester." ] }, diff --git a/src/lib/api/schemas/request-user-mutation-schemas.ts b/src/lib/api/schemas/request-user-mutation-schemas.ts index 849023e21..b25a7b9a9 100644 --- a/src/lib/api/schemas/request-user-mutation-schemas.ts +++ b/src/lib/api/schemas/request-user-mutation-schemas.ts @@ -63,7 +63,18 @@ export const calendarSubscriptionBatchRequestSchema = action: calendarSubscriptionBatchActionSchema, }) .superRefine((input, context) => { - if (input.action === "set" || hasCalendarSubscriptionSelection(input)) { + if (input.action === "set") { + if (input.semesterId === undefined) { + context.addIssue({ + code: "custom", + message: "semesterId is required when action is set", + path: ["semesterId"], + }); + } + return; + } + + if (hasCalendarSubscriptionSelection(input)) { return; } diff --git a/tests/e2e/src/app/api/calendar-subscriptions/test.ts b/tests/e2e/src/app/api/calendar-subscriptions/test.ts index 68013951d..ad64cfb80 100644 --- a/tests/e2e/src/app/api/calendar-subscriptions/test.ts +++ b/tests/e2e/src/app/api/calendar-subscriptions/test.ts @@ -241,6 +241,22 @@ test.describe("日历订阅 API", () => { data: { sectionIds: [currentSection.id, previousSection.id] }, }); expect(setupResponse.status()).toBe(200); + + const unscopedSetResponse = await page.request.post(BATCH_BASE, { + data: { action: "set", sectionIds: [] }, + }); + expect(unscopedSetResponse.status()).toBe(400); + const preservedBody = (await ( + await page.request.get("/api/calendar-subscriptions/current") + ).json()) as { + subscription?: { sections?: Array<{ id?: number }> } | null; + }; + const preservedIds = + preservedBody.subscription?.sections?.map((section) => section.id) ?? + []; + expect(preservedIds).toContain(currentSection.id); + expect(preservedIds).toContain(previousSection.id); + const currentSemesterId = currentSection.semesterId; const response = await page.request.post(BATCH_BASE, { diff --git a/tests/unit/api-schemas.test.ts b/tests/unit/api-schemas.test.ts index d8ad67880..310bceda6 100644 --- a/tests/unit/api-schemas.test.ts +++ b/tests/unit/api-schemas.test.ts @@ -452,9 +452,18 @@ describe("其他请求 schema", () => { }).success, ).toBe(true); expect( - calendarSubscriptionBatchRequestSchema.safeParse({ action: "set" }) - .success, + calendarSubscriptionBatchRequestSchema.safeParse({ + action: "set", + semesterId: 12, + }).success, ).toBe(true); + const unscopedSet = calendarSubscriptionBatchRequestSchema.safeParse({ + action: "set", + }); + expect(unscopedSet.success).toBe(false); + if (!unscopedSet.success) { + expect(unscopedSet.error.issues[0]?.path).toEqual(["semesterId"]); + } expect( calendarSubscriptionBatchRequestSchema.safeParse({ action: "remove" }) .success,