diff --git a/src/app/api/snippets/[id]/route.ts b/src/app/api/snippets/[id]/route.ts index fe16833c..41eaa9de 100644 --- a/src/app/api/snippets/[id]/route.ts +++ b/src/app/api/snippets/[id]/route.ts @@ -3,6 +3,7 @@ import { auditLog } from "@/lib/audit/store"; import { requireGrievanceSession } from "@/lib/auth/grievance-session"; import { canDeleteSharedContent, canManageQolContent } from "@/lib/qol/access"; import { snippetStore } from "@/lib/snippets/memory-adapter"; +import type { UpdateCaSnippetInput } from "@/types/qol"; import type { UserRole } from "@/types/tenant"; type RouteContext = { params: Promise<{ id: string }> }; @@ -51,8 +52,22 @@ export async function PATCH(request: Request, context: RouteContext) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const body = await request.json(); - const updated = await snippetStore.update(id, body); + const raw = await request.json(); + const body = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + const patch: UpdateCaSnippetInput = {}; + if (typeof body.title === "string") patch.title = body.title; + if (typeof body.clauseRef === "string") patch.clauseRef = body.clauseRef; + if (typeof body.body === "string") patch.body = body.body; + if ( + Array.isArray(body.tags) && + body.tags.every((tag) => typeof tag === "string") + ) { + patch.tags = body.tags; + } + const updated = await snippetStore.update(id, patch); await auditLog.log({ userId: authResult.session.user.id, action: "snippet.update", diff --git a/src/lib/snippets/api-routes.test.ts b/src/lib/snippets/api-routes.test.ts new file mode 100644 index 00000000..7ed54fce --- /dev/null +++ b/src/lib/snippets/api-routes.test.ts @@ -0,0 +1,306 @@ +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 listSnippets, + POST as createSnippet, +} from "@/app/api/snippets/route"; +import { + DELETE as deleteSnippet, + GET as getSnippet, + PATCH as patchSnippet, +} from "@/app/api/snippets/[id]/route"; +import { resetSnippetMemoryForTests, snippetStore } from "./memory-adapter"; + +function session(input?: { + id?: string; + name?: string; + unionId?: string | null; + localId?: string | null; + bargainingUnitId?: string; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + name: input?.name ?? "Local 243 President", + unionId: + input?.unionId === null ? undefined : (input?.unionId ?? "union-opseu"), + localId: + input?.localId === null ? undefined : (input?.localId ?? "local-243"), + bargainingUnitId: input?.bargainingUnitId, + 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/snippets${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const validCreate = { + title: "Duty of fair representation", + clauseRef: "Article 4.02", + body: "The union shall represent all members fairly.", + tags: ["dfr"], +}; + +describe("snippets API routes", () => { + beforeEach(() => { + resetSnippetMemoryForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetSnippetMemoryForTests(); + }); + + describe("GET /api/snippets", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listSnippets(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listSnippets(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 snippetStore.create( + { + title: "Other union clause", + clauseRef: "Art. 1", + body: "Must never appear", + localId: "local-243", + }, + { + unionId: "union-other", + createdById: "user-x", + createdByName: "X", + }, + ); + await snippetStore.create( + { + title: "Other local clause", + clauseRef: "Art. 2", + body: "Same union, other local", + localId: "local-560", + }, + { + unionId: "union-opseu", + createdById: "user-y", + createdByName: "Y", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listSnippets(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + snippets: Array<{ title: string; unionId: string; localId?: string }>; + }; + expect(body.snippets.every((s) => s.unionId === "union-opseu")).toBe(true); + expect( + body.snippets.every((s) => !s.localId || s.localId === "local-243"), + ).toBe(true); + expect(body.snippets.map((s) => s.title)).not.toContain( + "Other union clause", + ); + expect(body.snippets.map((s) => s.title)).not.toContain( + "Other local clause", + ); + }); + + it("filters by query", async () => { + authMock.mockResolvedValue(session()); + const res = await listSnippets(listRequest("?q=just%20cause")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + snippets: Array<{ id: string; title: string }>; + }; + expect(body.snippets.map((s) => s.id)).toContain("snip-001"); + expect(body.snippets.every((s) => /just cause/i.test(s.title))).toBe( + true, + ); + }); + }); + + describe("POST /api/snippets", () => { + it("rejects a missing body and stamps the session union/creator", async () => { + authMock.mockResolvedValue( + session({ + id: "user-steward-243", + name: "Local 243 Steward", + roles: ["local_steward"], + }), + ); + const missing = await createSnippet( + jsonRequest({ title: "No clause", body: "Nope" }), + ); + expect(missing.status).toBe(400); + + const created = await createSnippet( + jsonRequest({ + ...validCreate, + unionId: "union-other", + createdById: "attacker", + }), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + snippet: { + unionId: string; + localId?: string; + createdById: string; + title: string; + }; + }; + expect(body.snippet.unionId).toBe("union-opseu"); + expect(body.snippet.localId).toBe("local-243"); + expect(body.snippet.createdById).toBe("user-steward-243"); + expect(body.snippet.title).toBe(validCreate.title); + }); + + it("returns 403 when a member or stability member tries to create", async () => { + authMock.mockResolvedValue(session({ roles: ["stability_member"] })); + expect((await createSnippet(jsonRequest(validCreate))).status).toBe(403); + }); + + it("returns 400 when the session has no union", async () => { + authMock.mockResolvedValue( + session({ unionId: null, roles: ["local_president"] }), + ); + const res = await createSnippet(jsonRequest(validCreate)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Union required" }); + }); + }); + + describe("GET/PATCH/DELETE /api/snippets/[id]", () => { + it("returns 404 for a missing id and 403 for another union, including platform_admin", async () => { + const foreign = await snippetStore.create( + { + title: "Foreign clause", + clauseRef: "Art. 9", + body: "Other union CA", + }, + { + unionId: "union-other", + createdById: "user-other", + createdByName: "Other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const missing = await getSnippet( + new Request("http://localhost"), + params("snip-missing"), + ); + expect(missing.status).toBe(404); + + const viewed = await getSnippet( + new Request("http://localhost"), + params(foreign.id), + ); + expect(viewed.status).toBe(403); + expect(await viewed.json()).toEqual({ error: "Forbidden" }); + + const patched = await patchSnippet( + jsonRequest({ title: "Hijacked", unionId: "union-opseu" }), + params(foreign.id), + ); + expect(patched.status).toBe(403); + const stored = await snippetStore.getById(foreign.id); + expect(stored?.title).toBe("Foreign clause"); + expect(stored?.unionId).toBe("union-other"); + + const deleted = await deleteSnippet( + new Request("http://localhost"), + params(foreign.id), + ); + expect(deleted.status).toBe(403); + expect(await snippetStore.getById(foreign.id)).not.toBeNull(); + }); + + it("ignores forged tenant keys on PATCH of an owned snippet", async () => { + authMock.mockResolvedValue( + session({ + id: "user-steward-243-pt", + roles: ["local_steward"], + }), + ); + const patched = await patchSnippet( + jsonRequest({ + title: "Updated additional hours", + unionId: "union-other", + localId: "local-evil", + createdById: "attacker", + }), + params("snip-004"), + ); + expect(patched.status).toBe(200); + const body = (await patched.json()) as { + snippet: { + title: string; + unionId: string; + localId?: string; + createdById: string; + }; + }; + expect(body.snippet.title).toBe("Updated additional hours"); + expect(body.snippet.unionId).toBe("union-opseu"); + expect(body.snippet.localId).toBe("local-243"); + expect(body.snippet.createdById).toBe("user-steward-243-pt"); + }); + + it("lets the author delete and forbids another steward", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + expect( + (await deleteSnippet(new Request("http://localhost"), params("snip-004"))) + .status, + ).toBe(403); + expect(await snippetStore.getById("snip-004")).not.toBeNull(); + + authMock.mockResolvedValue( + session({ id: "user-steward-243-pt", roles: ["local_steward"] }), + ); + const deleted = await deleteSnippet( + new Request("http://localhost"), + params("snip-004"), + ); + expect(deleted.status).toBe(200); + expect(await snippetStore.getById("snip-004")).toBeNull(); + }); + + it("lets a president delete another officer's snippet", async () => { + authMock.mockResolvedValue(session()); + const deleted = await deleteSnippet( + new Request("http://localhost"), + params("snip-004"), + ); + expect(deleted.status).toBe(200); + expect(await snippetStore.getById("snip-004")).toBeNull(); + }); + }); +}); diff --git a/src/lib/snippets/memory-adapter.ts b/src/lib/snippets/memory-adapter.ts index f8cad8ff..0d303091 100644 --- a/src/lib/snippets/memory-adapter.ts +++ b/src/lib/snippets/memory-adapter.ts @@ -5,7 +5,8 @@ import type { UpdateCaSnippetInput, } from "@/types/qol"; -const snippets: CaSnippet[] = [ +function seedSnippets(): CaSnippet[] { + return [ { id: "snip-001", unionId: "union-opseu", @@ -60,7 +61,10 @@ const snippets: CaSnippet[] = [ createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), updatedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), }, -]; + ]; +} + +const snippets: CaSnippet[] = seedSnippets(); function id(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -151,3 +155,8 @@ export class MemorySnippetAdapter implements SnippetAdapter { } export const snippetStore: SnippetAdapter = new MemorySnippetAdapter(); + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetSnippetMemoryForTests(): void { + snippets.splice(0, snippets.length, ...seedSnippets()); +} diff --git a/src/lib/tasks/api-routes.test.ts b/src/lib/tasks/api-routes.test.ts new file mode 100644 index 00000000..b5e09bdf --- /dev/null +++ b/src/lib/tasks/api-routes.test.ts @@ -0,0 +1,398 @@ +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 listTasks, POST as createTask } from "@/app/api/tasks/route"; +import { + DELETE as deleteTask, + GET as getTask, + PATCH as patchTask, +} from "@/app/api/tasks/[id]/route"; +import { POST as toggleTaskReaction } from "@/app/api/tasks/[id]/reactions/route"; +import { memoryTaskStore, resetTaskMemoryForTests } from "./memory-adapter"; +import { resetTaskStore } from "./store"; +import { + createOverlayUnion, + resetTenantOverlayForTests, +} from "@/lib/tenant/overlay"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + bargainingUnitId?: string; + 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"), + bargainingUnitId: input?.bargainingUnitId, + 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/tasks${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const validCreate = { + title: "Prep Step 2 notes", +}; + +describe("tasks API routes", () => { + beforeEach(() => { + resetTaskMemoryForTests(); + resetTaskStore(); + resetTenantOverlayForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetTaskMemoryForTests(); + resetTaskStore(); + resetTenantOverlayForTests(); + }); + + describe("GET /api/tasks", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listTasks(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listTasks(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 memoryTaskStore.create( + { title: "Other union file" }, + { + unionId: "union-other", + localId: "local-243", + createdById: "user-x", + assigneeId: "user-x", + }, + ); + await memoryTaskStore.create( + { title: "Other local file" }, + { + unionId: "union-opseu", + localId: "local-560", + createdById: "user-y", + assigneeId: "user-y", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listTasks(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + tasks: Array<{ title: string; unionId: string; localId: string }>; + }; + expect(body.tasks.every((task) => task.unionId === "union-opseu")).toBe( + true, + ); + expect(body.tasks.every((task) => task.localId === "local-243")).toBe( + true, + ); + expect(body.tasks.map((task) => task.title)).not.toContain( + "Other union file", + ); + expect(body.tasks.map((task) => task.title)).not.toContain( + "Other local file", + ); + }); + + it("ignores an unknown status and returns only the caller's tasks for mine=1", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const ignored = await listTasks(listRequest("?status=archived")); + expect(ignored.status).toBe(200); + const ignoredBody = (await ignored.json()) as { + tasks: Array<{ id: string; status: string }>; + }; + expect(ignoredBody.tasks.some((task) => task.id === "task-001")).toBe( + true, + ); + expect(ignoredBody.tasks.some((task) => task.status === "done")).toBe( + true, + ); + + const mine = await listTasks(listRequest("?mine=1")); + const mineBody = (await mine.json()) as { + tasks: Array<{ id: string; assigneeId: string }>; + }; + expect(mineBody.tasks.length).toBeGreaterThan(0); + expect( + mineBody.tasks.every((task) => task.assigneeId === "user-steward-243"), + ).toBe(true); + }); + + it("returns changed:false when nothing is newer than since", async () => { + authMock.mockResolvedValue(session()); + const future = new Date(Date.now() + 60_000).toISOString(); + const res = await listTasks(listRequest(`?since=${encodeURIComponent(future)}`)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ changed: false, tasks: [] }); + }); + }); + + describe("POST /api/tasks", () => { + it("rejects forged tenant keys and stamps the session union/local/creator", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const forged = await createTask( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + createdById: "attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createTask(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + task: { + unionId: string; + localId: string; + createdById: string; + assigneeId: string; + status: string; + }; + }; + expect(body.task.unionId).toBe("union-opseu"); + expect(body.task.localId).toBe("local-243"); + expect(body.task.createdById).toBe("user-steward-243"); + expect(body.task.assigneeId).toBe("user-steward-243"); + expect(body.task.status).toBe("open"); + }); + + it("lets a steward create for themselves but not assign others", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const forbidden = await createTask( + jsonRequest({ + title: "Assign the president", + assigneeId: "user-president-243", + }), + ); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ + error: "Only elevated officers may assign others", + }); + }); + + it("lets a president assign another officer", async () => { + authMock.mockResolvedValue(session()); + const created = await createTask( + jsonRequest({ + title: "Follow up with steward", + assigneeId: "user-steward-243", + }), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + task: { assigneeId: string; createdById: string }; + }; + expect(body.task.assigneeId).toBe("user-steward-243"); + expect(body.task.createdById).toBe("user-president-243"); + }); + + it("returns 403 when the tasks module is off for that tenant", async () => { + const tenant = createOverlayUnion({ + name: "Comms Only Local", + enabledModules: ["comms", "grievance"], + localNumber: "888", + }); + authMock.mockResolvedValue( + session({ + id: "user-steward-888", + unionId: tenant.union.id, + localId: tenant.locals![0]!.id, + roles: ["local_steward"], + }), + ); + const res = await createTask(jsonRequest(validCreate)); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Module not enabled" }); + }); + }); + + describe("GET/PATCH/DELETE /api/tasks/[id]", () => { + it("returns 404 for a missing id and 403 for another union, including platform_admin", async () => { + const foreign = await memoryTaskStore.create( + { title: "Foreign task" }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-other", + assigneeId: "user-other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const missing = await getTask( + new Request("http://localhost"), + params("task-missing"), + ); + expect(missing.status).toBe(404); + + const viewed = await getTask( + new Request("http://localhost"), + params(foreign.id), + ); + expect(viewed.status).toBe(403); + expect(await viewed.json()).toEqual({ error: "Forbidden" }); + + const patched = await patchTask( + jsonRequest({ title: "Hijacked" }), + params(foreign.id), + ); + expect(patched.status).toBe(403); + expect((await memoryTaskStore.getById(foreign.id))?.title).toBe( + "Foreign task", + ); + + const deleted = await deleteTask( + new Request("http://localhost"), + params(foreign.id), + ); + expect(deleted.status).toBe(403); + expect(await memoryTaskStore.getById(foreign.id)).not.toBeNull(); + }); + + it("lets the assignee mark done but not edit or delete another officer's task", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + + const done = await patchTask( + jsonRequest({ status: "done" }), + params("task-001"), + ); + expect(done.status).toBe(200); + expect((await memoryTaskStore.getById("task-001"))?.status).toBe("done"); + + const reassignOther = await patchTask( + jsonRequest({ status: "done" }), + params("task-002"), + ); + expect(reassignOther.status).toBe(403); + + const editOther = await patchTask( + jsonRequest({ title: "Rewrite booking" }), + params("task-002"), + ); + expect(editOther.status).toBe(403); + expect((await memoryTaskStore.getById("task-002"))?.title).toContain( + "LEC room", + ); + + expect( + (await deleteTask(new Request("http://localhost"), params("task-001"))) + .status, + ).toBe(403); + expect(await memoryTaskStore.getById("task-001")).not.toBeNull(); + }); + + it("rejects tenant keys on update and lets the creator delete", async () => { + authMock.mockResolvedValue(session()); + const forged = await patchTask( + jsonRequest({ title: "Keep tenant", unionId: "union-other" }), + params("task-002"), + ); + expect(forged.status).toBe(400); + expect((await memoryTaskStore.getById("task-002"))?.unionId).toBe( + "union-opseu", + ); + + const deleted = await deleteTask( + new Request("http://localhost"), + params("task-002"), + ); + expect(deleted.status).toBe(200); + expect(await memoryTaskStore.getById("task-002")).toBeNull(); + }); + }); + + describe("POST /api/tasks/[id]/reactions", () => { + it("returns 404 when missing and 403 for another union with no write", async () => { + const foreign = await memoryTaskStore.create( + { title: "Foreign reaction" }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-other", + assigneeId: "user-other", + }, + ); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + + expect( + (await toggleTaskReaction(jsonRequest({ kind: "ack" }), params("missing"))) + .status, + ).toBe(404); + + const forbidden = await toggleTaskReaction( + jsonRequest({ kind: "ack" }), + params(foreign.id), + ); + expect(forbidden.status).toBe(403); + expect((await memoryTaskStore.getById(foreign.id))?.reactions).toEqual([]); + }); + + it("lets a steward toggle a reaction and rejects extra keys", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const extra = await toggleTaskReaction( + jsonRequest({ kind: "solidarity", userId: "attacker" }), + params("task-001"), + ); + expect(extra.status).toBe(400); + + const toggled = await toggleTaskReaction( + jsonRequest({ kind: "solidarity" }), + params("task-001"), + ); + expect(toggled.status).toBe(200); + const body = (await toggled.json()) as { + task: { reactions: Array<{ kind: string; userId: string }> }; + }; + expect(body.task.reactions).toEqual( + expect.arrayContaining([ + { kind: "solidarity", userId: "user-steward-243" }, + ]), + ); + }); + }); +}); diff --git a/src/lib/tasks/memory-adapter.ts b/src/lib/tasks/memory-adapter.ts index 794eb896..41ab3bd0 100644 --- a/src/lib/tasks/memory-adapter.ts +++ b/src/lib/tasks/memory-adapter.ts @@ -8,7 +8,8 @@ import type { import type { HubReactionKind } from "@/types/hub-social"; import { toggleHubReaction } from "@/lib/hub/reactions"; -const tasks: Task[] = [ +function seedTasks(): Task[] { + return [ { id: "task-001", unionId: "union-opseu", @@ -72,7 +73,10 @@ const tasks: Task[] = [ mentionedUserIds: ["user-president-243"], reactions: [], }, -]; + ]; +} + +const tasks: Task[] = seedTasks(); function id(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -215,3 +219,8 @@ export class MemoryTaskAdapter implements TaskAdapter { } export const memoryTaskStore: TaskAdapter = new MemoryTaskAdapter(); + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetTaskMemoryForTests(): void { + tasks.splice(0, tasks.length, ...seedTasks()); +} diff --git a/src/lib/validation/task.test.ts b/src/lib/validation/task.test.ts new file mode 100644 index 00000000..5542c8d5 --- /dev/null +++ b/src/lib/validation/task.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonBody } from "@/lib/validation/parse"; +import { createTaskSchema, updateTaskSchema } from "@/lib/validation/task"; + +const validCreate = { + title: "Prep Step 2 notes", +}; + +describe("task request schemas", () => { + it("accepts a steward-authored create body and rejects tenant identity keys", () => { + expect(parseJsonBody(createTaskSchema, validCreate).ok).toBe(true); + expect( + parseJsonBody(createTaskSchema, { + ...validCreate, + unionId: "other-union", + localId: "other-local", + createdById: "attacker", + }).ok, + ).toBe(false); + }); + + it("rejects an empty title and a non-ISO due date", () => { + expect(parseJsonBody(createTaskSchema, { title: "" }).ok).toBe(false); + expect( + parseJsonBody(createTaskSchema, { + ...validCreate, + dueAt: "tomorrow", + }).ok, + ).toBe(false); + }); + + it("allows partial updates and still blocks mass-assigned tenant fields", () => { + expect(parseJsonBody(updateTaskSchema, { status: "done" }).ok).toBe(true); + expect( + parseJsonBody(updateTaskSchema, { + title: "Updated", + unionId: "other-union", + createdById: "attacker", + }).ok, + ).toBe(false); + }); +});