test(api): harden estimate races and user auth boundaries

This commit is contained in:
2026-03-30 12:32:51 +02:00
parent 3c4894a966
commit 019c267435
3 changed files with 192 additions and 1 deletions
@@ -2889,6 +2889,113 @@ describe("assistant import/export and dispo tools", () => {
}); });
}); });
it("returns stable assistant errors when estimate creation loses referenced records mid-write", async () => {
const cases = [
{
name: "missing resource reference",
payload: {
name: "Delivery Estimate",
demandLines: [
{
resourceId: "resource_1",
lineType: "LABOR",
name: "Animation",
hours: 40,
costRateCents: 0,
billRateCents: 0,
currency: "EUR",
costTotalCents: 0,
priceTotalCents: 0,
monthlySpread: {},
staffingAttributes: {},
metadata: {},
},
],
},
rejection: {
code: "P2003",
message: "Foreign key constraint failed",
meta: { field_name: "EstimateDemandLine_resourceId_fkey" },
},
expected: "Resource not found with the given criteria.",
},
{
name: "missing scope item reference",
payload: {
name: "Delivery Estimate",
scopeItems: [
{
sequenceNo: 1,
scopeType: "SHOT",
name: "Shot 010",
technicalSpec: {},
metadata: {},
},
],
demandLines: [
{
scopeItemId: "scope_item_missing",
lineType: "LABOR",
name: "Lighting",
hours: 24,
costRateCents: 0,
billRateCents: 0,
currency: "EUR",
costTotalCents: 0,
priceTotalCents: 0,
monthlySpread: {},
staffingAttributes: {},
metadata: {},
},
],
},
rejection: {
code: "P2003",
message: "Foreign key constraint failed",
meta: { field_name: "EstimateDemandLine_scopeItemId_fkey" },
},
expected: "Estimate scope item not found with the given criteria.",
},
{
name: "generic referenced record race",
payload: { name: "Delivery Estimate" },
rejection: {
code: "P2025",
message: "Record to create no longer references a valid row",
meta: { cause: "Dependent record disappeared during nested estimate create" },
},
expected: "One of the referenced project, role, resource, or scope items no longer exists.",
},
] as const;
for (const testCase of cases) {
const ctx = createToolContext(
{
estimate: {
create: vi.fn().mockRejectedValue(testCase.rejection),
},
rateCardLine: {
findMany: vi.fn().mockResolvedValue([]),
},
},
{
userRole: SystemRole.MANAGER,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"create_estimate",
JSON.stringify(testCase.payload),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: testCase.expected,
});
}
});
it("returns stable assistant errors for estimate mutation tools backed by estimate application use-cases", async () => { it("returns stable assistant errors for estimate mutation tools backed by estimate application use-cases", async () => {
const cases = [ const cases = [
{ {
@@ -34,6 +34,57 @@ function createContext(
} }
describe("user router authorization", () => { describe("user router authorization", () => {
it("requires authentication for self-service profile lookups", async () => {
const findUnique = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
},
}, { session: false }));
await expect(caller.me()).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUnique).not.toHaveBeenCalled();
});
it("requires authentication for dashboard layout reads", async () => {
const findUnique = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
},
}, { session: false }));
await expect(caller.getDashboardLayout()).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUnique).not.toHaveBeenCalled();
});
it("requires authentication for favorite project toggles", async () => {
const findUnique = vi.fn();
const update = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
update,
},
}, { session: false }));
await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUnique).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("forbids regular users from listing assignable users", async () => { it("forbids regular users from listing assignable users", async () => {
const findMany = vi.fn(); const findMany = vi.fn();
const caller = createCaller(createContext({ const caller = createCaller(createContext({
@@ -105,6 +156,36 @@ describe("user router authorization", () => {
expect(findUnique).not.toHaveBeenCalled(); expect(findUnique).not.toHaveBeenCalled();
}); });
it("forbids non-admin users from linking resources", async () => {
const userFindUnique = vi.fn();
const resourceFindUnique = vi.fn();
const updateMany = vi.fn();
const update = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique: userFindUnique,
},
resource: {
findUnique: resourceFindUnique,
updateMany,
update,
},
}, { role: SystemRole.MANAGER }));
await expect(caller.linkResource({
userId: "user_2",
resourceId: "resource_1",
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Admin role required",
});
expect(userFindUnique).not.toHaveBeenCalled();
expect(resourceFindUnique).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("keeps TOTP verification public for the login flow", async () => { it("keeps TOTP verification public for the login flow", async () => {
const findUniqueOrThrow = vi.fn().mockResolvedValue({ const findUniqueOrThrow = vi.fn().mockResolvedValue({
id: "user_1", id: "user_1",
+4 -1
View File
@@ -1683,7 +1683,7 @@ function getTrpcErrorMetadata(error: unknown): {
message?: unknown; message?: unknown;
cause?: unknown; cause?: unknown;
data?: { code?: unknown }; data?: { code?: unknown };
shape?: { code?: unknown; message?: unknown }; shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } };
}; };
const candidateCode = typeof candidate.code === "string" const candidateCode = typeof candidate.code === "string"
@@ -1709,6 +1709,9 @@ function getTrpcErrorMetadata(error: unknown): {
if ("cause" in candidate) { if ("cause" in candidate) {
queue.push(candidate.cause); queue.push(candidate.cause);
} }
if (candidate.shape?.data?.cause) {
queue.push(candidate.shape.data.cause);
}
} }
return null; return null;