feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import { projectRouter } from "../router/project.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
@@ -26,6 +29,19 @@ vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../ai-client.js", () => ({
|
||||
isDalleConfigured: vi.fn().mockReturnValue(false),
|
||||
createDalleClient: vi.fn(),
|
||||
@@ -155,6 +171,47 @@ describe("project router", () => {
|
||||
expect(db.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs and swallows background cache and webhook failures during create", async () => {
|
||||
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
|
||||
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
|
||||
|
||||
const created = { ...sampleProject, id: "project_safe_create" };
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
shortCode: "SAFE-001",
|
||||
name: "Safe Project",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
winProbability: 80,
|
||||
budgetCents: 500_000_00,
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-06-30"),
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result.id).toBe("project_safe_create");
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
|
||||
"Project background side effect failed",
|
||||
);
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.created" }),
|
||||
"Project background side effect failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CONFLICT when shortCode already exists", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
@@ -208,7 +265,7 @@ describe("project router", () => {
|
||||
// ─── getById ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getById", () => {
|
||||
it("returns the correct project with allocations and demands", async () => {
|
||||
it("returns the correct project with allocations and demands for controller-level access", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }),
|
||||
@@ -218,7 +275,7 @@ describe("project router", () => {
|
||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getById({ id: "project_1" });
|
||||
|
||||
expect(result.id).toBe("project_1");
|
||||
@@ -236,11 +293,22 @@ describe("project router", () => {
|
||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createControllerCaller(db);
|
||||
await expect(caller.getById({ id: "missing" })).rejects.toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks USER role from loading full project planning context", async () => {
|
||||
const db = {
|
||||
project: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.getById({ id: "project_1" })).rejects.toThrow(
|
||||
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShoringRatio", () => {
|
||||
@@ -292,7 +360,7 @@ describe("project router", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||
|
||||
expect(result.totalHours).toBe(24);
|
||||
@@ -373,6 +441,38 @@ describe("project router", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs and swallows background failures during status changes", async () => {
|
||||
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
|
||||
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
|
||||
|
||||
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
||||
const db = {
|
||||
project: {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
},
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.updateStatus({
|
||||
id: "project_1",
|
||||
status: ProjectStatus.COMPLETED,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result.status).toBe(ProjectStatus.COMPLETED);
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
|
||||
"Project background side effect failed",
|
||||
);
|
||||
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.status_changed" }),
|
||||
"Project background side effect failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batchUpdateStatus ────────────────────────────────────────────────────
|
||||
@@ -547,4 +647,212 @@ describe("project router", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant-facing detail routes", () => {
|
||||
it("returns lightweight project search summaries from the canonical router", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
client: { name: "Acme Mobility" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "project_1",
|
||||
code: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: "ACTIVE",
|
||||
start: "2026-01-01",
|
||||
end: "2026-03-31",
|
||||
client: "Acme Mobility",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns formatted project search summaries from the canonical router", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
budgetCents: 500000,
|
||||
winProbability: 100,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
client: { name: "Acme Mobility" },
|
||||
_count: { assignments: 3, estimates: 1 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "project_1",
|
||||
code: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: "ACTIVE",
|
||||
budget: "5.000,00 EUR",
|
||||
winProbability: "100%",
|
||||
start: "2026-01-01",
|
||||
end: "2026-03-31",
|
||||
client: "Acme Mobility",
|
||||
assignmentCount: 3,
|
||||
estimateCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks USER role from detailed project search summaries", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 }),
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
|
||||
it("returns lightweight project identifier reads from the canonical router", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getByIdentifier({ identifier: "GDM" });
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns formatted project details from the canonical router", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
shortCode: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: ProjectStatus.ACTIVE,
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 500000,
|
||||
winProbability: 100,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||
responsiblePerson: "Bruce Banner",
|
||||
client: { name: "Acme Mobility" },
|
||||
utilizationCategory: { code: "BILLABLE", name: "Billable" },
|
||||
_count: { assignments: 3, estimates: 1 },
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
resource: { displayName: "Bruce Banner", eid: "EMP-001" },
|
||||
role: "Lead",
|
||||
status: "ACTIVE",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-02-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-02-28T00:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getByIdentifierDetail({ identifier: "GDM" });
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "project_1",
|
||||
code: "GDM",
|
||||
name: "Gelddruckmaschine",
|
||||
status: "ACTIVE",
|
||||
orderType: "CHARGEABLE",
|
||||
allocationType: "INT",
|
||||
budget: "5.000,00 EUR",
|
||||
budgetCents: 500000,
|
||||
winProbability: "100%",
|
||||
start: "2026-01-01",
|
||||
end: "2026-03-31",
|
||||
responsible: "Bruce Banner",
|
||||
client: "Acme Mobility",
|
||||
category: "Billable",
|
||||
assignmentCount: 3,
|
||||
estimateCount: 1,
|
||||
topAllocations: [
|
||||
{
|
||||
resource: "Bruce Banner",
|
||||
eid: "EMP-001",
|
||||
role: "Lead",
|
||||
status: "ACTIVE",
|
||||
hoursPerDay: 8,
|
||||
start: "2026-02-01",
|
||||
end: "2026-02-28",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks USER role from detailed project identifier reads", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.getByIdentifierDetail({ identifier: "GDM" }),
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user