From 87f7e9be3761f2555fdff54f0fe52e4e0fc6944d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 10:09:14 +0000 Subject: [PATCH] test: lock informal-log tenancy, convert, and request validation Route handlers for the steward quick-log were only covered by the static auth-marker scan. A missed 403/404 or a convert that stamps the wrong union would not show up in smoke locators. Co-authored-by: Ryan Morris --- src/lib/informal-log/access.test.ts | 75 ++++++ src/lib/informal-log/api-routes.test.ts | 341 ++++++++++++++++++++++++ src/lib/informal-log/memory-adapter.ts | 101 +++---- src/lib/informal-log/session.test.ts | 139 ++++++++++ src/lib/validation/informal-log.test.ts | 58 ++++ 5 files changed, 668 insertions(+), 46 deletions(-) create mode 100644 src/lib/informal-log/access.test.ts create mode 100644 src/lib/informal-log/api-routes.test.ts create mode 100644 src/lib/informal-log/session.test.ts create mode 100644 src/lib/validation/informal-log.test.ts diff --git a/src/lib/informal-log/access.test.ts b/src/lib/informal-log/access.test.ts new file mode 100644 index 00000000..0782f761 --- /dev/null +++ b/src/lib/informal-log/access.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + canAccessInformalLogModule, + canConvertInformalLog, + canCreateInformalLog, + canDeleteInformalLog, + canViewInformalLogEntry, +} from "./access"; +import type { InformalLogEntry } from "@/types/informal-log"; + +const entry: InformalLogEntry = { + id: "log-1", + unionId: "union-a", + localId: "local-1", + topic: "Hours of work", + channel: "in_person", + summary: "Spoke with the supervisor before filing.", + occurredAt: "2026-08-01T12:00:00.000Z", + loggedById: "steward-1", + loggedByName: "Alex Steward", + createdAt: "2026-08-01T12:00:00.000Z", +}; + +describe("informal log access", () => { + it("lets stewards and elevated officers into the module, not members", () => { + expect(canAccessInformalLogModule(["local_steward"])).toBe(true); + expect(canAccessInformalLogModule(["local_exec"])).toBe(true); + expect(canAccessInformalLogModule(["local_president"])).toBe(true); + expect(canCreateInformalLog(["local_steward"])).toBe(true); + expect(canAccessInformalLogModule(["local_member"])).toBe(false); + expect(canAccessInformalLogModule(["stability_member"])).toBe(false); + expect(canAccessInformalLogModule([])).toBe(false); + }); + + it("blocks local_exec from converting a log into a grievance", () => { + expect(canConvertInformalLog(["local_steward"])).toBe(true); + expect(canConvertInformalLog(["local_president"])).toBe(true); + expect(canConvertInformalLog(["local_exec"])).toBe(false); + expect(canConvertInformalLog(["local_steward", "local_exec"])).toBe(false); + expect(canConvertInformalLog(["local_member"])).toBe(false); + }); + + it("lets the author or an elevated officer delete a log", () => { + expect(canDeleteInformalLog(entry, "steward-1", ["local_steward"])).toBe( + true, + ); + expect(canDeleteInformalLog(entry, "other", ["local_steward"])).toBe(false); + expect(canDeleteInformalLog(entry, "other", ["local_president"])).toBe(true); + expect(canDeleteInformalLog(entry, "other", ["local_exec"])).toBe(true); + }); + + it("never allows a cross-union read, even for platform_admin", () => { + expect( + canViewInformalLogEntry(entry, "union-b", "local-1", ["platform_admin"]), + ).toBe(false); + expect( + canViewInformalLogEntry(entry, undefined, "local-1", ["local_president"]), + ).toBe(false); + }); + + it("scopes stewards to their local and lets elevated roles read other locals", () => { + expect( + canViewInformalLogEntry(entry, "union-a", "local-1", ["local_steward"]), + ).toBe(true); + expect( + canViewInformalLogEntry(entry, "union-a", "local-2", ["local_steward"]), + ).toBe(false); + expect( + canViewInformalLogEntry(entry, "union-a", "local-2", ["union_admin"]), + ).toBe(true); + expect( + canViewInformalLogEntry(entry, "union-a", "local-1", ["local_member"]), + ).toBe(false); + }); +}); diff --git a/src/lib/informal-log/api-routes.test.ts b/src/lib/informal-log/api-routes.test.ts new file mode 100644 index 00000000..14bd00ba --- /dev/null +++ b/src/lib/informal-log/api-routes.test.ts @@ -0,0 +1,341 @@ +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 listLogs, POST as createLog } from "@/app/api/informal-log/route"; +import { + DELETE as deleteLog, + GET as getLog, + PATCH as patchLog, +} from "@/app/api/informal-log/[id]/route"; +import { POST as convertLog } from "@/app/api/informal-log/[id]/convert/route"; +import { memoryInformalLogStore, resetInformalLogMemoryForTests } from "./memory-adapter"; +import { resetInformalLogStore } from "./store"; +import { grievanceStore, resetGrievanceStore } from "@/lib/grievance/store"; +import { + createOverlayUnion, + resetTenantOverlayForTests, +} from "@/lib/tenant/overlay"; + +function session(input?: { + id?: string; + unionId?: string; + localId?: string; + bargainingUnitId?: string; + name?: string; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-steward-243", + name: input?.name ?? "Local 243 Steward", + unionId: input?.unionId ?? "union-opseu", + localId: input?.localId ?? "local-243", + bargainingUnitId: input?.bargainingUnitId, + roles: input?.roles ?? (["local_steward"] as UserRole[]), + }, + }; +} + +function jsonRequest(body: unknown): Request { + return { + json: async () => body, + } as Request; +} + +function listRequest(query = ""): Request { + return new Request(`http://localhost/api/informal-log${query}`); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const validCreate = { + topic: "Overtime skipped", + channel: "in_person" as const, + summary: "Supervisor used the wrong list.", + occurredAt: "2026-08-20T14:00:00.000Z", +}; + +describe("informal log API routes", () => { + beforeEach(() => { + resetInformalLogMemoryForTests(); + resetInformalLogStore(); + resetGrievanceStore(); + resetTenantOverlayForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetInformalLogMemoryForTests(); + resetInformalLogStore(); + resetTenantOverlayForTests(); + }); + + describe("GET /api/informal-log", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listLogs(listRequest())).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listLogs(listRequest()); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("does not list another union or another local for a steward", async () => { + await memoryInformalLogStore.create( + { + topic: "Other union", + channel: "email", + summary: "Must never appear", + occurredAt: "2026-08-21T12:00:00.000Z", + }, + { + unionId: "union-other", + localId: "local-243", + loggedById: "user-x", + loggedByName: "X", + }, + ); + await memoryInformalLogStore.create( + { + topic: "Other local", + channel: "phone", + summary: "Same union, other local", + occurredAt: "2026-08-21T13:00:00.000Z", + }, + { + unionId: "union-opseu", + localId: "local-560", + loggedById: "user-y", + loggedByName: "Y", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listLogs(listRequest()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + entries: Array<{ + id: string; + unionId: string; + localId: string; + topic: string; + }>; + }; + expect(body.entries.every((e) => e.unionId === "union-opseu")).toBe(true); + expect(body.entries.every((e) => e.localId === "local-243")).toBe(true); + expect(body.entries.map((e) => e.topic)).not.toContain("Other union"); + expect(body.entries.map((e) => e.topic)).not.toContain("Other local"); + }); + + it("filters unconverted entries when requested", async () => { + await memoryInformalLogStore.update("ilog-001", { + convertedToGrievanceId: "grev-seed", + }); + authMock.mockResolvedValue(session()); + const res = await listLogs(listRequest("?unconverted=1")); + const body = (await res.json()) as { + entries: Array<{ id: string; convertedToGrievanceId?: string }>; + }; + expect(body.entries.map((e) => e.id)).not.toContain("ilog-001"); + expect(body.entries.every((e) => !e.convertedToGrievanceId)).toBe(true); + }); + }); + + describe("POST /api/informal-log", () => { + it("rejects forged tenant keys and stamps the session union/local", async () => { + authMock.mockResolvedValue(session()); + const forged = await createLog( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + }), + ); + expect(forged.status).toBe(400); + + const created = await createLog(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + entry: { unionId: string; localId: string; loggedById: string }; + }; + expect(body.entry.unionId).toBe("union-opseu"); + expect(body.entry.localId).toBe("local-243"); + expect(body.entry.loggedById).toBe("user-steward-243"); + }); + + it("returns 403 when a member tries to create a log", async () => { + authMock.mockResolvedValue(session({ roles: ["stability_member"] })); + expect((await createLog(jsonRequest(validCreate))).status).toBe(403); + }); + }); + + describe("GET/PATCH/DELETE /api/informal-log/[id]", () => { + it("returns 404 for a cross-union id, including platform_admin", async () => { + const foreign = await memoryInformalLogStore.create( + { + topic: "Foreign", + channel: "letter", + summary: "Other union casework", + occurredAt: "2026-08-19T09:00:00.000Z", + }, + { + unionId: "union-other", + localId: "local-1", + loggedById: "user-other", + loggedByName: "Other", + }, + ); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const res = await getLog(listRequest(), params(foreign.id)); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Not found" }); + }); + + it("blocks edits after convert and rejects unknown update fields", async () => { + await memoryInformalLogStore.update("ilog-002", { + convertedToGrievanceId: "grev-existing", + }); + authMock.mockResolvedValue(session()); + + const conflict = await patchLog( + jsonRequest({ summary: "rewrite after file" }), + params("ilog-002"), + ); + expect(conflict.status).toBe(409); + + const invalid = await patchLog( + jsonRequest({ unionId: "union-other", summary: "ok" }), + params("ilog-001"), + ); + expect(invalid.status).toBe(400); + }); + + it("lets the author delete and forbids another steward", async () => { + authMock.mockResolvedValue( + session({ id: "user-other-steward", roles: ["local_steward"] }), + ); + expect((await deleteLog(listRequest(), params("ilog-001"))).status).toBe( + 403, + ); + + authMock.mockResolvedValue(session({ id: "user-steward-243" })); + const deleted = await deleteLog(listRequest(), params("ilog-001")); + expect(deleted.status).toBe(200); + expect(await memoryInformalLogStore.getById("ilog-001")).toBeNull(); + }); + }); + + describe("POST /api/informal-log/[id]/convert", () => { + it("forbids local_exec, including dual steward+exec roles", async () => { + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + expect((await convertLog(listRequest(), params("ilog-001"))).status).toBe( + 403, + ); + + authMock.mockResolvedValue( + session({ roles: ["local_steward", "local_exec"] }), + ); + expect((await convertLog(listRequest(), params("ilog-001"))).status).toBe( + 403, + ); + }); + + it("promotes a log into a Step 1 grievance in the entry tenant and then 409s", async () => { + authMock.mockResolvedValue(session()); + const res = await convertLog(listRequest(), params("ilog-001")); + expect(res.status).toBe(201); + const body = (await res.json()) as { + entry: { convertedToGrievanceId?: string; unionId: string }; + grievance: { + id: string; + unionId: string; + localId: string; + category: string; + assignedStewardId: string; + }; + }; + expect(body.entry.convertedToGrievanceId).toBe(body.grievance.id); + expect(body.grievance.unionId).toBe("union-opseu"); + expect(body.grievance.localId).toBe("local-243"); + expect(body.grievance.category).toBe("Scheduling / overtime assignment"); + expect(body.grievance.assignedStewardId).toBe("user-steward-243"); + + const stored = await grievanceStore.getById(body.grievance.id); + expect(stored?.grievance.unionId).toBe("union-opseu"); + expect(stored?.notes[0]?.body).toContain("Converted from informal log"); + + const again = await convertLog(listRequest(), params("ilog-001")); + expect(again.status).toBe(409); + expect(await again.json()).toMatchObject({ + error: "Already converted", + grievanceId: body.grievance.id, + }); + }); + + it("returns 403 when the grievance module is off for that tenant", async () => { + const tenant = createOverlayUnion({ + name: "Comms Only Local", + enabledModules: ["comms", "informalLog"], + localNumber: "888", + }); + const entry = await memoryInformalLogStore.create( + { + topic: "Cannot file", + channel: "email", + summary: "No grievance module", + occurredAt: "2026-08-18T10:00:00.000Z", + }, + { + unionId: tenant.union.id, + localId: tenant.locals[0]!.id, + loggedById: "user-steward-888", + loggedByName: "Steward 888", + }, + ); + + authMock.mockResolvedValue( + session({ + id: "user-steward-888", + unionId: tenant.union.id, + localId: tenant.locals[0]!.id, + }), + ); + const res = await convertLog(listRequest(), params(entry.id)); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Grievance module not enabled" }); + }); + + it("returns 404 when converting another union's log", async () => { + const foreign = await memoryInformalLogStore.create( + { + topic: "Foreign convert", + channel: "phone", + summary: "Must 404", + occurredAt: "2026-08-17T10:00:00.000Z", + }, + { + unionId: "union-other", + localId: "local-1", + loggedById: "user-other", + loggedByName: "Other", + }, + ); + authMock.mockResolvedValue(session({ roles: ["local_president"] })); + expect((await convertLog(listRequest(), params(foreign.id))).status).toBe( + 404, + ); + }); + }); +}); diff --git a/src/lib/informal-log/memory-adapter.ts b/src/lib/informal-log/memory-adapter.ts index 683659a5..b3e326ab 100644 --- a/src/lib/informal-log/memory-adapter.ts +++ b/src/lib/informal-log/memory-adapter.ts @@ -6,52 +6,56 @@ import type { UpdateInformalLogInput, } from "@/types/informal-log"; -const entries: InformalLogEntry[] = [ - { - id: "ilog-001", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-ft", - memberPseudonym: "Member A", - topic: "Scheduling / overtime assignment", - channel: "in_person", - summary: - "Discussed with supervisor whether OT was offered by seniority. Supervisor said they would check and follow up next week.", - occurredAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), - loggedById: "user-steward-243", - loggedByName: "Local 243 Steward", - createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "ilog-002", - unionId: "union-opseu", - localId: "local-243", - memberPseudonym: "Member D", - topic: "Workplace accommodation follow-up", - channel: "email", - summary: - "Member emailed about delayed response on accommodation request. Steward confirmed receipt with HR and asked for timeline.", - occurredAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - loggedById: "user-steward-243", - loggedByName: "Local 243 Steward", - createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - }, - { - id: "ilog-003", - unionId: "union-opseu", - localId: "local-243", - bargainingUnitId: "bu-243-pt", - memberPseudonym: "Member B", - topic: "Additional hours / call-in list", - channel: "phone", - summary: - "Member asked why they were skipped for Saturday coverage. Supervisor used the full-time overtime list instead of the part-time additional-hours list.", - occurredAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), - loggedById: "user-steward-243-pt", - loggedByName: "Local 243 Steward (PT)", - createdAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), - }, -]; +function seedEntries(): InformalLogEntry[] { + return [ + { + id: "ilog-001", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + memberPseudonym: "Member A", + topic: "Scheduling / overtime assignment", + channel: "in_person", + summary: + "Discussed with supervisor whether OT was offered by seniority. Supervisor said they would check and follow up next week.", + occurredAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + loggedById: "user-steward-243", + loggedByName: "Local 243 Steward", + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "ilog-002", + unionId: "union-opseu", + localId: "local-243", + memberPseudonym: "Member D", + topic: "Workplace accommodation follow-up", + channel: "email", + summary: + "Member emailed about delayed response on accommodation request. Steward confirmed receipt with HR and asked for timeline.", + occurredAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + loggedById: "user-steward-243", + loggedByName: "Local 243 Steward", + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: "ilog-003", + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-pt", + memberPseudonym: "Member B", + topic: "Additional hours / call-in list", + channel: "phone", + summary: + "Member asked why they were skipped for Saturday coverage. Supervisor used the full-time overtime list instead of the part-time additional-hours list.", + occurredAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), + loggedById: "user-steward-243-pt", + loggedByName: "Local 243 Steward (PT)", + createdAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), + }, + ]; +} + +const entries: InformalLogEntry[] = seedEntries(); function id(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -151,3 +155,8 @@ export class MemoryInformalLogAdapter implements InformalLogAdapter { export const memoryInformalLogStore: InformalLogAdapter = new MemoryInformalLogAdapter(); + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetInformalLogMemoryForTests(): void { + entries.splice(0, entries.length, ...seedEntries()); +} diff --git a/src/lib/informal-log/session.test.ts b/src/lib/informal-log/session.test.ts new file mode 100644 index 00000000..60122940 --- /dev/null +++ b/src/lib/informal-log/session.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Session } from "next-auth"; +import type { UserRole } from "@/types/tenant"; +import type { InformalLogEntry } from "@/types/informal-log"; + +vi.mock("@/auth", () => ({ + auth: vi.fn(), +})); + +import { + assertInformalLogView, + isInformalLogModuleEnabled, + listFiltersForInformalLogSession, + tenantIdsForInformalLogSession, +} from "@/lib/auth/informal-log-session"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + bargainingUnitId?: string; + roles?: UserRole[]; +}): Session { + return { + user: { + id: input?.id ?? "user-1", + 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_steward"] as UserRole[]), + }, + expires: "2099-01-01T00:00:00.000Z", + } as Session; +} + +const otherUnionEntry: InformalLogEntry = { + id: "log-other", + unionId: "union-other", + localId: "local-243", + topic: "Hours", + channel: "phone", + summary: "Other union", + occurredAt: "2026-08-01T12:00:00.000Z", + loggedById: "user-x", + loggedByName: "X", + createdAt: "2026-08-01T12:00:00.000Z", +}; + +describe("informal log session helpers", () => { + it("treats a missing union or unknown tenant as module disabled", () => { + expect(isInformalLogModuleEnabled(session({ unionId: null }))).toBe(false); + expect( + isInformalLogModuleEnabled(session({ unionId: "union-does-not-exist" })), + ).toBe(false); + expect(isInformalLogModuleEnabled(session())).toBe(true); + }); + + it("never lists another union when the session has no unionId", () => { + expect(listFiltersForInformalLogSession(session({ unionId: null }))).toEqual({ + unionId: "__none__", + localId: undefined, + }); + }); + + it("pins stewards to session local and collection; solo accounts list the whole union", () => { + expect( + listFiltersForInformalLogSession( + session({ bargainingUnitId: "bu-243-ft" }), + ), + ).toEqual({ + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + }); + + expect( + listFiltersForInformalLogSession( + session({ roles: ["solo_account"], localId: "local-243" }), + ), + ).toEqual({ unionId: "union-opseu" }); + }); + + it("lets cross-local admins drop local/collection filters only when localId is empty", () => { + expect( + listFiltersForInformalLogSession( + session({ + roles: ["union_admin"], + localId: "local-243", + bargainingUnitId: "bu-243-ft", + }), + ), + ).toEqual({ + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: "bu-243-ft", + }); + + expect( + listFiltersForInformalLogSession( + session({ + roles: ["union_admin"], + localId: null, + bargainingUnitId: "bu-243-ft", + }), + ), + ).toEqual({ + unionId: "union-opseu", + localId: undefined, + bargainingUnitId: undefined, + }); + }); + + it("stamps creates from the session, with solo fallbacks when tenant ids are missing", () => { + expect(tenantIdsForInformalLogSession(session())).toEqual({ + unionId: "union-opseu", + localId: "local-243", + bargainingUnitId: undefined, + }); + expect( + tenantIdsForInformalLogSession( + session({ id: "solo-1", unionId: null, localId: null }), + ), + ).toEqual({ + unionId: "solo-union-solo-1", + localId: "solo-local-solo-1", + bargainingUnitId: undefined, + }); + }); + + it("refuses a view when the entry belongs to another union", () => { + expect(assertInformalLogView(session(), otherUnionEntry)).toBe(false); + expect( + assertInformalLogView( + session({ roles: ["platform_admin"] }), + otherUnionEntry, + ), + ).toBe(false); + }); +}); diff --git a/src/lib/validation/informal-log.test.ts b/src/lib/validation/informal-log.test.ts new file mode 100644 index 00000000..c6890470 --- /dev/null +++ b/src/lib/validation/informal-log.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonBody } from "@/lib/validation/parse"; +import { + createInformalLogSchema, + updateInformalLogSchema, +} from "@/lib/validation/informal-log"; + +const validCreate = { + topic: "Overtime skipped", + channel: "in_person" as const, + summary: "Supervisor used the wrong list.", + occurredAt: "2026-08-20T14:00:00.000Z", +}; + +describe("informal log request schemas", () => { + it("accepts a steward-authored create body and rejects tenant identity keys", () => { + expect(parseJsonBody(createInformalLogSchema, validCreate).ok).toBe(true); + expect( + parseJsonBody(createInformalLogSchema, { + ...validCreate, + unionId: "other-union", + localId: "other-local", + loggedById: "attacker", + }).ok, + ).toBe(false); + }); + + it("rejects empty topic, unknown channel, and non-ISO dates", () => { + expect( + parseJsonBody(createInformalLogSchema, { ...validCreate, topic: "" }).ok, + ).toBe(false); + expect( + parseJsonBody(createInformalLogSchema, { + ...validCreate, + channel: "sms", + }).ok, + ).toBe(false); + expect( + parseJsonBody(createInformalLogSchema, { + ...validCreate, + occurredAt: "yesterday", + }).ok, + ).toBe(false); + }); + + it("allows partial updates and still blocks mass-assigned tenant fields", () => { + expect( + parseJsonBody(updateInformalLogSchema, { summary: "Updated note" }).ok, + ).toBe(true); + expect( + parseJsonBody(updateInformalLogSchema, { + summary: "Updated note", + unionId: "other-union", + convertedToGrievanceId: "grev-forged", + }).ok, + ).toBe(false); + }); +});