83ec5ecd36
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().
- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
find/update/delete/count/distinct, or an aggregate whose first stage is
not a $match, that does not pin exactly one businessId throws
TenantScopeError before reaching the database ($in, $exists and other
operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
queries that must span businesses - login's email lookup, refresh and
invite-token lookups, the M-Pesa callback, the platform console and the
maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
restricted to the business.
- Every flagged query site now names the business (or is exempted with a
reason).
- Tests: a check that every model with a businessId field carries the
guard; the guard refusing unscoped queries of every kind; and a route
sweep where business A sends business B's ids to every read route
(nothing of B's comes back) and every write route (all refused, B's
data unchanged). Test-side database inspections go through db(), an
explicit test exemption. Removing one route's business filter makes the
suite fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { stockOf } from "@/lib/server/stock";
|
|
import { StockTransfer } from "@/lib/server/models/StockTransfer";
|
|
import { StockLock } from "@/lib/server/models/StockLock";
|
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
|
import * as transfers from "@/app/api/stock/transfers/route";
|
|
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
|
|
|
|
async function openingStock(t: Tenant, quantity: number) {
|
|
await adjustments.POST(
|
|
req("POST", "/x", {
|
|
as: t.owner,
|
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "DELTA", quantity },
|
|
})
|
|
);
|
|
}
|
|
|
|
const transferOut = (t: Tenant, quantity: number) =>
|
|
transfers.POST(
|
|
req("POST", "/x", {
|
|
as: t.owner,
|
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity },
|
|
})
|
|
);
|
|
|
|
describe("concurrent stock changes", () => {
|
|
it("never lets simultaneous transfers send more than the branch holds", async () => {
|
|
const t = await makeTenant();
|
|
await openingStock(t, 10);
|
|
|
|
// Five managers press "Record transfer" for 4 units at the same moment.
|
|
const statuses = (await Promise.all(Array.from({ length: 5 }, () => transferOut(t, 4)))).map((r) => r.status);
|
|
|
|
expect(statuses.filter((s) => s === 201)).toHaveLength(2);
|
|
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
|
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
|
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
|
|
});
|
|
|
|
it("holds across repeated races, not just once", async () => {
|
|
for (let round = 0; round < 5; round++) {
|
|
const t = await makeTenant("Round" + round);
|
|
await openingStock(t, 9);
|
|
const statuses = (await Promise.all(Array.from({ length: 4 }, () => transferOut(t, 3)))).map((r) => r.status);
|
|
expect(statuses.filter((s) => s === 201)).toHaveLength(3);
|
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("applies a double-tapped transfer once even when both taps race", async () => {
|
|
const t = await makeTenant();
|
|
await openingStock(t, 10);
|
|
const body = { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 6 };
|
|
const statuses = (
|
|
await Promise.all([
|
|
transfers.POST(req("POST", "/x", { as: t.owner, body })),
|
|
transfers.POST(req("POST", "/x", { as: t.owner, body })),
|
|
])
|
|
).map((r) => r.status);
|
|
expect(statuses.sort()).toEqual([200, 201]);
|
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(4);
|
|
});
|
|
|
|
it("takes over a lock left behind by a crashed request once it expires", async () => {
|
|
const t = await makeTenant();
|
|
await openingStock(t, 10);
|
|
await StockLock.create({
|
|
_id: `${t.businessId}:${t.branchA}:${t.water20}`,
|
|
owner: "crashed-request",
|
|
lockedUntil: new Date(Date.now() - 1000),
|
|
});
|
|
expect((await transferOut(t, 3)).status).toBe(201);
|
|
expect(await StockLock.countDocuments({})).toBe(0);
|
|
});
|
|
|
|
it("serialises a physical count with a transfer, so the count is never applied to a stale balance", async () => {
|
|
const t = await makeTenant();
|
|
await openingStock(t, 10);
|
|
|
|
// A count of 10 and a transfer of 6 race. Whichever runs second must
|
|
// see the first: either the count resets 4 back to 10 after the
|
|
// transfer, or the transfer moves 6 out of the counted 10.
|
|
const [count, transfer] = await Promise.all([
|
|
adjustments.POST(
|
|
req("POST", "/x", {
|
|
as: t.owner,
|
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "COUNT", quantity: 10 },
|
|
})
|
|
),
|
|
transferOut(t, 6),
|
|
]);
|
|
expect([count.status, transfer.status]).toEqual([201, 201]);
|
|
const a = await stockOf(t.businessId, t.branchA, t.water20);
|
|
expect([4, 10]).toContain(a);
|
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(6);
|
|
});
|
|
|
|
it("does not block transfers of different products or branches on each other", async () => {
|
|
const t = await makeTenant();
|
|
await openingStock(t, 10);
|
|
await adjustments.POST(
|
|
req("POST", "/x", {
|
|
as: t.owner,
|
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 10 },
|
|
})
|
|
);
|
|
const results = await Promise.all([
|
|
transferOut(t, 5),
|
|
transfers.POST(
|
|
req("POST", "/x", {
|
|
as: t.owner,
|
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water5, quantity: 5 },
|
|
})
|
|
),
|
|
]);
|
|
expect(results.map((r) => r.status)).toEqual([201, 201]);
|
|
});
|
|
});
|