Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/lib/informal-log/access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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([])).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);
});
});
222 changes: 222 additions & 0 deletions src/lib/officer-learning/api-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { 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 getMe, PUT as putMe } from "@/app/api/officer-learning/me/route";
import {
GET as getLocalSettings,
PUT as putLocalSettings,
} from "@/app/api/officer-learning/local-settings/route";
import { GET as getLocalReport } from "@/app/api/officer-learning/local-report/route";
import { resetOfficerLearningMemoryForTests } from "./memory-adapter";
import { memoryOfficerLearningStore } from "./memory-adapter";
import { resetOfficerLearningStoreSingleton } from "./store";

function session(input?: {
id?: string;
unionId?: string;
localId?: string;
name?: string;
roles?: UserRole[];
}) {
return {
user: {
id: input?.id ?? "user-1",
name: input?.name ?? "Alex Steward",
unionId: input?.unionId ?? "union-a",
localId: input?.localId ?? "local-1",
roles: input?.roles ?? (["local_steward"] as UserRole[]),
},
};
}

function jsonRequest(body: unknown): Request {
return {
json: async () => body,
} as Request;
}

function invalidJsonRequest(): Request {
return {

Check failure on line 47 in src/lib/officer-learning/api-routes.test.ts

View workflow job for this annotation

GitHub Actions / test-and-build

Conversion of type '{ json: () => Promise<never>; }' to type 'Request' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as Request;
}

const validMeBody = {
displayName: "Alex Steward",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": {
status: "completed" as const,
scrollDepth: 100,
quizPassed: true,
},
},
};

describe("officer learning API routes", () => {
beforeEach(() => {
resetOfficerLearningMemoryForTests();
resetOfficerLearningStoreSingleton();
authMock.mockReset();
});

describe("GET /api/officer-learning/me", () => {
it("returns 401 when the session is missing tenant identity", async () => {
authMock.mockResolvedValue(null);
const res = await getMe();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorized" });

authMock.mockResolvedValue({
user: { id: "user-1", unionId: "union-a" },
});
expect((await getMe()).status).toBe(401);
});

it("returns an empty personal record when none is stored", async () => {
authMock.mockResolvedValue(session());
const res = await getMe();
expect(res.status).toBe(200);
const body = (await res.json()) as {
record: { userId: string; hubSyncEnabled: boolean; modules: unknown };
};
expect(body.record.userId).toBe("user-1");
expect(body.record.hubSyncEnabled).toBe(false);
expect(body.record.modules).toEqual({});
});
});

describe("PUT /api/officer-learning/me", () => {
it("rejects unauthenticated, invalid JSON, and schema-invalid bodies", async () => {
authMock.mockResolvedValue(null);
expect((await putMe(jsonRequest(validMeBody))).status).toBe(401);

authMock.mockResolvedValue(session());
expect((await putMe(invalidJsonRequest())).status).toBe(400);

const invalid = await putMe(
jsonRequest({ ...validMeBody, hubSyncEnabled: false }),
);
expect(invalid.status).toBe(400);
expect(await invalid.json()).toMatchObject({ error: "Validation failed" });
});

it("persists under the session tenant and ignores forged unionId in the body", async () => {
authMock.mockResolvedValue(session());
const res = await putMe(
jsonRequest({
...validMeBody,
unionId: "other-union",
userId: "attacker",
}),
);
expect(res.status).toBe(400);

const ok = await putMe(jsonRequest(validMeBody));
expect(ok.status).toBe(200);

const stored = await memoryOfficerLearningStore.getUser(
"union-a",
"user-1",
);
expect(stored?.unionId).toBe("union-a");
expect(stored?.localId).toBe("local-1");
expect(stored?.modules["module-1"]?.quizPassed).toBe(true);
expect(
await memoryOfficerLearningStore.getUser("other-union", "user-1"),
).toBeNull();
});
});

describe("local settings and report", () => {
it("returns 403 when a steward tries to manage or read the local report", async () => {
authMock.mockResolvedValue(session({ roles: ["local_steward"] }));
expect((await getLocalSettings()).status).toBe(403);
expect((await putLocalSettings(jsonRequest({ reportingEnabled: true }))).status).toBe(
403,
);
expect((await getLocalReport()).status).toBe(403);
});

it("lets a president enable reporting and only lists their union and local", async () => {
await memoryOfficerLearningStore.upsertUser({
userId: "other-union-user",
unionId: "union-b",
localId: "local-1",
displayName: "Other union",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": {
status: "completed",
scrollDepth: 100,
quizPassed: true,
},
},
});
await memoryOfficerLearningStore.saveLocalSettings({
unionId: "union-b",
localId: "local-1",
reportingEnabled: true,
updatedById: "pres-b",
updatedAt: new Date().toISOString(),
});

authMock.mockResolvedValue(
session({ id: "pres-1", roles: ["local_president"] }),
);

const beforeEnable = await getLocalSettings();
expect(beforeEnable.status).toBe(200);
expect(
((await beforeEnable.json()) as { settings: { reportingEnabled: boolean } })
.settings.reportingEnabled,
).toBe(false);

const put = await putLocalSettings(jsonRequest({ reportingEnabled: true }));
expect(put.status).toBe(200);

await memoryOfficerLearningStore.upsertUser({
userId: "user-1",
unionId: "union-a",
localId: "local-1",
displayName: "Alex Steward",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": {
status: "completed",
scrollDepth: 100,
quizPassed: true,
},
},
});

const report = await getLocalReport();
expect(report.status).toBe(200);
const body = (await report.json()) as {
rows: Array<{ userId: string; displayName: string }>;
};
expect(body.rows).toHaveLength(1);
expect(body.rows[0].userId).toBe("user-1");
expect(body.rows.map((row) => row.displayName)).not.toContain("Other union");
});

