diff --git a/src/lib/checkins/api-routes.test.ts b/src/lib/checkins/api-routes.test.ts new file mode 100644 index 00000000..c84312d0 --- /dev/null +++ b/src/lib/checkins/api-routes.test.ts @@ -0,0 +1,292 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserRole } from "@/types/tenant"; +import { formatUtcDateKey } from "@/lib/checkins/periods"; + +const { authMock } = vi.hoisted(() => ({ + authMock: vi.fn(), +})); + +vi.mock("@/auth", () => ({ + auth: authMock, +})); + +import { + GET as listCheckins, + POST as createCheckin, +} from "@/app/api/checkins/route"; +import { PATCH as patchCheckin } from "@/app/api/checkins/[id]/route"; +import { + GET as listAnswers, + POST as postAnswer, +} from "@/app/api/checkins/[id]/answers/route"; +import { + memoryCheckinsStore, + resetCheckinsMemoryForTests, +} from "./memory-adapter"; +import { resetCheckinsStore } from "./store"; +import { + createOverlayUnion, + resetTenantOverlayForTests, +} from "@/lib/tenant/overlay"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + roles: input?.roles ?? (["local_president"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const validCreate = { + question: "What did you move this week?", + cadence: "daily" as const, +}; + +describe("checkins API routes", () => { + beforeEach(() => { + resetCheckinsMemoryForTests(); + resetCheckinsStore(); + resetTenantOverlayForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetCheckinsMemoryForTests(); + resetCheckinsStore(); + resetTenantOverlayForTests(); + }); + + describe("GET/POST /api/checkins", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listCheckins()).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listCheckins(); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("does not list another union or another local for a president", async () => { + await memoryCheckinsStore.createSchedule( + { question: "Other union", cadence: "daily" }, + { + unionId: "union-other", + localId: "local-243", + createdById: "user-x", + createdByName: "X", + }, + ); + await memoryCheckinsStore.createSchedule( + { question: "Other local", cadence: "daily" }, + { + unionId: "union-opseu", + localId: "local-560", + createdById: "user-y", + createdByName: "Y", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listCheckins(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + schedules: Array<{ question: string; unionId: string; localId: string }>; + }; + expect(body.schedules.every((s) => s.unionId === "union-opseu")).toBe( + true, + ); + expect(body.schedules.every((s) => s.localId === "local-243")).toBe(true); + expect(body.schedules.map((s) => s.question)).not.toContain("Other union"); + expect(body.schedules.map((s) => s.question)).not.toContain("Other local"); + }); + + it("forbids a steward from creating a schedule", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const res = await createCheckin(jsonRequest(validCreate)); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Forbidden" }); + }); + + it("rejects forged tenant keys and stamps the session union/local/creator", async () => { + authMock.mockResolvedValue(session()); + const forged = await createCheckin( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + createdById: "user-attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createCheckin(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + schedule: { + unionId: string; + localId: string; + cadence: string; + createdById: string; + active: boolean; + }; + }; + expect(body.schedule.unionId).toBe("union-opseu"); + expect(body.schedule.localId).toBe("local-243"); + expect(body.schedule.createdById).toBe("user-president-243"); + expect(body.schedule.cadence).toBe("daily"); + expect(body.schedule.active).toBe(true); + }); + + it("returns 403 when the checkins module is off for that tenant", async () => { + const tenant = createOverlayUnion({ + name: "Comms Only Local", + enabledModules: ["comms", "grievance"], + localNumber: "888", + }); + authMock.mockResolvedValue( + session({ + id: "user-president-888", + unionId: tenant.union.id, + localId: tenant.locals![0]!.id, + }), + ); + const res = await listCheckins(); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Module not enabled" }); + }); + }); + + describe("GET/POST /api/checkins/[id]/answers", () => { + it("returns 404 for a missing id and for another union, including platform_admin", async () => { + const foreign = await memoryCheckinsStore.createSchedule( + { question: "Foreign check-in", cadence: "daily" }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-other", + createdByName: "Other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + expect( + ( + await listAnswers( + new Request("http://localhost/api/checkins/x/answers"), + params("checkin-missing"), + ) + ).status, + ).toBe(404); + + const viewed = await listAnswers( + new Request("http://localhost/api/checkins/x/answers"), + params(foreign.id), + ); + expect(viewed.status).toBe(404); + expect(await viewed.json()).toEqual({ error: "Not found" }); + + const posted = await postAnswer( + jsonRequest({ body: "Hijack" }), + params(foreign.id), + ); + expect(posted.status).toBe(404); + expect(await memoryCheckinsStore.listAnswers(foreign.id, formatUtcDateKey(new Date()))).toEqual( + [], + ); + }); + + it("lets a steward answer the current period once, then 409s", async () => { + authMock.mockResolvedValue(session()); + const created = await createCheckin(jsonRequest(validCreate)); + const schedule = ((await created.json()) as { schedule: { id: string } }) + .schedule; + + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const wrongPeriod = await postAnswer( + jsonRequest({ body: "Too old", periodKey: "1999-01-01" }), + params(schedule.id), + ); + expect(wrongPeriod.status).toBe(400); + expect(await wrongPeriod.json()).toEqual({ + error: "Answers are only accepted for the current period", + }); + + const first = await postAnswer( + jsonRequest({ body: "Filed two Step 1s." }), + params(schedule.id), + ); + expect(first.status).toBe(201); + const body = (await first.json()) as { + answer: { + authorId: string; + unionId: string; + localId: string; + body: string; + periodKey: string; + }; + }; + expect(body.answer.authorId).toBe("user-steward-243"); + expect(body.answer.unionId).toBe("union-opseu"); + expect(body.answer.localId).toBe("local-243"); + expect(body.answer.body).toBe("Filed two Step 1s."); + expect(body.answer.periodKey).toBe(formatUtcDateKey(new Date())); + + const retry = await postAnswer( + jsonRequest({ body: "Trying again" }), + params(schedule.id), + ); + expect(retry.status).toBe(409); + expect(await retry.json()).toEqual({ + error: "Already answered this period", + }); + }); + + it("rejects answers on an inactive schedule", async () => { + authMock.mockResolvedValue(session()); + const created = await createCheckin(jsonRequest(validCreate)); + const schedule = ((await created.json()) as { schedule: { id: string } }) + .schedule; + const patched = await patchCheckin( + jsonRequest({ active: false }), + params(schedule.id), + ); + expect(patched.status).toBe(200); + + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const res = await postAnswer( + jsonRequest({ body: "Still working" }), + params(schedule.id), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Check-in is inactive" }); + }); + }); +}); diff --git a/src/lib/checkins/memory-adapter.ts b/src/lib/checkins/memory-adapter.ts index 64b5f1f6..806ea7e6 100644 --- a/src/lib/checkins/memory-adapter.ts +++ b/src/lib/checkins/memory-adapter.ts @@ -14,51 +14,55 @@ function newId(prefix: string): string { const now = () => new Date().toISOString(); -const schedules: CheckinSchedule[] = [ - { - id: "checkin-sched-001", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-ft", - question: "What are you working on for the local this week?", - cadence: "weekly", - weekday: 1, - active: true, - createdById: "user-president-243", - createdByName: "Local 243 President", - createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "checkin-sched-002", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-ft", - question: "Any member issues or board questions that need a follow-up today?", - cadence: "weekdays", - active: true, - createdById: "user-president-243", - createdByName: "Local 243 President", - createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "checkin-sched-003", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-pt", - question: - "Were any part-time members skipped on this week's additional-hours list?", - cadence: "weekly", - weekday: 3, - active: true, - createdById: "user-president-243", - createdByName: "Local 243 President", - createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), - }, -]; +function seedSchedules(): CheckinSchedule[] { + return [ + { + id: "checkin-sched-001", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + question: "What are you working on for the local this week?", + cadence: "weekly", + weekday: 1, + active: true, + createdById: "user-president-243", + createdByName: "Local 243 President", + createdAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "checkin-sched-002", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + question: + "Any member issues or board questions that need a follow-up today?", + cadence: "weekdays", + active: true, + createdById: "user-president-243", + createdByName: "Local 243 President", + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "checkin-sched-003", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-pt", + question: + "Were any part-time members skipped on this week's additional-hours list?", + cadence: "weekly", + weekday: 3, + active: true, + createdById: "user-president-243", + createdByName: "Local 243 President", + createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), + }, + ]; +} +const schedules: CheckinSchedule[] = seedSchedules(); const answers: CheckinAnswer[] = []; export class MemoryCheckinsAdapter implements CheckinsAdapter { @@ -206,3 +210,9 @@ export class MemoryCheckinsAdapter implements CheckinsAdapter { } export const memoryCheckinsStore = new MemoryCheckinsAdapter(); + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetCheckinsMemoryForTests(): void { + schedules.splice(0, schedules.length, ...seedSchedules()); + answers.length = 0; +} diff --git a/src/lib/discussions/api-routes.test.ts b/src/lib/discussions/api-routes.test.ts new file mode 100644 index 00000000..7c69ac9f --- /dev/null +++ b/src/lib/discussions/api-routes.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserRole } from "@/types/tenant"; + +const { authMock } = vi.hoisted(() => ({ + authMock: vi.fn(), +})); + +vi.mock("@/auth", () => ({ + auth: authMock, +})); + +import { + GET as listThreads, + POST as createThread, +} from "@/app/api/discussions/route"; +import { + GET as listPosts, + POST as createPost, +} from "@/app/api/discussions/[id]/posts/route"; +import { POST as toggleReaction } from "@/app/api/discussions/[id]/posts/[postId]/reactions/route"; +import { + memoryDiscussionsStore, + resetMemoryDiscussions, +} from "./memory-adapter"; +import { resetDiscussionsStore } from "./store"; +import { + createOverlayUnion, + resetTenantOverlayForTests, +} from "@/lib/tenant/overlay"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + roles: input?.roles ?? (["local_president"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/discussions${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +function reactionParams(id: string, postId: string) { + return { params: Promise.resolve({ id, postId }) }; +} + +const validCreate = { + title: "Board duty rotation", + body: "Who can cover Thursday evening?", +}; + +describe("discussions API routes", () => { + beforeEach(() => { + resetMemoryDiscussions(); + resetDiscussionsStore(); + resetTenantOverlayForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetMemoryDiscussions(); + resetDiscussionsStore(); + resetTenantOverlayForTests(); + }); + + describe("GET/POST /api/discussions", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listThreads(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listThreads(listRequest()); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("does not list another union or another local for a president", async () => { + await memoryDiscussionsStore.createThread( + { title: "Other union", body: "Must never appear" }, + { + unionId: "union-other", + localId: "local-243", + createdById: "user-x", + createdByName: "X", + }, + ); + await memoryDiscussionsStore.createThread( + { title: "Other local", body: "Same union, other local" }, + { + unionId: "union-opseu", + localId: "local-560", + createdById: "user-y", + createdByName: "Y", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listThreads(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + threads: Array<{ title: string; unionId: string; localId: string }>; + }; + expect(body.threads.every((t) => t.unionId === "union-opseu")).toBe(true); + expect(body.threads.every((t) => t.localId === "local-243")).toBe(true); + expect(body.threads.map((t) => t.title)).not.toContain("Other union"); + expect(body.threads.map((t) => t.title)).not.toContain("Other local"); + }); + + it("rejects forged tenant keys and stamps the session union/local/creator", async () => { + authMock.mockResolvedValue(session()); + const forged = await createThread( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + createdById: "user-attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createThread(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + thread: { + unionId: string; + localId: string; + title: string; + createdById: string; + postCount: number; + }; + }; + expect(body.thread.unionId).toBe("union-opseu"); + expect(body.thread.localId).toBe("local-243"); + expect(body.thread.title).toBe("Board duty rotation"); + expect(body.thread.createdById).toBe("user-president-243"); + expect(body.thread.postCount).toBe(1); + }); + + it("returns 400 when a thread tries to link both a grievance and a bumping case", async () => { + authMock.mockResolvedValue(session()); + const res = await createThread( + jsonRequest({ + ...validCreate, + grievanceId: "grev-001", + bumpingCaseId: "bump-001", + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 403 when the discussions module is off for that tenant", async () => { + const tenant = createOverlayUnion({ + name: "Comms Only Local", + enabledModules: ["comms", "grievance"], + localNumber: "888", + }); + authMock.mockResolvedValue( + session({ + id: "user-president-888", + unionId: tenant.union.id, + localId: tenant.locals![0]!.id, + }), + ); + const res = await listThreads(listRequest()); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Module not enabled" }); + }); + }); + + describe("GET/POST /api/discussions/[id]/posts and reactions", () => { + it("returns 404 for a missing thread and 403 for another union, including platform_admin", async () => { + const foreign = await memoryDiscussionsStore.createThread( + { title: "Foreign thread", body: "Keep out" }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-other", + createdByName: "Other", + }, + ); + const foreignPosts = await memoryDiscussionsStore.listPosts(foreign.id); + const foreignPostId = foreignPosts[0]!.id; + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + expect( + ( + await listPosts( + new Request("http://localhost"), + params("disc-missing"), + ) + ).status, + ).toBe(404); + + const viewed = await listPosts( + new Request("http://localhost"), + params(foreign.id), + ); + expect(viewed.status).toBe(403); + expect(await viewed.json()).toEqual({ error: "Forbidden" }); + + const posted = await createPost( + jsonRequest({ body: "Hijack" }), + params(foreign.id), + ); + expect(posted.status).toBe(403); + expect((await memoryDiscussionsStore.listPosts(foreign.id)).length).toBe(1); + + const reacted = await toggleReaction( + jsonRequest({ kind: "ack" }), + reactionParams(foreign.id, foreignPostId), + ); + expect(reacted.status).toBe(403); + expect( + (await memoryDiscussionsStore.getPost(foreignPostId))?.reactions, + ).toEqual([]); + }); + + it("lets a steward post on a standalone thread and toggle a reaction", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const posted = await createPost( + jsonRequest({ body: "I can cover Thursday." }), + params("disc-thread-001"), + ); + expect(posted.status).toBe(201); + const body = (await posted.json()) as { + post: { authorId: string; unionId: string; body: string }; + }; + expect(body.post.authorId).toBe("user-steward-243"); + expect(body.post.unionId).toBe("union-opseu"); + expect(body.post.body).toBe("I can cover Thursday."); + + const extraKeys = await toggleReaction( + jsonRequest({ kind: "ack", userId: "user-attacker" }), + reactionParams("disc-thread-001", "disc-post-001"), + ); + expect(extraKeys.status).toBe(400); + + const reacted = await toggleReaction( + jsonRequest({ kind: "ack" }), + reactionParams("disc-thread-001", "disc-post-001"), + ); + expect(reacted.status).toBe(200); + const reactionBody = (await reacted.json()) as { + post: { reactions: Array<{ kind: string; userId: string }> }; + }; + expect(reactionBody.post.reactions).toEqual( + expect.arrayContaining([ + { kind: "ack", userId: "user-steward-243" }, + ]), + ); + + const listed = await listPosts( + new Request("http://localhost"), + params("disc-thread-001"), + ); + expect(listed.status).toBe(200); + const listedBody = (await listed.json()) as { + posts: Array<{ body: string }>; + }; + expect(listedBody.posts.some((p) => p.body === "I can cover Thursday.")).toBe( + true, + ); + }); + + it("returns 404 when the post does not belong to the thread", async () => { + authMock.mockResolvedValue(session()); + const res = await toggleReaction( + jsonRequest({ kind: "solidarity" }), + reactionParams("disc-thread-001", "disc-post-003"), + ); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/src/lib/discussions/memory-adapter.ts b/src/lib/discussions/memory-adapter.ts index 4433fa11..b48076d5 100644 --- a/src/lib/discussions/memory-adapter.ts +++ b/src/lib/discussions/memory-adapter.ts @@ -24,101 +24,112 @@ function emptyPostFields(ts: string) { }; } -const threads: DiscussionThread[] = [ - { - id: "disc-thread-001", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-ft", - title: "Local business — next membership meeting prep", - body: "Thread for officers to coordinate agenda items ahead of the next local meeting. Keep member PII out of posts.", - createdById: "user-president-243", - createdByName: "Local 243 President", - createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), - lastPostAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), - postCount: 2, - }, - { - id: "disc-thread-002", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-ft", - title: "Grievance grev-001 — Step 1 strategy", - body: "Internal discussion linked to Member A grievance. Visible only to officers who can view that grievance.", - grievanceId: "grev-001", - createdById: "user-steward-243", - createdByName: "Local 243 Steward", - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), - lastPostAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), - postCount: 1, - }, - { - id: "disc-thread-003", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-pt", - title: "Grievance grev-002 — Step 2 discipline (PT Support)", - body: "Internal discussion linked to Member B. PT Support CA timelines (7 / 14 / 21), not the full-time steps. Keep member PII out of posts.", - grievanceId: "grev-002", - createdById: "user-steward-243-pt", - createdByName: "Local 243 Steward (PT)", - createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), - lastPostAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), - postCount: 1, - }, -]; +function seedThreads(): DiscussionThread[] { + return [ + { + id: "disc-thread-001", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + title: "Local business — next membership meeting prep", + body: "Thread for officers to coordinate agenda items ahead of the next local meeting. Keep member PII out of posts.", + createdById: "user-president-243", + createdByName: "Local 243 President", + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + lastPostAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + postCount: 2, + }, + { + id: "disc-thread-002", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + title: "Grievance grev-001 — Step 1 strategy", + body: "Internal discussion linked to Member A grievance. Visible only to officers who can view that grievance.", + grievanceId: "grev-001", + createdById: "user-steward-243", + createdByName: "Local 243 Steward", + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + lastPostAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + postCount: 1, + }, + { + id: "disc-thread-003", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-pt", + title: "Grievance grev-002 — Step 2 discipline (PT Support)", + body: "Internal discussion linked to Member B. PT Support CA timelines (7 / 14 / 21), not the full-time steps. Keep member PII out of posts.", + grievanceId: "grev-002", + createdById: "user-steward-243-pt", + createdByName: "Local 243 Steward (PT)", + createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + lastPostAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + postCount: 1, + }, + ]; +} + +function seedPosts(): DiscussionPost[] { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(); + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + return [ + { + id: "disc-post-001", + threadId: "disc-thread-001", + unionId: "union-opseu", + localId: "local-243", + authorId: "user-president-243", + authorName: "Local 243 President", + body: "Please add any standing items by Friday. Treasurer update and grievance overview are already on the draft.", + createdAt: twoDaysAgo, + ...emptyPostFields(twoDaysAgo), + }, + { + id: "disc-post-002", + threadId: "disc-thread-001", + unionId: "union-opseu", + localId: "local-243", + authorId: "user-steward-243", + authorName: "Local 243 Steward (FT)", + body: "Can we slot 10 minutes for duty-bank clarification? @Local 243 President — members have been asking at the board.", + createdAt: oneDayAgo, + updatedAt: oneDayAgo, + mentionedUserIds: ["user-president-243"], + reactions: [{ kind: "solidarity", userId: "user-president-243" }], + }, + { + id: "disc-post-003", + threadId: "disc-thread-002", + unionId: "union-opseu", + localId: "local-243", + authorId: "user-steward-243", + authorName: "Local 243 Steward", + body: "Management response is overdue — drafting escalation checklist next.", + createdAt: sixHoursAgo, + ...emptyPostFields(sixHoursAgo), + }, + { + id: "disc-post-004", + threadId: "disc-thread-003", + unionId: "union-opseu", + localId: "local-243", + authorId: "user-steward-243-pt", + authorName: "Local 243 Steward (PT)", + body: "Step 1 used the 7-working-day PT clock. Employer denied. Escalating to Step 2 (14 working days). Member needs an evening slot — they were skipped on Saturday additional hours the same week.", + createdAt: threeHoursAgo, + ...emptyPostFields(threeHoursAgo), + }, + ]; +} -const posts: DiscussionPost[] = [ - { - id: "disc-post-001", - threadId: "disc-thread-001", - unionId: "union-opseu", - localId: "local-243", - authorId: "user-president-243", - authorName: "Local 243 President", - body: "Please add any standing items by Friday. Treasurer update and grievance overview are already on the draft.", - createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - ...emptyPostFields(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()), - }, - { - id: "disc-post-002", - threadId: "disc-thread-001", - unionId: "union-opseu", - localId: "local-243", - authorId: "user-steward-243", - authorName: "Local 243 Steward (FT)", - body: "Can we slot 10 minutes for duty-bank clarification? @Local 243 President — members have been asking at the board.", - createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), - updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), - mentionedUserIds: ["user-president-243"], - reactions: [{ kind: "solidarity", userId: "user-president-243" }], - }, - { - id: "disc-post-003", - threadId: "disc-thread-002", - unionId: "union-opseu", - localId: "local-243", - authorId: "user-steward-243", - authorName: "Local 243 Steward", - body: "Management response is overdue — drafting escalation checklist next.", - createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), - ...emptyPostFields(new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString()), - }, - { - id: "disc-post-004", - threadId: "disc-thread-003", - unionId: "union-opseu", - localId: "local-243", - authorId: "user-steward-243-pt", - authorName: "Local 243 Steward (PT)", - body: "Step 1 used the 7-working-day PT clock. Employer denied. Escalating to Step 2 (14 working days). Member needs an evening slot — they were skipped on Saturday additional hours the same week.", - createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), - ...emptyPostFields(new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()), - }, -]; +const threads: DiscussionThread[] = seedThreads(); +const posts: DiscussionPost[] = seedPosts(); function touchThread(thread: DiscussionThread, ts: string) { thread.updatedAt = ts; @@ -273,8 +284,8 @@ export class MemoryDiscussionsAdapter implements DiscussionsAdapter { export const memoryDiscussionsStore = new MemoryDiscussionsAdapter(); -/** @internal test helper */ +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ export function resetMemoryDiscussions(): void { - threads.length = 3; - posts.length = 4; + threads.splice(0, threads.length, ...seedThreads()); + posts.splice(0, posts.length, ...seedPosts()); } diff --git a/src/lib/polls/api-routes.test.ts b/src/lib/polls/api-routes.test.ts new file mode 100644 index 00000000..1fd7afdf --- /dev/null +++ b/src/lib/polls/api-routes.test.ts @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserRole } from "@/types/tenant"; + +const { authMock } = vi.hoisted(() => ({ + authMock: vi.fn(), +})); + +vi.mock("@/auth", () => ({ + auth: authMock, +})); + +import { + GET as listPolls, + POST as createPoll, +} from "@/app/api/polls/route"; +import { + GET as getPoll, + PATCH as patchPoll, +} from "@/app/api/polls/id/[id]/route"; +import { GET as exportPoll } from "@/app/api/polls/id/[id]/export/route"; +import { POST as submitPollResponse } from "@/app/api/polls/[slug]/responses/route"; +import { + memoryPollsStore, + resetMemoryPollsStore, +} from "./memory-adapter"; +import { resetPollsStore } from "./store"; +import { resetPollSubmitRateLimit } from "./rate-limit"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + roles: input?.roles ?? (["local_president"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/polls${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +function slugParams(slug: string) { + return { params: Promise.resolve({ slug }) }; +} + +function publicSubmitRequest(body: unknown, ip = "203.0.113.10"): Request { + return new Request("http://localhost/api/polls/meeting-rsvp/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": ip, + }, + body: JSON.stringify(body), + }); +} + +const validCreate = { + slug: "meeting-rsvp", + title: "Membership meeting RSVP", + questions: [ + { + id: "q1", + text: "Will you attend?", + type: "single_choice" as const, + options: ["Yes", "No"], + }, + ], +}; + +async function seedPoll(input?: { + unionId?: string; + localId?: string; + slug?: string; + status?: "open" | "closed"; + createdById?: string; +}) { + return memoryPollsStore.create( + { + ...validCreate, + slug: input?.slug ?? `poll-${Math.random().toString(36).slice(2, 8)}`, + status: input?.status ?? "open", + }, + { + unionId: input?.unionId ?? "union-opseu", + localId: input?.localId ?? "local-243", + createdById: input?.createdById ?? "user-president-243", + }, + ); +} + +describe("polls API routes", () => { + beforeEach(() => { + resetMemoryPollsStore(); + resetPollsStore(); + resetPollSubmitRateLimit(); + authMock.mockReset(); + }); + + afterEach(() => { + resetMemoryPollsStore(); + resetPollsStore(); + resetPollSubmitRateLimit(); + }); + + describe("GET/POST /api/polls", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listPolls(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listPolls(listRequest()); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + expect( + (await createPoll(jsonRequest(validCreate))).status, + ).toBe(403); + }); + + it("does not list another union or another local for a president", async () => { + await seedPoll({ slug: "same-local", unionId: "union-opseu", localId: "local-243" }); + await seedPoll({ + slug: "other-union", + unionId: "union-other", + localId: "local-243", + }); + await seedPoll({ + slug: "other-local", + unionId: "union-opseu", + localId: "local-560", + }); + + authMock.mockResolvedValue(session()); + const res = await listPolls(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + polls: Array<{ slug: string; unionId: string; localId: string }>; + }; + expect(body.polls.every((p) => p.unionId === "union-opseu")).toBe(true); + expect(body.polls.every((p) => p.localId === "local-243")).toBe(true); + expect(body.polls.map((p) => p.slug)).toEqual(["same-local"]); + }); + + it("ignores an unknown status query", async () => { + await seedPoll({ slug: "open-poll", status: "open" }); + await seedPoll({ slug: "closed-poll", status: "closed" }); + authMock.mockResolvedValue(session()); + const res = await listPolls(listRequest("?status=draft")); + expect(res.status).toBe(200); + const body = (await res.json()) as { polls: Array<{ slug: string }> }; + expect(body.polls.map((p) => p.slug).sort()).toEqual([ + "closed-poll", + "open-poll", + ]); + }); + + it("rejects forged tenant keys and stamps the session union/local/creator", async () => { + authMock.mockResolvedValue(session()); + const forged = await createPoll( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + createdById: "user-attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createPoll(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + poll: { + unionId: string; + localId: string; + slug: string; + createdById: string; + status: string; + }; + }; + expect(body.poll.unionId).toBe("union-opseu"); + expect(body.poll.localId).toBe("local-243"); + expect(body.poll.slug).toBe("meeting-rsvp"); + expect(body.poll.createdById).toBe("user-president-243"); + expect(body.poll.status).toBe("open"); + }); + + it("returns 409 when the slug is already in use", async () => { + authMock.mockResolvedValue(session()); + expect((await createPoll(jsonRequest(validCreate))).status).toBe(201); + const dup = await createPoll(jsonRequest(validCreate)); + expect(dup.status).toBe(409); + expect(await dup.json()).toEqual({ error: "Slug already in use" }); + }); + }); + + describe("GET/PATCH/export /api/polls/id/[id]", () => { + it("returns 404 for a missing id and for another union, including platform_admin", async () => { + const foreign = await seedPoll({ + unionId: "union-other", + localId: "local-1", + slug: "foreign-poll", + }); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + + expect( + (await getPoll(new Request("http://localhost"), params("poll-missing"))) + .status, + ).toBe(404); + + const viewed = await getPoll( + new Request("http://localhost"), + params(foreign.id), + ); + expect(viewed.status).toBe(404); + expect(await viewed.json()).toEqual({ error: "Not found" }); + + const patched = await patchPoll( + jsonRequest({ status: "closed" }), + params(foreign.id), + ); + expect(patched.status).toBe(404); + expect((await memoryPollsStore.getById(foreign.id))?.status).toBe("open"); + + const exported = await exportPoll( + new Request("http://localhost/api/polls/id/x/export"), + params(foreign.id), + ); + expect(exported.status).toBe(404); + }); + + it("exports CSV for a same-local poll and closes it on PATCH", async () => { + const poll = await seedPoll({ slug: "export-me" }); + await memoryPollsStore.submitResponse( + poll.id, + { answers: { q1: "Yes" }, consentAccepted: true }, + {}, + ); + + authMock.mockResolvedValue(session()); + const csv = await exportPoll( + new Request("http://localhost/api/polls/id/x/export?format=csv"), + params(poll.id), + ); + expect(csv.status).toBe(200); + expect(csv.headers.get("content-type")).toContain("text/csv"); + expect(await csv.text()).toContain("Will you attend?"); + + const closed = await patchPoll( + jsonRequest({ status: "closed" }), + params(poll.id), + ); + expect(closed.status).toBe(200); + expect((await memoryPollsStore.getById(poll.id))?.status).toBe("closed"); + }); + }); + + describe("POST /api/polls/[slug]/responses", () => { + it("returns 404 for a missing or closed poll without revealing which", async () => { + await seedPoll({ slug: "closed-now", status: "closed" }); + const missing = await submitPollResponse( + publicSubmitRequest({ + answers: { q1: "Yes" }, + consentAccepted: true, + }), + slugParams("no-such-poll"), + ); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: "Poll not found" }); + + const closed = await submitPollResponse( + publicSubmitRequest({ + answers: { q1: "Yes" }, + consentAccepted: true, + }), + slugParams("closed-now"), + ); + expect(closed.status).toBe(404); + expect(await closed.json()).toEqual({ error: "Poll not found" }); + }); + + it("requires consent, rejects invalid options, then records an anonymous response", async () => { + await seedPoll({ slug: "meeting-rsvp" }); + + const noConsent = await submitPollResponse( + publicSubmitRequest({ + answers: { q1: "Yes" }, + consentAccepted: false, + }), + slugParams("meeting-rsvp"), + ); + expect(noConsent.status).toBe(400); + expect(await noConsent.json()).toEqual({ error: "Consent required" }); + + const badOption = await submitPollResponse( + publicSubmitRequest({ + answers: { q1: "Maybe" }, + consentAccepted: true, + }), + slugParams("meeting-rsvp"), + ); + expect(badOption.status).toBe(400); + + const ok = await submitPollResponse( + publicSubmitRequest({ + answers: { q1: "Yes" }, + consentAccepted: true, + }), + slugParams("meeting-rsvp"), + ); + expect(ok.status).toBe(201); + const body = (await ok.json()) as { + ok: boolean; + responseId: string; + ipHash?: string; + }; + expect(body.ok).toBe(true); + expect(body.responseId).toBeTruthy(); + expect(body.ipHash).toBeUndefined(); + + const stored = await memoryPollsStore.listResponses( + (await memoryPollsStore.getBySlug("meeting-rsvp"))!.id, + ); + expect(stored).toHaveLength(1); + expect(stored[0]?.answers.q1).toBe("Yes"); + expect(stored[0]?.ipHash).toBeTruthy(); + expect(stored[0]?.ipHash).not.toBe("203.0.113.10"); + }); + + it("returns 429 after the per-IP submit window", async () => { + await seedPoll({ slug: "meeting-rsvp" }); + const payload = { answers: { q1: "Yes" }, consentAccepted: true }; + for (let i = 0; i < 8; i += 1) { + expect( + ( + await submitPollResponse( + publicSubmitRequest(payload, "198.51.100.9"), + slugParams("meeting-rsvp"), + ) + ).status, + ).toBe(201); + } + const blocked = await submitPollResponse( + publicSubmitRequest(payload, "198.51.100.9"), + slugParams("meeting-rsvp"), + ); + expect(blocked.status).toBe(429); + }); + }); +}); diff --git a/src/lib/polls/rate-limit.test.ts b/src/lib/polls/rate-limit.test.ts new file mode 100644 index 00000000..85237c74 --- /dev/null +++ b/src/lib/polls/rate-limit.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + checkPollSubmitRateLimit, + extractClientIp, + hashClientIp, + resetPollSubmitRateLimit, +} from "./rate-limit"; + +describe("poll submit rate limit", () => { + afterEach(() => { + resetPollSubmitRateLimit(); + }); + + it("reads the first forwarded IP, then x-real-ip, then unknown", () => { + expect( + extractClientIp( + new Request("http://localhost", { + headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.1" }, + }), + ), + ).toBe("203.0.113.10"); + expect( + extractClientIp( + new Request("http://localhost", { + headers: { "x-real-ip": "198.51.100.20" }, + }), + ), + ).toBe("198.51.100.20"); + expect(extractClientIp(new Request("http://localhost"))).toBe("unknown"); + }); + + it("hashes the IP rather than storing it, and trips after 8 submits", () => { + const hash = hashClientIp("203.0.113.10", "test-salt"); + expect(hash).not.toBe("203.0.113.10"); + expect(hash).toHaveLength(64); + + for (let i = 0; i < 8; i += 1) { + expect(checkPollSubmitRateLimit("ip-a")).toBe(true); + } + expect(checkPollSubmitRateLimit("ip-a")).toBe(false); + expect(checkPollSubmitRateLimit("ip-b")).toBe(true); + + resetPollSubmitRateLimit(); + expect(checkPollSubmitRateLimit("ip-a")).toBe(true); + }); +}); diff --git a/src/lib/validation/polls.test.ts b/src/lib/validation/polls.test.ts new file mode 100644 index 00000000..4aaf8c65 --- /dev/null +++ b/src/lib/validation/polls.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonBody } from "@/lib/validation/parse"; +import { + createPollSchema, + submitPollResponseSchema, + updatePollSchema, +} from "@/lib/validation/polls"; + +const validQuestion = { + id: "q1", + text: "Will you attend?", + type: "single_choice" as const, + options: ["Yes", "No"], +}; + +const validCreate = { + slug: "meeting-rsvp", + title: "Membership meeting RSVP", + questions: [validQuestion], +}; + +describe("polls request schemas", () => { + it("rejects tenant identity keys on create", () => { + expect(parseJsonBody(createPollSchema, validCreate).ok).toBe(true); + expect( + parseJsonBody(createPollSchema, { + ...validCreate, + unionId: "union-other", + localId: "local-evil", + }).ok, + ).toBe(false); + }); + + it("rejects single_choice without two options and extra update keys", () => { + expect( + parseJsonBody(createPollSchema, { + ...validCreate, + questions: [{ id: "q1", text: "Attend?", type: "single_choice" }], + }).ok, + ).toBe(false); + expect(parseJsonBody(updatePollSchema, { status: "closed" }).ok).toBe(true); + expect( + parseJsonBody(updatePollSchema, { + status: "closed", + unionId: "union-other", + }).ok, + ).toBe(false); + }); + + it("rejects extra keys on public submit", () => { + expect( + parseJsonBody(submitPollResponseSchema, { + answers: { q1: "Yes" }, + consentAccepted: true, + }).ok, + ).toBe(true); + expect( + parseJsonBody(submitPollResponseSchema, { + answers: { q1: "Yes" }, + consentAccepted: true, + ip: "1.2.3.4", + }).ok, + ).toBe(false); + }); +});