fix(api): wrap critical mutations in transactions and fix TOCTOU race conditions

- applyProjectScenario: wrap assignment loop in db.$transaction to prevent partial updates
- vacation approve/reject: fix TOCTOU race via updateMany with status-guard in WHERE + CONFLICT on count=0
- vacation cancel: wrap vacation.update + entitlement.updateMany in $transaction
- batchApprove: collect mutations, wrap in $transaction, dispatch SSE/notifications after commit
- Fix dead-code bug in createHappyPathDb where $transaction was assigned after return
- Add atomicity and concurrency tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 08:34:59 +02:00
parent b103e79e92
commit 1d6d75ecf6
6 changed files with 245 additions and 129 deletions
@@ -10,8 +10,9 @@ function makeDb(overrides: {
assignmentUpdate?: ReturnType<typeof vi.fn>;
assignmentCreate?: ReturnType<typeof vi.fn>;
resourceFindUnique?: ReturnType<typeof vi.fn>;
transaction?: ReturnType<typeof vi.fn>;
}) {
return {
const db = {
project: {
findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }),
},
@@ -22,6 +23,10 @@ function makeDb(overrides: {
resource: {
findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }),
},
};
return {
...db,
$transaction: overrides.transaction ?? vi.fn(async (cb: (tx: typeof db) => Promise<unknown>) => cb(db)),
} as never;
}
@@ -151,4 +156,46 @@ describe("applyProjectScenario", () => {
// appliedCount = 2 creates + 0 cancel = 2.
expect(result.appliedCount).toBe(2);
});
it("wraps all mutations in a single transaction", async () => {
assignmentCreate = vi.fn().mockResolvedValue({ id: "new_1" });
assignmentUpdate = vi.fn().mockResolvedValue({});
// transaction mock that propagates the error so we can verify atomicity
const transaction = vi.fn(async (cb: (tx: unknown) => Promise<unknown>) => cb({
assignment: { update: assignmentUpdate, create: assignmentCreate },
resource: { findUnique: resourceFindUnique },
}));
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction });
await applyProjectScenario(db, {
projectId: "project_1",
changes: [{ ...baseChange, resourceId: "resource_1" }],
});
expect(transaction).toHaveBeenCalledTimes(1);
});
it("propagates transaction error so partial changes are rolled back", async () => {
assignmentCreate = vi.fn()
.mockResolvedValueOnce({ id: "new_1" })
.mockRejectedValueOnce(new Error("DB constraint violation"));
const transaction = vi.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ assignment: { update: assignmentUpdate, create: assignmentCreate }, resource: { findUnique: resourceFindUnique } }),
);
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction });
await expect(
applyProjectScenario(db, {
projectId: "project_1",
changes: [
{ ...baseChange, resourceId: "resource_1" },
{ ...baseChange, resourceId: "resource_2" },
],
}),
).rejects.toThrow("DB constraint violation");
});
});