it("returns 401 for local settings without a session", async () => {
authMock.mockResolvedValue(null);
expect((await getLocalSettings()).status).toBe(401);
expect((await getLocalReport()).status).toBe(401);
});
});
});
63 changes: 62 additions & 1 deletion src/lib/officer-learning/hub-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
saveOfficerLearningLocalSettings,
upsertOfficerLearningUser,
} from "./hub-store";
import { canManageOfficerLearningReport } from "./access";
import {
canManageOfficerLearningReport,
canSyncOfficerLearning,
} from "./access";
import { officerLearningDbBackend } from "@/lib/db/backend";

describe("officer learning hub store", () => {
Expand Down Expand Up @@ -74,13 +77,71 @@ describe("officer learning hub store", () => {
).toBe(false);
expect(await getOfficerLearningUser("union-a", "missing")).toBeNull();
});

it("never lists completions from another union, even with a matching localId", async () => {
await saveOfficerLearningLocalSettings({
unionId: "union-a",
localId: "local-1",
reportingEnabled: true,
updatedById: "pres-1",
updatedAt: new Date().toISOString(),
});
await upsertOfficerLearningUser({
userId: "u-b",
unionId: "union-b",
localId: "local-1",
displayName: "Other union",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": { status: "completed", scrollDepth: 100, quizPassed: true },
},
});

expect(await listSharedCompletionsForLocal("union-a", "local-1")).toEqual(
[],
);
});

it("never lists completions from another local in the same union", async () => {
await saveOfficerLearningLocalSettings({
unionId: "union-a",
localId: "local-1",
reportingEnabled: true,
updatedById: "pres-1",
updatedAt: new Date().toISOString(),
});
await upsertOfficerLearningUser({
userId: "u-other-local",
unionId: "union-a",
localId: "local-2",
displayName: "Other local",
hubSyncEnabled: true,
shareWithLocal: true,
modules: {
"module-1": { status: "completed", scrollDepth: 100, quizPassed: true },
},
});

expect(await listSharedCompletionsForLocal("union-a", "local-1")).toEqual(
[],
);
});
});

describe("officer learning access", () => {
it("allows presidents and execs to manage reports", () => {
expect(canManageOfficerLearningReport(["local_president"])).toBe(true);
expect(canManageOfficerLearningReport(["local_exec"])).toBe(true);
expect(canManageOfficerLearningReport(["union_admin"])).toBe(true);
expect(canManageOfficerLearningReport(["local_steward"])).toBe(false);
expect(canManageOfficerLearningReport([])).toBe(false);
});

it("lets any signed-in role sync personal progress", () => {
expect(canSyncOfficerLearning(["local_steward"])).toBe(true);
expect(canSyncOfficerLearning(["local_member"])).toBe(true);
expect(canSyncOfficerLearning([])).toBe(false);
});
});

Expand Down
Loading
Loading