feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { createNotification } from "../lib/create-notification.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { vacationRouter } from "../router/vacation.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
@@ -33,6 +36,15 @@ vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(vacationRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
@@ -163,6 +175,9 @@ describe("vacation router", () => {
|
||||
describe("list", () => {
|
||||
it("returns vacations with default filters", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([sampleVacation]),
|
||||
},
|
||||
@@ -183,6 +198,9 @@ describe("vacation router", () => {
|
||||
|
||||
it("applies resourceId filter", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -198,8 +216,48 @@ describe("vacation router", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes regular users to their own resource when no filter is provided", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await caller.list({});
|
||||
|
||||
expect(db.vacation.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ resourceId: "res_own" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forbids regular users from listing another resource's vacations", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.list({ resourceId: "res_other" })).rejects.toThrow(
|
||||
"You can only view vacation data for your own resource",
|
||||
);
|
||||
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies status and type filters", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -229,11 +287,110 @@ describe("vacation router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("background side effects", () => {
|
||||
it("logs and swallows async notification failures during approval", async () => {
|
||||
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
|
||||
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||
...(select.user
|
||||
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.PENDING,
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.approve({ id: "vac_1" });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result.status).toBe(VacationStatus.APPROVED);
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
effectName: "notifyVacationStatus",
|
||||
vacationId: "vac_1",
|
||||
resourceId: "res_1",
|
||||
newStatus: VacationStatus.APPROVED,
|
||||
}),
|
||||
"Vacation background side effect failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs and swallows webhook failures during approval", async () => {
|
||||
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
|
||||
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||
...(select.user
|
||||
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.PENDING,
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.approve({ id: "vac_1" });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result.status).toBe(VacationStatus.APPROVED);
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectName: "dispatchWebhooks", event: "vacation.approved" }),
|
||||
"Vacation background side effect failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("returns vacation by id", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
resource: { ...sampleVacation.resource, userId: "user_1" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -248,6 +405,23 @@ describe("vacation router", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forbids regular users from reading another user's vacation", async () => {
|
||||
const db = {
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
requestedById: "someone_else",
|
||||
resource: { ...sampleVacation.resource, userId: "someone_else" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.getById({ id: "vac_1" })).rejects.toThrow(
|
||||
"You can only view your own vacation data",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
vacation: {
|
||||
@@ -890,6 +1064,9 @@ describe("vacation router", () => {
|
||||
describe("getForResource", () => {
|
||||
it("returns approved vacations in date range", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -920,6 +1097,27 @@ describe("vacation router", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forbids regular users from reading another resource's approved vacations", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.getForResource({
|
||||
resourceId: "res_other",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
}),
|
||||
).rejects.toThrow("You can only view vacation data for your own resource");
|
||||
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPendingApprovals", () => {
|
||||
@@ -952,6 +1150,7 @@ describe("vacation router", () => {
|
||||
it("returns overlapping vacations for the same chapter", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
|
||||
},
|
||||
vacation: {
|
||||
@@ -987,6 +1186,7 @@ describe("vacation router", () => {
|
||||
it("returns empty array when resource has no chapter", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
|
||||
},
|
||||
};
|
||||
@@ -1000,6 +1200,76 @@ describe("vacation router", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("forbids regular users from reading another resource's team overlap", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.getTeamOverlap({
|
||||
resourceId: "res_other",
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-05"),
|
||||
}),
|
||||
).rejects.toThrow("You can only view vacation data for your own resource");
|
||||
expect(db.resource.findUnique).not.toHaveBeenCalled();
|
||||
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamOverlapDetail", () => {
|
||||
it("returns assistant-friendly overlap detail from the canonical overlap query", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ displayName: "Bruce Banner", chapter: "CGI" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
...sampleVacation,
|
||||
id: "vac_other",
|
||||
resourceId: "res_2",
|
||||
status: VacationStatus.APPROVED,
|
||||
resource: { id: "res_2", displayName: "Clark Kent", eid: "E-002" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getTeamOverlapDetail({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-08-10T00:00:00.000Z"),
|
||||
endDate: new Date("2026-08-12T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
resource: "Bruce Banner",
|
||||
chapter: "CGI",
|
||||
period: "2026-08-10 to 2026-08-12",
|
||||
overlapCount: 1,
|
||||
overlappingVacations: [
|
||||
{
|
||||
resource: "Clark Kent",
|
||||
type: VacationType.ANNUAL,
|
||||
status: VacationStatus.APPROVED,
|
||||
start: "2026-06-01",
|
||||
end: "2026-06-05",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchCreatePublicHolidays", () => {
|
||||
|
||||
Reference in New Issue
Block a user