feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
@@ -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" }));
});
});
});