diff --git a/e2e/officer-learning.smoke.spec.ts b/e2e/officer-learning.smoke.spec.ts index 8d0b0d19..93a00ba2 100644 --- a/e2e/officer-learning.smoke.spec.ts +++ b/e2e/officer-learning.smoke.spec.ts @@ -114,7 +114,7 @@ test.describe("Officer Learning @smoke", () => { await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); await expect(page.getByText("Officer Learning module")).toBeVisible(); await expect( - page.getByRole("link", { name: /Contract Enforcement/i }), + page.getByRole("link", { name: "Duty of Fair Representation →" }), ).toBeVisible(); await expectNoSeriousA11yViolations(page); }); diff --git a/e2e/tools.export.smoke.spec.ts b/e2e/tools.export.smoke.spec.ts index 3f43de5e..e788fcea 100644 --- a/e2e/tools.export.smoke.spec.ts +++ b/e2e/tools.export.smoke.spec.ts @@ -113,6 +113,7 @@ test.describe("Tool export output smoke @smoke", () => { }); test("Graphic Maker PNG keeps brand field and type ink", async ({ page }) => { + test.setTimeout(90_000); await page.setViewportSize({ width: 1280, height: 900 }); await page.goto("/en/tools/graphic-maker/"); await expect( diff --git a/messages/en.json b/messages/en.json index 086da3ab..f3b362bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1496,6 +1496,7 @@ "pathTitle": "Full Officer Learning path (all sixteen modules)" }, "hubSync": { + "panelLabel": "Hub backup and local sharing", "signedOutTitle": "Want a Hub backup?", "signedOutBody": "Sign in to Officer Hub to save completions to your account. Sharing with your local stays optional and needs your consent.", "signIn": "Sign in to Officer Hub", diff --git a/src/lib/auth/auth-email-routes.test.ts b/src/lib/auth/auth-email-routes.test.ts new file mode 100644 index 00000000..35228acf --- /dev/null +++ b/src/lib/auth/auth-email-routes.test.ts @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET as emailStatus } from "@/app/api/auth/email-status/route"; +import { POST as forgotPassword } from "@/app/api/auth/forgot-password/route"; +import { + GET as getResetToken, + POST as resetPassword, +} from "@/app/api/auth/reset-password/[token]/route"; +import { POST as signInEmail } from "@/app/api/auth/sign-in-email/route"; +import { auditLog } from "@/lib/audit/store"; +import { acceptInvite, createInvite, findInvitedUser, resetInviteStoreForTests } from "@/lib/auth/invites"; +import { resetPasswordResetStoreForTests } from "@/lib/auth/password-reset"; +import { resetSignInTokenStoreForTests } from "@/lib/auth/sign-in-link"; +import { resetEmailTransportForTests } from "@/lib/email/send"; + +function jsonPost(url: string, body: unknown): Request { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function params(token: string) { + return { params: Promise.resolve({ token }) }; +} + +async function seedInvitee() { + const invite = await createInvite({ + email: "reset.officer@example.ca", + name: "Reset Officer", + unionId: "union-opseu", + localId: "local-243", + roles: ["local_steward"], + invitedById: "admin-1", + }); + const accepted = await acceptInvite(invite.token, "oldpassword1"); + if (!accepted.user) throw new Error("failed to accept test invite"); + return accepted.user; +} + +describe("auth email and password-reset routes", () => { + const envBackup = { ...process.env }; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + beforeEach(() => { + resetInviteStoreForTests(); + resetPasswordResetStoreForTests(); + resetSignInTokenStoreForTests(); + resetEmailTransportForTests(); + warnSpy.mockClear(); + process.env = { ...envBackup }; + delete process.env.EMAIL_ENABLED; + delete process.env.SMTP_HOST; + delete process.env.SMTP_PORT; + delete process.env.SMTP_USER; + delete process.env.SMTP_PASS; + delete process.env.EMAIL_FROM; + }); + + afterEach(() => { + process.env = envBackup; + resetInviteStoreForTests(); + resetPasswordResetStoreForTests(); + resetSignInTokenStoreForTests(); + resetEmailTransportForTests(); + }); + + describe("GET /api/auth/email-status", () => { + it("returns a public SMTP snapshot with no secrets", async () => { + process.env.EMAIL_ENABLED = "true"; + process.env.SMTP_HOST = "smtp.mailgun.org"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_USER = "postmaster@mg.example.com"; + process.env.SMTP_PASS = "super-secret-pass"; + process.env.EMAIL_FROM = "UnionOps "; + + const res = await emailStatus(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + emailEnabled: boolean; + emailFlag: boolean; + smtp: Record; + }; + expect(body.emailEnabled).toBe(true); + expect(body.emailFlag).toBe(true); + expect(body.smtp).toMatchObject({ + host: "smtp.mailgun.org", + port: 587, + from: "UnionOps ", + authConfigured: true, + userPresent: true, + userLooksLikeEmail: true, + }); + expect(JSON.stringify(body)).not.toContain("super-secret-pass"); + expect(body.smtp).not.toHaveProperty("pass"); + expect(body.smtp).not.toHaveProperty("password"); + }); + }); + + describe("POST /api/auth/forgot-password", () => { + it("returns 400 for invalid JSON and invalid email", async () => { + const badJson = await forgotPassword( + new Request("http://localhost/api/auth/forgot-password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }), + ); + expect(badJson.status).toBe(400); + expect(await badJson.json()).toEqual({ error: "Invalid JSON" }); + + const badEmail = await forgotPassword( + jsonPost("http://localhost/api/auth/forgot-password", { + email: "not-an-email", + }), + ); + expect(badEmail.status).toBe(400); + }); + + it("does not enumerate unknown or demo emails", async () => { + const unknown = await forgotPassword( + jsonPost("http://localhost/api/auth/forgot-password", { + email: "nobody@example.ca", + }), + ); + expect(unknown.status).toBe(200); + const unknownBody = (await unknown.json()) as Record; + expect(unknownBody.ok).toBe(true); + expect(unknownBody.emailSent).toBe(false); + expect(unknownBody.smtp).toBeUndefined(); + expect(unknownBody.emailReason).toBeUndefined(); + + const demo = await forgotPassword( + jsonPost("http://localhost/api/auth/forgot-password", { + email: "president.243@unionops.test", + }), + ); + expect(demo.status).toBe(200); + const demoBody = (await demo.json()) as Record; + expect(demoBody.ok).toBe(true); + expect(demoBody.emailSent).toBe(false); + expect(demoBody.smtp).toBeUndefined(); + }); + + it("flattens SMTP diagnostics into audit metadata without leaking the password", async () => { + await seedInvitee(); + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_USER = "user"; + process.env.SMTP_PASS = "super-secret-pass"; + process.env.EMAIL_FROM = "noreply@example.com"; + + const res = await forgotPassword( + jsonPost("http://localhost/api/auth/forgot-password", { + email: "Reset.Officer@example.ca", + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + emailSent: boolean; + emailReason?: string; + smtp?: { host?: string | null; authConfigured?: boolean }; + }; + expect(body.ok).toBe(true); + expect(body.emailSent).toBe(false); + expect(body.emailReason).toBe("not_configured"); + expect(body.smtp?.host).toBe("smtp.example.com"); + expect(body.smtp?.authConfigured).toBe(true); + expect(JSON.stringify(body)).not.toContain("super-secret-pass"); + + const logged = (await auditLog.query({ resourceType: "auth", limit: 50 })).find( + (entry) => + entry.action === "email.password_reset_skipped" && + entry.metadata?.email === "reset.officer@example.ca", + ); + expect(logged?.metadata).toMatchObject({ + source: "invite", + reason: "not_configured", + smtpHost: "smtp.example.com", + smtpPort: "587", + smtpFrom: "noreply@example.com", + smtpAuthConfigured: "true", + }); + expect(logged?.metadata?.smtpJson).toContain("smtp.example.com"); + expect(JSON.stringify(logged?.metadata)).not.toContain("super-secret-pass"); + expect(Object.values(logged?.metadata ?? {}).every((v) => typeof v === "string")).toBe( + true, + ); + }); + }); + + describe("POST /api/auth/sign-in-email", () => { + it("returns 400 for invalid JSON", async () => { + const badJson = await signInEmail( + new Request("http://localhost/api/auth/sign-in-email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }), + ); + expect(badJson.status).toBe(400); + expect(await badJson.json()).toEqual({ error: "Invalid JSON" }); + }); + + it("does not attach SMTP diagnostics for an unknown email", async () => { + const res = await signInEmail( + jsonPost("http://localhost/api/auth/sign-in-email", { + email: "nobody@example.ca", + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.ok).toBe(true); + expect(body.emailSent).toBe(false); + expect(body.smtp).toBeUndefined(); + }); + + it("flattens SMTP diagnostics for a known demo account when mail is not configured", async () => { + process.env.SMTP_HOST = "smtp.example.com"; + process.env.SMTP_PORT = "465"; + process.env.EMAIL_FROM = "noreply@example.com"; + + const res = await signInEmail( + jsonPost("http://localhost/api/auth/sign-in-email", { + email: "president.243@unionops.test", + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + emailSent: boolean; + emailReason?: string; + smtp?: { host?: string | null; port?: number | null; secure?: boolean }; + }; + expect(body.ok).toBe(true); + expect(body.emailSent).toBe(false); + expect(body.emailReason).toBe("not_configured"); + expect(body.smtp?.host).toBe("smtp.example.com"); + expect(body.smtp?.port).toBe(465); + expect(body.smtp?.secure).toBe(true); + + const logged = (await auditLog.query({ resourceType: "auth", limit: 50 })).find( + (entry) => + entry.action === "email.sign_in_link_skipped" && + entry.metadata?.email === "president.243@unionops.test", + ); + expect(logged?.metadata).toMatchObject({ + smtpHost: "smtp.example.com", + smtpPort: "465", + smtpAuthConfigured: "false", + }); + expect(Object.values(logged?.metadata ?? {}).every((v) => typeof v === "string")).toBe( + true, + ); + }); + }); + + describe("GET/POST /api/auth/reset-password/[token]", () => { + it("returns 404 for a missing token and pending for a live one", async () => { + const missing = await getResetToken( + new Request("http://localhost"), + params("no-such-token"), + ); + expect(missing.status).toBe(404); + + const user = await seedInvitee(); + const { createPasswordResetToken } = await import( + "@/lib/auth/password-reset" + ); + const row = await createPasswordResetToken({ + email: user.email, + userId: user.id, + }); + const pending = await getResetToken( + new Request("http://localhost"), + params(row.token), + ); + expect(pending.status).toBe(200); + const body = (await pending.json()) as { status: string; email: string }; + expect(body.status).toBe("pending"); + expect(body.email).toBe("reset.officer@example.ca"); + }); + + it("rejects a short password, then consumes the token once", async () => { + const user = await seedInvitee(); + const { createPasswordResetToken } = await import( + "@/lib/auth/password-reset" + ); + const row = await createPasswordResetToken({ + email: user.email, + userId: user.id, + }); + + const short = await resetPassword( + jsonPost("http://localhost/api/auth/reset-password/x", { + password: "short", + }), + params(row.token), + ); + expect(short.status).toBe(400); + + const ok = await resetPassword( + jsonPost("http://localhost/api/auth/reset-password/x", { + password: "newpassword1", + }), + params(row.token), + ); + expect(ok.status).toBe(200); + const body = (await ok.json()) as { ok: boolean; email: string }; + expect(body.ok).toBe(true); + expect(body.email).toBe("reset.officer@example.ca"); + await expect( + findInvitedUser("reset.officer@example.ca", "oldpassword1"), + ).resolves.toBeNull(); + await expect( + findInvitedUser("reset.officer@example.ca", "newpassword1"), + ).resolves.toMatchObject({ email: "reset.officer@example.ca" }); + + const reused = await resetPassword( + jsonPost("http://localhost/api/auth/reset-password/x", { + password: "anotherpassword1", + }), + params(row.token), + ); + expect(reused.status).toBe(400); + expect(await reused.json()).toEqual({ + error: "Reset link already used", + }); + }); + }); +}); diff --git a/src/lib/auth/mfa-routes.test.ts b/src/lib/auth/mfa-routes.test.ts new file mode 100644 index 00000000..1dbf39ff --- /dev/null +++ b/src/lib/auth/mfa-routes.test.ts @@ -0,0 +1,214 @@ +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 { POST as enrollMfa } from "@/app/api/mfa/enroll/route"; +import { POST as confirmEnroll } from "@/app/api/mfa/enroll/confirm/route"; +import { GET as mfaStatus } from "@/app/api/mfa/status/route"; +import { POST as verifyMfa } from "@/app/api/mfa/verify/route"; +import { resetMfaEnrollmentStoreForTests } from "@/lib/auth/mfa-enrollment-store"; +import { clearMfaGrants } from "@/lib/auth/mfa-grants"; +import { getTotpSecretForUser } from "@/lib/auth/mfa-user-secret"; +import { generateTotp } from "@/lib/auth/totp"; + +function session(input?: { + id?: string; + email?: string; + mfaVerified?: boolean; + roles?: UserRole[]; +}) { + return { + user: { + id: input?.id ?? "user-president-243", + email: input?.email ?? "president.243@unionops.test", + name: "Local 243 President", + unionId: "union-opseu", + localId: "local-243", + roles: input?.roles ?? (["local_president"] as UserRole[]), + mfaVerified: input?.mfaVerified, + }, + }; +} + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost/api/mfa", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("MFA API routes", () => { + const envBackup = { ...process.env }; + + beforeEach(() => { + authMock.mockReset(); + resetMfaEnrollmentStoreForTests(); + clearMfaGrants(); + process.env = { ...envBackup }; + delete process.env.AUTH_MFA_ENABLED; + delete process.env.AUTH_MFA_MODE; + delete process.env.AUTH_MFA_CODE; + delete process.env.AUTH_ALLOW_SHARED_MFA_IN_PROD; + }); + + afterEach(() => { + process.env = envBackup; + resetMfaEnrollmentStoreForTests(); + clearMfaGrants(); + }); + + describe("GET /api/mfa/status", () => { + it("returns 401 without a session", async () => { + authMock.mockResolvedValue(null); + expect((await mfaStatus()).status).toBe(401); + }); + + it("reports MFA off by default even when the session has no grant", async () => { + authMock.mockResolvedValue(session({ mfaVerified: false })); + const res = await mfaStatus(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + enabled: false, + mode: null, + needsEnrollment: false, + mfaVerified: true, + }); + }); + }); + + describe("POST /api/mfa/enroll", () => { + it("returns 401 without a session and 503 when TOTP mode is off", async () => { + authMock.mockResolvedValue(null); + expect((await enrollMfa()).status).toBe(401); + + authMock.mockResolvedValue(session()); + const disabled = await enrollMfa(); + expect(disabled.status).toBe(503); + expect(await disabled.json()).toEqual({ + error: "TOTP enrollment requires AUTH_MFA_MODE=totp on this instance.", + }); + }); + + it("returns a pending secret and otpauth URI without persisting until confirm", async () => { + process.env.AUTH_MFA_ENABLED = "true"; + process.env.AUTH_MFA_MODE = "totp"; + authMock.mockResolvedValue(session()); + + const before = await getTotpSecretForUser("user-president-243"); + const res = await enrollMfa(); + expect(res.status).toBe(200); + const body = (await res.json()) as { secret: string; otpauthUri: string }; + expect(body.secret).toMatch(/^[A-Z2-7]+$/); + expect(body.otpauthUri).toContain("otpauth://totp/"); + expect(body.otpauthUri).toContain(`secret=${body.secret}`); + expect(await getTotpSecretForUser("user-president-243")).toBe(before); + }); + }); + + describe("POST /api/mfa/enroll/confirm", () => { + it("rejects invalid JSON, missing pending enrollment, and a wrong code", async () => { + process.env.AUTH_MFA_ENABLED = "true"; + process.env.AUTH_MFA_MODE = "totp"; + authMock.mockResolvedValue(session()); + + const badJson = await confirmEnroll( + new Request("http://localhost/api/mfa/enroll/confirm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }), + ); + expect(badJson.status).toBe(400); + expect(await badJson.json()).toEqual({ error: "Invalid code" }); + + const noPending = await confirmEnroll(jsonRequest({ code: "123456" })); + expect(noPending.status).toBe(400); + expect(await noPending.json()).toMatchObject({ + error: expect.stringContaining("No pending enrollment"), + }); + + const enrolled = await enrollMfa(); + const { secret } = (await enrolled.json()) as { secret: string }; + const wrong = await confirmEnroll(jsonRequest({ code: "000000" })); + expect(wrong.status).toBe(400); + expect(await getTotpSecretForUser("user-president-243")).not.toBe(secret); + }); + + it("persists the pending secret only after a valid TOTP", async () => { + process.env.AUTH_MFA_ENABLED = "true"; + process.env.AUTH_MFA_MODE = "totp"; + authMock.mockResolvedValue(session()); + + const enrolled = await enrollMfa(); + const { secret } = (await enrolled.json()) as { secret: string }; + const code = generateTotp(secret); + const confirmed = await confirmEnroll(jsonRequest({ code })); + expect(confirmed.status).toBe(200); + expect(await confirmed.json()).toEqual({ success: true }); + expect(await getTotpSecretForUser("user-president-243")).toBe(secret); + }); + }); + + describe("POST /api/mfa/verify", () => { + it("returns 401 without a session and 503 when MFA is disabled", async () => { + authMock.mockResolvedValue(null); + expect((await verifyMfa(jsonRequest({ code: "000000" }))).status).toBe( + 401, + ); + + authMock.mockResolvedValue(session()); + const disabled = await verifyMfa(jsonRequest({ code: "000000" })); + expect(disabled.status).toBe(503); + expect(await disabled.json()).toMatchObject({ + error: expect.stringContaining("MFA is disabled"), + }); + }); + + it("issues a server-minted grant on a valid shared code and never trusts a client boolean", async () => { + process.env.AUTH_MFA_ENABLED = "true"; + process.env.AUTH_MFA_MODE = "shared_code_insecure"; + process.env.AUTH_MFA_CODE = "424242"; + authMock.mockResolvedValue(session({ mfaVerified: true })); + + const wrong = await verifyMfa( + jsonRequest({ code: "000000", mfaVerified: true }), + ); + expect(wrong.status).toBe(400); + + const ok = await verifyMfa(jsonRequest({ code: "424242" })); + expect(ok.status).toBe(200); + const body = (await ok.json()) as { + success: boolean; + mfaGrant: string; + mfaVerified?: boolean; + }; + expect(body.success).toBe(true); + expect(body.mfaGrant).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(body.mfaVerified).toBeUndefined(); + }); + + it("returns needsEnrollment when TOTP is on but the account has no secret", async () => { + process.env.AUTH_MFA_ENABLED = "true"; + process.env.AUTH_MFA_MODE = "totp"; + authMock.mockResolvedValue( + session({ id: "user-solo", email: "solo@example.ca" }), + ); + const res = await verifyMfa(jsonRequest({ code: "123456" })); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ + error: "TOTP is not enrolled for this account.", + needsEnrollment: true, + }); + }); + }); +}); diff --git a/src/lib/bumping/api-routes.test.ts b/src/lib/bumping/api-routes.test.ts new file mode 100644 index 00000000..a38d340a --- /dev/null +++ b/src/lib/bumping/api-routes.test.ts @@ -0,0 +1,542 @@ +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 listCases, POST as createCase } from "@/app/api/bumping/cases/route"; +import { + GET as getCase, + PATCH as patchCase, +} from "@/app/api/bumping/cases/[id]/route"; +import { POST as addNote } from "@/app/api/bumping/cases/[id]/notes/route"; +import { POST as recordDecision } from "@/app/api/bumping/cases/[id]/decision/route"; +import { POST as addSession } from "@/app/api/bumping/cases/[id]/sessions/route"; +import { GET as rankSeniority } from "@/app/api/bumping/seniority/route"; +import { + memoryBumpingStore, + resetBumpingMemoryForTests, +} from "./memory-adapter"; +import { resetBumpingStore } from "./store"; +import { + createOverlayUnion, + resetTenantOverlayForTests, +} from "@/lib/tenant/overlay"; + +function session(input?: { + id?: string; + unionId?: string | null; + localId?: string | null; + name?: 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"), + 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 emptyPosition = { + title: "", + duties: "", + qualifications: "", + seniorityNotes: "", +}; + +const validCreate = { + memberRef: "Member Test", + seniorityDate: "2019-01-01", + currentPosition: "Clerk II", + targetPosition: "Clerk I (vacant)", + scenario: "Layoff bump into a vacant lower classification", +}; + +async function seedForeignCase() { + return memoryBumpingStore.create( + { + ...validCreate, + memberRef: "Foreign member", + incumbentPosition: emptyPosition, + bumpingPosition: emptyPosition, + }, + { + unionId: "union-other", + localId: "local-1", + createdById: "user-other", + }, + ); +} + +describe("bumping API routes", () => { + beforeEach(() => { + resetBumpingMemoryForTests(); + resetBumpingStore(); + resetTenantOverlayForTests(); + authMock.mockReset(); + }); + + afterEach(() => { + resetBumpingMemoryForTests(); + resetBumpingStore(); + resetTenantOverlayForTests(); + }); + + describe("GET /api/bumping/cases", () => { + it("returns 401 without a session and 403 for members", async () => { + authMock.mockResolvedValue(null); + expect((await listCases()).status).toBe(401); + + authMock.mockResolvedValue(session({ roles: ["local_member"] })); + const forbidden = await listCases(); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toEqual({ error: "Forbidden" }); + }); + + it("returns 403 when the bumping module is not enabled for the tenant", async () => { + const tenant = createOverlayUnion({ + name: "Comms Only", + enabledModules: ["comms", "grievance"], + localNumber: "888", + }); + authMock.mockResolvedValue( + session({ + unionId: tenant.union.id, + localId: tenant.locals![0]!.id, + }), + ); + const res = await listCases(); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Module not enabled" }); + }); + + it("does not list another union, and a president does not see sister locals", async () => { + await seedForeignCase(); + await memoryBumpingStore.create( + { + ...validCreate, + memberRef: "Sister local member", + incumbentPosition: emptyPosition, + bumpingPosition: emptyPosition, + }, + { + unionId: "union-opseu", + localId: "local-560", + createdById: "user-president-560", + }, + ); + + authMock.mockResolvedValue(session()); + const res = await listCases(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + cases: Array<{ memberRef: string; unionId: string; localId: string }>; + }; + expect(body.cases.every((c) => c.unionId === "union-opseu")).toBe(true); + expect(body.cases.every((c) => c.localId === "local-243")).toBe(true); + expect(body.cases.map((c) => c.memberRef)).not.toContain("Foreign member"); + expect(body.cases.map((c) => c.memberRef)).not.toContain( + "Sister local member", + ); + }); + + it("lets a union_admin without a local list sister locals in the same union only", async () => { + await seedForeignCase(); + await memoryBumpingStore.create( + { + ...validCreate, + memberRef: "Sister local member", + incumbentPosition: emptyPosition, + bumpingPosition: emptyPosition, + }, + { + unionId: "union-opseu", + localId: "local-560", + createdById: "user-president-560", + }, + ); + + authMock.mockResolvedValue( + session({ + id: "user-union-admin", + localId: null, + roles: ["union_admin"], + }), + ); + const res = await listCases(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + cases: Array<{ memberRef: string; unionId: string; localId: string }>; + }; + expect(body.cases.every((c) => c.unionId === "union-opseu")).toBe(true); + expect(body.cases.map((c) => c.memberRef)).toContain("Sister local member"); + expect(body.cases.map((c) => c.memberRef)).toContain("Member C"); + expect(body.cases.map((c) => c.memberRef)).not.toContain("Foreign member"); + }); + }); + + describe("POST /api/bumping/cases", () => { + it("rejects forged tenant keys and stamps the session union/local/author", async () => { + authMock.mockResolvedValue(session()); + const forged = await createCase( + jsonRequest({ + ...validCreate, + unionId: "union-other", + localId: "local-evil", + createdById: "attacker", + }), + ); + expect(forged.status).toBe(400); + + const created = await createCase(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + bumpingCase: { + unionId: string; + localId: string; + createdById: string; + memberRef: string; + status: string; + }; + }; + expect(body.bumpingCase.unionId).toBe("union-opseu"); + expect(body.bumpingCase.localId).toBe("local-243"); + expect(body.bumpingCase.createdById).toBe("user-president-243"); + expect(body.bumpingCase.memberRef).toBe("Member Test"); + expect(body.bumpingCase.status).toBe("open"); + }); + + it("returns 400 when the session has no local", async () => { + authMock.mockResolvedValue( + session({ localId: null, roles: ["union_admin"] }), + ); + const res = await createCase(jsonRequest(validCreate)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Union and local context required", + }); + }); + + it("returns 403 when a steward or local_exec tries to create a case", async () => { + authMock.mockResolvedValue(session({ roles: ["local_steward"] })); + expect((await createCase(jsonRequest(validCreate))).status).toBe(403); + + authMock.mockResolvedValue(session({ roles: ["local_exec"] })); + expect((await createCase(jsonRequest(validCreate))).status).toBe(403); + }); + + it("lets a stability_member create a case", async () => { + authMock.mockResolvedValue( + session({ + id: "user-stability-243", + name: "Stability Rep", + roles: ["stability_member"], + }), + ); + const created = await createCase(jsonRequest(validCreate)); + expect(created.status).toBe(201); + const body = (await created.json()) as { + bumpingCase: { createdById: string }; + }; + expect(body.bumpingCase.createdById).toBe("user-stability-243"); + }); + }); + + describe("GET/PATCH /api/bumping/cases/[id]", () => { + it("returns 404 for a missing id", async () => { + authMock.mockResolvedValue(session()); + const missing = await getCase( + new Request("http://localhost"), + params("bump-missing"), + ); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: "Not found" }); + }); + + it("returns 403 for another union, including platform_admin, and does not mutate", async () => { + const foreign = await seedForeignCase(); + + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + const viewed = await getCase( + new Request("http://localhost"), + params(foreign.bumpingCase.id), + ); + expect(viewed.status).toBe(403); + expect(await viewed.json()).toEqual({ error: "Forbidden" }); + + const patched = await patchCase( + jsonRequest({ status: "closed" }), + params(foreign.bumpingCase.id), + ); + expect(patched.status).toBe(403); + expect( + (await memoryBumpingStore.getById(foreign.bumpingCase.id))?.bumpingCase + .status, + ).toBe("open"); + }); + + it("lets a steward and local_exec read a local case but not patch it", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + const viewed = await getCase( + new Request("http://localhost"), + params("bump-001"), + ); + expect(viewed.status).toBe(200); + const patched = await patchCase( + jsonRequest({ status: "closed" }), + params("bump-001"), + ); + expect(patched.status).toBe(403); + + authMock.mockResolvedValue( + session({ id: "user-exec-243", roles: ["local_exec"] }), + ); + expect( + (await getCase(new Request("http://localhost"), params("bump-001"))) + .status, + ).toBe(200); + expect( + (await patchCase(jsonRequest({ status: "closed" }), params("bump-001"))) + .status, + ).toBe(403); + expect( + (await memoryBumpingStore.getById("bump-001"))?.bumpingCase.status, + ).toBe("in_review"); + }); + + it("rejects extra tenant keys on PATCH then updates status from the session tenant", async () => { + authMock.mockResolvedValue(session()); + const forged = await patchCase( + jsonRequest({ status: "closed", unionId: "union-other" }), + params("bump-001"), + ); + expect(forged.status).toBe(400); + + const updated = await patchCase( + jsonRequest({ status: "closed" }), + params("bump-001"), + ); + expect(updated.status).toBe(200); + const body = (await updated.json()) as { + bumpingCase: { status: string; unionId: string; localId: string }; + }; + expect(body.bumpingCase.status).toBe("closed"); + expect(body.bumpingCase.unionId).toBe("union-opseu"); + expect(body.bumpingCase.localId).toBe("local-243"); + }); + }); + + describe("POST notes/decision/sessions", () => { + it("rejects blank note bodies and stamps the session author, ignoring forged keys", async () => { + authMock.mockResolvedValue(session()); + const blank = await addNote( + jsonRequest({ body: " " }), + params("bump-001"), + ); + expect(blank.status).toBe(400); + expect(await blank.json()).toEqual({ error: "body is required" }); + + const created = await addNote( + jsonRequest({ + body: "Need HR clarification on supervisory duties.", + authorId: "attacker", + authorName: "Hacker", + }), + params("bump-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + note: { authorId: string; authorName: string; body: string }; + }; + expect(body.note.authorId).toBe("user-president-243"); + expect(body.note.authorName).toBe("Local 243 President"); + expect(body.note.body).toBe( + "Need HR clarification on supervisory duties.", + ); + }); + + it("requires outcome and rationale, then stamps recordedById from the session", async () => { + authMock.mockResolvedValue(session()); + const missing = await recordDecision( + jsonRequest({ outcome: "Proceed" }), + params("bump-001"), + ); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ + error: "outcome and rationale are required", + }); + + const created = await recordDecision( + jsonRequest({ + outcome: "Proceed with bump", + rationale: "Seniority and qualifications verified.", + recordedById: "attacker", + }), + params("bump-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + decision: { recordedById: string; outcome: string }; + }; + expect(body.decision.recordedById).toBe("user-president-243"); + expect(body.decision.outcome).toBe("Proceed with bump"); + expect( + (await memoryBumpingStore.getById("bump-001"))?.bumpingCase.status, + ).toBe("decided"); + }); + + it("requires date and agenda, then stamps createdById from the session", async () => { + authMock.mockResolvedValue(session()); + const missing = await addSession( + jsonRequest({ date: "2026-09-10" }), + params("bump-001"), + ); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ + error: "date and agenda are required", + }); + + const created = await addSession( + jsonRequest({ + date: "2026-09-10", + agenda: "Review position descriptions", + attendees: ["Chair"], + createdById: "attacker", + }), + params("bump-001"), + ); + expect(created.status).toBe(201); + const body = (await created.json()) as { + session: { createdById: string; agenda: string }; + }; + expect(body.session.createdById).toBe("user-president-243"); + expect(body.session.agenda).toBe("Review position descriptions"); + }); + + it("returns 403 when a steward writes notes, decisions, or sessions", async () => { + authMock.mockResolvedValue( + session({ id: "user-steward-243", roles: ["local_steward"] }), + ); + expect( + (await addNote(jsonRequest({ body: "Nope" }), params("bump-001"))) + .status, + ).toBe(403); + expect( + ( + await recordDecision( + jsonRequest({ outcome: "X", rationale: "Y" }), + params("bump-001"), + ) + ).status, + ).toBe(403); + expect( + ( + await addSession( + jsonRequest({ date: "2026-09-10", agenda: "Nope" }), + params("bump-001"), + ) + ).status, + ).toBe(403); + }); + + it("returns 403 for another union's case, including platform_admin", async () => { + const foreign = await seedForeignCase(); + authMock.mockResolvedValue(session({ roles: ["platform_admin"] })); + expect( + ( + await addNote( + jsonRequest({ body: "Hijack" }), + params(foreign.bumpingCase.id), + ) + ).status, + ).toBe(403); + expect( + ( + await recordDecision( + jsonRequest({ outcome: "X", rationale: "Y" }), + params(foreign.bumpingCase.id), + ) + ).status, + ).toBe(403); + expect( + ( + await addSession( + jsonRequest({ date: "2026-09-10", agenda: "Hijack" }), + params(foreign.bumpingCase.id), + ) + ).status, + ).toBe(403); + }); + }); + + describe("GET /api/bumping/seniority", () => { + it("returns 401 without a session and 400 without classification", async () => { + authMock.mockResolvedValue(null); + expect( + ( + await rankSeniority( + new Request("http://localhost/api/bumping/seniority"), + ) + ).status, + ).toBe(401); + + authMock.mockResolvedValue(session()); + const missing = await rankSeniority( + new Request("http://localhost/api/bumping/seniority"), + ); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ + error: "classification query parameter required", + }); + }); + + it("ranks only the session local and excludes inactive plus other locals", async () => { + authMock.mockResolvedValue(session()); + const res = await rankSeniority( + new Request( + "http://localhost/api/bumping/seniority?classification=Administrative%20Assistant%20I", + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + advisory: boolean; + ranked: Array<{ memberRef: string; localId: string; active: boolean }>; + }; + expect(body.advisory).toBe(true); + expect(body.ranked.map((r) => r.memberRef)).toEqual([ + "Member A", + "Member E", + "Member B", + ]); + expect(body.ranked.every((r) => r.localId === "local-243")).toBe(true); + expect(body.ranked.every((r) => r.active)).toBe(true); + expect(body.ranked.map((r) => r.memberRef)).not.toContain( + "Member Other Local", + ); + expect(body.ranked.map((r) => r.memberRef)).not.toContain("Member D"); + }); + }); +}); diff --git a/src/lib/bumping/memory-adapter.ts b/src/lib/bumping/memory-adapter.ts index a0f8f6a1..573f30be 100644 --- a/src/lib/bumping/memory-adapter.ts +++ b/src/lib/bumping/memory-adapter.ts @@ -297,5 +297,28 @@ export class MemoryBumpingAdapter implements BumpingAdapter { export const memoryBumpingStore: BumpingAdapter = new MemoryBumpingAdapter(); +const bumpingMemorySeed = { + cases: structuredClone(cases), + sessions: structuredClone(sessions), + notes: structuredClone(notes), + decisions: structuredClone(decisions), +}; + +/** @internal test helper — restores demo seed so mutating tests stay isolated. */ +export function resetBumpingMemoryForTests(): void { + cases.splice(0, cases.length, ...structuredClone(bumpingMemorySeed.cases)); + sessions.splice( + 0, + sessions.length, + ...structuredClone(bumpingMemorySeed.sessions), + ); + notes.splice(0, notes.length, ...structuredClone(bumpingMemorySeed.notes)); + decisions.splice( + 0, + decisions.length, + ...structuredClone(bumpingMemorySeed.decisions), + ); +} + /** @deprecated Prefer `@/lib/bumping/store` */ export const bumpingStore = memoryBumpingStore; diff --git a/src/lib/officer-learning/diagram-timeline-i18n.test.ts b/src/lib/officer-learning/diagram-timeline-i18n.test.ts index 5a763732..4b781cc2 100644 --- a/src/lib/officer-learning/diagram-timeline-i18n.test.ts +++ b/src/lib/officer-learning/diagram-timeline-i18n.test.ts @@ -72,4 +72,13 @@ describe("officer learning diagram/timeline i18n", () => { ).toBe(true); } }); + + it("keeps EN/FR Hub sync panel labels so the dashboard cannot MISSING_MESSAGE", () => { + expect( + hasPath(en as Nested, ["officerLearning", "hubSync", "panelLabel"]), + ).toBe(true); + expect( + hasPath(fr as Nested, ["officerLearning", "hubSync", "panelLabel"]), + ).toBe(true); + }); });