From fb2aa9acc1cc6682c6d83b36181332bffb3f3d90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 10:07:00 +0000 Subject: [PATCH] test: lock expense list, election tallies, and document vault tenancy Route handlers for expense list/create/submit, election nominations/tallies, and document list/upload/delete were only covered by the static auth-marker scan after #44. These tests lock cross-union isolation, session tenant stamps, and extra-key rejection on those mutating paths. Co-authored-by: Ryan Morris --- src/lib/documents/api-routes.test.ts | 226 ++++++++++++++++++++++++++- src/lib/elections/api-routes.test.ts | 225 ++++++++++++++++++++++++++ src/lib/expenses/api-routes.test.ts | 207 ++++++++++++++++++++++++ src/lib/validation/elections.test.ts | 67 ++++++++ 4 files changed, 723 insertions(+), 2 deletions(-) diff --git a/src/lib/documents/api-routes.test.ts b/src/lib/documents/api-routes.test.ts index 035173e7..6a5108a5 100644 --- a/src/lib/documents/api-routes.test.ts +++ b/src/lib/documents/api-routes.test.ts @@ -13,6 +13,11 @@ vi.mock("@/auth", () => ({ auth: authMock, })); +import { + GET as listDocuments, + POST as uploadDocument, +} from "@/app/api/documents/route"; +import { DELETE as deleteDocument } from "@/app/api/documents/[id]/route"; import { GET as downloadDocument } from "@/app/api/documents/[id]/download/route"; import { insertDocumentForTests, @@ -43,6 +48,12 @@ function session(input?: { }; } +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + function params(id: string) { return { params: Promise.resolve({ id }) }; } @@ -51,22 +62,24 @@ function seedDoc(input?: { id?: string; unionId?: string; localId?: string; + title?: string; scanStatus?: AttachmentScanStatus; storageKey?: string; fileName?: string; + uploadedById?: string; }): DocumentRecord { const id = input?.id ?? `doc-${Math.random().toString(36).slice(2, 8)}`; const doc: DocumentRecord = { id, unionId: input?.unionId ?? "union-opseu", localId: input?.localId ?? "local-243", - title: "CBA excerpt", + title: input?.title ?? "CBA excerpt", fileName: input?.fileName ?? "cba.pdf", mimeType: "application/pdf", sizeBytes: 12, storageKey: input?.storageKey ?? `union-opseu/local-243/document/${id}/${id}/cba.pdf`, scanStatus: input?.scanStatus ?? "clean", - uploadedById: "user-steward-243", + uploadedById: input?.uploadedById ?? "user-steward-243", createdAt: "2026-08-01T00:00:00.000Z", }; insertDocumentForTests(doc); @@ -193,3 +206,212 @@ describe("document download API", () => { expect(Buffer.from(await res.arrayBuffer())).toEqual(bytes); }); }); + +describe("document list/upload/delete API", () => { + let dir: string; + const previousLocalDir = process.env.ATTACHMENT_LOCAL_DIR; + const previousScannerUrl = process.env.ATTACHMENT_SCANNER_URL; + const previousScanMode = process.env.ATTACHMENT_SCAN_MODE; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "uo-docs-list-")); + process.env.ATTACHMENT_LOCAL_DIR = dir; + delete process.env.ATTACHMENT_SCANNER_URL; + delete process.env.ATTACHMENT_SCAN_MODE; + resetDocumentsMemoryForTests(); + resetDocumentStore(); + resetObjectStorageCache(); + authMock.mockReset(); + }); + + afterEach(async () => { + resetDocumentsMemoryForTests(); + resetDocumentStore(); + resetObjectStorageCache(); + if (previousLocalDir === undefined) { + delete process.env.ATTACHMENT_LOCAL_DIR; + } else { + process.env.ATTACHMENT_LOCAL_DIR = previousLocalDir; + } + if (previousScannerUrl === undefined) { + delete process.env.ATTACHMENT_SCANNER_URL; + } else { + process.env.ATTACHMENT_SCANNER_URL = previousScannerUrl; + } + if (previousScanMode === undefined) { + delete process.env.ATTACHMENT_SCAN_MODE; + } else { + process.env.ATTACHMENT_SCAN_MODE = previousScanMode; + } + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + }); + + function pdfPayload() { + const bytes = Buffer.from("%PDF-1.4 test"); + return { + bytes, + body: { + title: "CBA excerpt", + fileName: "cba.pdf", + mimeType: "application/pdf", + sizeBytes: bytes.length, + contentBase64: bytes.toString("base64"), + }, + }; + } + + it("returns 401 without a session and 403 for members", async () => { + seedDoc(); + authMock.mockResolvedValue(null); + expect((await listDocuments()).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listDocuments(); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + expect((await uploadDocument(jsonRequest(pdfPayload().body))).status).toBe( + 403, + ); + }); + + it("does not list another union, and pins a steward to their local", async () => { + seedDoc({ id: "doc-mine", title: "Local 243 CBA" }); + seedDoc({ + id: "doc-other-union", + unionId: "union-other", + localId: "local-1", + title: "Other union CBA", + }); + seedDoc({ + id: "doc-other-local", + localId: "local-560", + title: "Other local CBA", + }); + + authMock.mockResolvedValue(session()); + const res = await listDocuments(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + documents: Array<{ id: string; title: string; unionId: string; localId: string }>; + }; + expect(body.documents.every((d) => d.unionId === "union-opseu")).toBe(true); + expect(body.documents.every((d) => d.localId === "local-243")).toBe(true); + expect(body.documents.map((d) => d.id)).toEqual(["doc-mine"]); + expect(body.documents.map((d) => d.title)).not.toContain("Other union CBA"); + expect(body.documents.map((d) => d.title)).not.toContain("Other local CBA"); + }); + + it("never lists another union for union_admin, including when the session local is empty", async () => { + seedDoc({ id: "doc-243" }); + seedDoc({ + id: "doc-560", + localId: "local-560", + title: "Sister local", + }); + seedDoc({ + id: "doc-other-union", + unionId: "union-other", + localId: "local-1", + title: "Other union CBA", + }); + + authMock.mockResolvedValue( + session({ roles: ["union_admin"], localId: null }), + ); + const res = await listDocuments(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + documents: Array<{ id: string; unionId: string }>; + }; + expect(body.documents.every((d) => d.unionId === "union-opseu")).toBe(true); + expect(body.documents.map((d) => d.id).sort()).toEqual([ + "doc-243", + "doc-560", + ]); + expect(body.documents.map((d) => d.id)).not.toContain("doc-other-union"); + }); + + it("rejects a missing payload, then stamps the session tenant and ignores forged union/local keys", async () => { + const { body: payload, bytes } = pdfPayload(); + authMock.mockResolvedValue(session()); + const missing = await uploadDocument( + jsonRequest({ title: "CBA excerpt", fileName: "cba.pdf" }), + ); + expect(missing.status).toBe(400); + + const mismatch = await uploadDocument( + jsonRequest({ ...payload, sizeBytes: bytes.length + 1 }), + ); + expect(mismatch.status).toBe(400); + + const created = await uploadDocument( + jsonRequest({ + ...payload, + unionId: "union-other", + localId: "local-evil", + uploadedById: "attacker", + }), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + document: { + unionId: string; + localId: string; + uploadedById: string; + fileName: string; + sizeBytes: number; + }; + }; + expect(body.document.unionId).toBe("union-opseu"); + expect(body.document.localId).toBe("local-243"); + expect(body.document.uploadedById).toBe("user-steward-243"); + expect(body.document.fileName).toBe("cba.pdf"); + expect(body.document.sizeBytes).toBe(bytes.length); + }); + + it("returns 400 when the session has no local", async () => { + authMock.mockResolvedValue( + session({ roles: ["union_admin"], localId: null }), + ); + const res = await uploadDocument(jsonRequest(pdfPayload().body)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Union and local required" }); + }); + + it("returns 404 for another union on delete, including platform_admin, and forbids a steward from deleting someone else's file", async () => { + const foreign = seedDoc({ + id: "doc-foreign", + unionId: "union-other", + localId: "local-1", + }); + const owned = seedDoc({ id: "doc-owned" }); + const otherSteward = seedDoc({ + id: "doc-peer", + uploadedById: "user-steward-peer", + }); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const crossUnion = await deleteDocument( + new Request("http://localhost"), + params(foreign.id), + ); + expect(crossUnion.status).toBe(404); + expect(await crossUnion.json()).toEqual({ error: "Not found" }); + + authMock.mockResolvedValue(session()); + const forbidden = await deleteDocument( + new Request("http://localhost"), + params(otherSteward.id), + ); + expect(forbidden.status).toBe(403); + + const deleted = await deleteDocument( + new Request("http://localhost"), + params(owned.id), + ); + expect(deleted.status).toBe(200); + expect(await deleted.json()).toEqual({ ok: true }); + }); +}); diff --git a/src/lib/elections/api-routes.test.ts b/src/lib/elections/api-routes.test.ts index 2a0739db..7ffdac39 100644 --- a/src/lib/elections/api-routes.test.ts +++ b/src/lib/elections/api-routes.test.ts @@ -9,6 +9,12 @@ vi.mock("@/auth", () => ({ auth: authMock, })); +import { + GET as listElections, + POST as createElection, +} from "@/app/api/elections/route"; +import { POST as addNomination } from "@/app/api/elections/[id]/nominations/route"; +import { POST as recordTallies } from "@/app/api/elections/[id]/tallies/route"; import { POST as promote } from "@/app/api/elections/[id]/promote/route"; import { memoryElectionsStore, @@ -46,6 +52,10 @@ function jsonRequest(body: unknown): Request { } as Request; } +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/elections${query}`); +} + function params(id: string) { return { params: Promise.resolve({ id }) }; } @@ -55,6 +65,19 @@ const promoteBody = { nomineeName: "Alex Rivera", }; +const nominationBody = { + position: "Secretary", + nomineeName: "Lee Park", + nominator: "Alex Rivera", +}; + +const talliesBody = { + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: 12 }, + { position: "Vice-President", nomineeName: "Sam Okonkwo", votes: 9 }, + ], +}; + describe("elections promote API", () => { beforeEach(() => { resetElectionsMemoryForTests(); @@ -156,3 +179,205 @@ describe("elections promote API", () => { expect(body.officer.termStart).toBe("2026-09-01"); }); }); + +describe("elections list/create/nominations/tallies API", () => { + beforeEach(() => { + resetElectionsMemoryForTests(); + resetElectionsStore(); + resetOfficerRosterMemoryForTests(); + resetOfficerRosterStore(); + authMock.mockReset(); + }); + + afterEach(() => { + resetElectionsMemoryForTests(); + resetElectionsStore(); + resetOfficerRosterMemoryForTests(); + resetOfficerRosterStore(); + }); + + it("returns 401 without a session and 403 for members, stewards, and local_exec", async () => { + authMock.mockResolvedValue(null); + expect((await listElections(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + expect((await listElections(listRequest())).status).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_steward"] })); + expect( + (await addNomination(jsonRequest(nominationBody), params("elec-001"))) + .status, + ).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + const exec = await recordTallies( + jsonRequest(talliesBody), + params("elec-001"), + ); + expect(exec.status).toBe(403); + expect(await exec.json()).toEqual({ error: "Forbidden" }); + }); + + it("does not list another union or another local for a president", async () => { + await memoryElectionsStore.create( + { title: "Other union exec", positions: ["President"] }, + { unionId: "union-other", localId: "local-243" }, + ); + await memoryElectionsStore.create( + { title: "Other local exec", positions: ["President"] }, + { unionId: "union-opseu", localId: "local-560" }, + ); + + authMock.mockResolvedValue(session()); + const res = await listElections(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + cycles: Array<{ title: string; unionId: string; localId: string }>; + }; + expect(body.cycles.every((c) => c.unionId === "union-opseu")).toBe(true); + expect(body.cycles.every((c) => c.localId === "local-243")).toBe(true); + expect(body.cycles.map((c) => c.title)).not.toContain("Other union exec"); + expect(body.cycles.map((c) => c.title)).not.toContain("Other local exec"); + expect(body.cycles.map((c) => c.title)).toContain("2026 Executive election"); + }); + + it("rejects forged tenant keys on create, then stamps the session union/local", async () => { + authMock.mockResolvedValue(session()); + const forged = await createElection( + jsonRequest({ + title: "Special election", + positions: ["Treasurer"], + unionId: "union-other", + localId: "local-evil", + }), + ); + expect(forged.status).toBe(400); + + const created = await createElection( + jsonRequest({ title: "Special election", positions: ["Treasurer"] }), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + cycle: { + title: string; + unionId: string; + localId: string; + status: string; + }; + }; + expect(body.cycle.title).toBe("Special election"); + expect(body.cycle.unionId).toBe("union-opseu"); + expect(body.cycle.localId).toBe("local-243"); + expect(body.cycle.status).toBe("open"); + }); + + it("returns 404 for a missing cycle and 403 for another union, including platform_admin, without writing a nomination", async () => { + const foreign = await memoryElectionsStore.create( + { title: "Other union exec", positions: ["President"] }, + { unionId: "union-other", localId: "local-1" }, + ); + + authMock.mockResolvedValue(session()); + expect( + ( + await addNomination( + jsonRequest(nominationBody), + params("elec-does-not-exist"), + ) + ).status, + ).toBe(404); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const res = await addNomination( + jsonRequest(nominationBody), + params(foreign.id), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Forbidden" }); + expect((await memoryElectionsStore.getById(foreign.id))?.nominations).toEqual( + [], + ); + }); + + it("rejects extra nomination keys, then appends the nominee on the session cycle", async () => { + authMock.mockResolvedValue(session()); + const forged = await addNomination( + jsonRequest({ + ...nominationBody, + unionId: "union-other", + id: "nom-forged", + }), + params("elec-001"), + ); + expect(forged.status).toBe(400); + + const created = await addNomination( + jsonRequest(nominationBody), + params("elec-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + cycle: { + id: string; + nominations: Array<{ + position: string; + nomineeName: string; + status: string; + }>; + }; + }; + expect(body.cycle.id).toBe("elec-001"); + expect(body.cycle.nominations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + position: "Secretary", + nomineeName: "Lee Park", + status: "pending", + }), + ]), + ); + }); + + it("rejects negative votes, then records offline tallies and marks the cycle tallied", async () => { + const foreign = await memoryElectionsStore.create( + { title: "Other union exec", positions: ["President"] }, + { unionId: "union-other", localId: "local-1" }, + ); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const crossUnion = await recordTallies( + jsonRequest(talliesBody), + params(foreign.id), + ); + expect(crossUnion.status).toBe(403); + expect((await memoryElectionsStore.getById(foreign.id))?.tallies).toEqual([]); + expect((await memoryElectionsStore.getById(foreign.id))?.status).toBe("open"); + + authMock.mockResolvedValue(session()); + const negative = await recordTallies( + jsonRequest({ + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: -1 }, + ], + }), + params("elec-001"), + ); + expect(negative.status).toBe(400); + + const recorded = await recordTallies( + jsonRequest(talliesBody), + params("elec-001"), + ); + expect(recorded.status).toBe(200); + const body = (await recorded.json()) as { + cycle: { + status: string; + tallies: Array<{ nomineeName: string; votes: number }>; + }; + }; + expect(body.cycle.status).toBe("tallied"); + expect(body.cycle.tallies).toEqual([ + { position: "President", nomineeName: "Alex Rivera", votes: 12 }, + { position: "Vice-President", nomineeName: "Sam Okonkwo", votes: 9 }, + ]); + }); +}); diff --git a/src/lib/expenses/api-routes.test.ts b/src/lib/expenses/api-routes.test.ts index 0161ff9e..26d0641e 100644 --- a/src/lib/expenses/api-routes.test.ts +++ b/src/lib/expenses/api-routes.test.ts @@ -9,6 +9,11 @@ vi.mock("@/auth", () => ({ auth: authMock, })); +import { + GET as listExpenses, + POST as createExpense, +} from "@/app/api/expenses/route"; +import { POST as submitExpense } from "@/app/api/expenses/[id]/submit/route"; import { POST as approveExpense } from "@/app/api/expenses/[id]/approve/route"; import { POST as denyExpense } from "@/app/api/expenses/[id]/deny/route"; import { @@ -44,10 +49,27 @@ function jsonRequest(body: unknown): Request { } as Request; } +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/expenses${query}`); +} + function params(id: string) { return { params: Promise.resolve({ id }) }; } +const validCreate = { + title: "Printer paper", + purpose: "Steward desk supplies", + lineItems: [ + { + date: "2026-08-01", + category: "supplies" as const, + amount: 45.5, + description: "Staples run", + }, + ], +}; + async function seedSubmitted(input?: { unionId?: string; localId?: string; @@ -234,3 +256,188 @@ describe("expense approve/deny API", () => { expect(body.submission.deniedReason).toBe("Missing receipt"); }); }); + +describe("expense list/create/submit API", () => { + beforeEach(() => { + resetExpenseMemoryForTests(); + resetExpenseStore(); + resetLedgerMemoryForTests(); + resetLedgerStore(); + authMock.mockReset(); + }); + + afterEach(() => { + resetExpenseMemoryForTests(); + resetExpenseStore(); + resetLedgerMemoryForTests(); + resetLedgerStore(); + }); + + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listExpenses(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listExpenses(listRequest()); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + expect((await createExpense(jsonRequest(validCreate))).status).toBe(403); + }); + + it("does not list another union or another local for a president", async () => { + await memoryExpenseStore.create(validCreate, { + unionId: "union-other", + localId: "local-243", + submittedById: "user-x", + submittedByName: "X", + }); + await memoryExpenseStore.create(validCreate, { + unionId: "union-opseu", + localId: "local-560", + submittedById: "user-y", + submittedByName: "Y", + }); + const mine = await memoryExpenseStore.create( + { ...validCreate, title: "Local 243 toner" }, + { + unionId: "union-opseu", + localId: "local-243", + submittedById: "user-president-243", + submittedByName: "Local 243 President", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listExpenses(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array<{ id: string; title: string; unionId: string; localId: string }>; + }; + expect(body.items.every((row) => row.unionId === "union-opseu")).toBe(true); + expect(body.items.every((row) => row.localId === "local-243")).toBe(true); + expect(body.items.map((row) => row.id)).toEqual([mine.id]); + expect(body.items.map((row) => row.title)).not.toContain("Printer paper"); + }); + + it("honours mine=1 and ignores an unknown status filter", async () => { + await memoryExpenseStore.create(validCreate, { + unionId: "union-opseu", + localId: "local-243", + submittedById: "user-steward-243", + submittedByName: "Local 243 Steward", + }); + const mine = await memoryExpenseStore.create( + { ...validCreate, title: "President mileage" }, + { + unionId: "union-opseu", + localId: "local-243", + submittedById: "user-president-243", + submittedByName: "Local 243 President", + }, + ); + + authMock.mockResolvedValue(session()); + const mineOnly = await listExpenses(listRequest("?mine=1")); + const mineBody = (await mineOnly.json()) as { + items: Array<{ id: string; submittedById: string }>; + }; + expect(mineBody.items).toHaveLength(1); + expect(mineBody.items[0]?.id).toBe(mine.id); + expect(mineBody.items[0]?.submittedById).toBe("user-president-243"); + + const junkStatus = await listExpenses(listRequest("?status=hacked")); + const junkBody = (await junkStatus.json()) as { items: Array<{ id: string }> }; + expect(junkBody.items).toHaveLength(2); + }); + + it("rejects forged tenant keys, then stamps the session union/local/submitter", async () => { + authMock.mockResolvedValue(session()); + const forged = await createExpense( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + submittedById: "attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createExpense(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + submission: { + unionId: string; + localId: string; + submittedById: string; + status: string; + totalAmount: number; + }; + }; + expect(body.submission.unionId).toBe("union-opseu"); + expect(body.submission.localId).toBe("local-243"); + expect(body.submission.submittedById).toBe("user-president-243"); + expect(body.submission.status).toBe("draft"); + expect(body.submission.totalAmount).toBe(45.5); + }); + + it("returns 400 when the session has no local", async () => { + authMock.mockResolvedValue( + session({ roles: ["union_admin"], localId: null }), + ); + const res = await createExpense(jsonRequest(validCreate)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Local required" }); + }); + + it("returns 404 for another union on submit, including platform_admin, and lets the owner submit a draft", async () => { + const foreign = await memoryExpenseStore.create(validCreate, { + unionId: "union-other", + localId: "local-1", + submittedById: "user-other", + submittedByName: "Other", + }); + const draft = await memoryExpenseStore.create(validCreate, { + unionId: "union-opseu", + localId: "local-243", + submittedById: "user-steward-243", + submittedByName: "Local 243 Steward", + }); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const crossUnion = await submitExpense( + new Request("http://localhost"), + params(foreign.id), + ); + expect(crossUnion.status).toBe(404); + expect(await crossUnion.json()).toEqual({ error: "Not found" }); + expect((await memoryExpenseStore.getById(foreign.id))?.status).toBe("draft"); + + authMock.mockResolvedValue( + session({ id: "user-other-steward", roles: ["local_steward"] }), + ); + expect( + ( + await submitExpense(new Request("http://localhost"), params(draft.id)) + ).status, + ).toBe(403); + + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const submitted = await submitExpense( + new Request("http://localhost"), + params(draft.id), + ); + expect(submitted.status).toBe(200); + const body = (await submitted.json()) as { + submission: { status: string }; + }; + expect(body.submission.status).toBe("submitted"); + + const retry = await submitExpense( + new Request("http://localhost"), + params(draft.id), + ); + expect(retry.status).toBe(403); + }); +}); diff --git a/src/lib/validation/elections.test.ts b/src/lib/validation/elections.test.ts index 992d7212..4d5390a5 100644 --- a/src/lib/validation/elections.test.ts +++ b/src/lib/validation/elections.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { parseJsonBody } from "@/lib/validation/parse"; import { createElectionCycleSchema, + createNominationSchema, promoteToRosterSchema, + recordTalliesSchema, + updateNominationSchema, } from "@/lib/validation/elections"; describe("elections request schemas", () => { @@ -45,4 +48,68 @@ describe("elections request schemas", () => { }).ok, ).toBe(false); }); + + it("rejects tenant identity keys, empty names, and illegal status on nominations", () => { + expect( + parseJsonBody(createNominationSchema, { + position: "Secretary", + nomineeName: "Lee Park", + }).ok, + ).toBe(true); + expect( + parseJsonBody(createNominationSchema, { + position: "Secretary", + nomineeName: "Lee Park", + unionId: "union-other", + id: "nom-forged", + }).ok, + ).toBe(false); + expect( + parseJsonBody(createNominationSchema, { + position: "", + nomineeName: "Lee Park", + }).ok, + ).toBe(false); + expect( + parseJsonBody(updateNominationSchema, { status: "accepted" }).ok, + ).toBe(true); + expect( + parseJsonBody(updateNominationSchema, { + status: "winner", + unionId: "union-other", + }).ok, + ).toBe(false); + }); + + it("rejects extra keys, fractional votes, and negatives on tally records", () => { + expect( + parseJsonBody(recordTalliesSchema, { + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: 12 }, + ], + }).ok, + ).toBe(true); + expect( + parseJsonBody(recordTalliesSchema, { + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: -1 }, + ], + }).ok, + ).toBe(false); + expect( + parseJsonBody(recordTalliesSchema, { + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: 1.5 }, + ], + }).ok, + ).toBe(false); + expect( + parseJsonBody(recordTalliesSchema, { + tallies: [ + { position: "President", nomineeName: "Alex Rivera", votes: 12 }, + ], + unionId: "union-other", + }).ok, + ).toBe(false); + }); });