Files
ERP/tests/followups.test.ts
T
denis_ireri 83ec5ecd36 Enforce tenant isolation in the data layer instead of per route
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>
2026-10-12 11:52:35 +03:00

242 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import { backfillPurchaseMovements, stockOf, getStockLevels } from "@/lib/server/stock";
import * as branches from "@/app/api/branches/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as users from "@/app/api/users/route";
import * as invites from "@/app/api/invites/[token]/route";
import * as reinvite from "@/app/api/users/[id]/invite/route";
import * as login from "@/app/api/auth/login/route";
import * as products from "@/app/api/products/route";
import * as productById from "@/app/api/products/[id]/route";
import * as purchases from "@/app/api/purchases/route";
import * as profit from "@/app/api/reports/profit/route";
import * as cash from "@/app/api/transactions/cash/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
}
const newBranch = (name: string) => ({ branchName: name, locationCity: "Nakuru" });
describe("F-01 trial branch allowance", () => {
it("lets a trialling Starter business run two branches, not three", async () => {
const t = await makeTenant(); // already has branches A and B
await setPlan(t, "STARTER", "TRIALING");
const third = await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }));
expect(third.status).toBe(403);
expect((await third.json()).error).toMatch(/free trial allows 2/);
});
it("counts only active branches, and a paid Starter plan allows one", async () => {
const t = await makeTenant();
await setPlan(t, "STARTER", "TRIALING");
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
await setPlan(t, "STARTER", "ACTIVE");
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("D") }))).status).toBe(403);
});
});
describe("F-06 branch deactivation", () => {
it("refuses while the branch still holds stock or active staff, then succeeds once emptied", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 4 } })
);
const deactivate = () =>
branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), params({ id: t.branchB }));
const withStock = await deactivate();
expect(withStock.status).toBe(409);
expect((await withStock.json()).error).toMatch(/4 x 20L Jerrycan/);
await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 4 },
})
);
const withStaff = await deactivate();
expect(withStaff.status).toBe(409);
expect((await withStaff.json()).error).toMatch(/active staff/);
await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
expect((await deactivate()).status).toBe(200);
// Hidden from pickers, kept on the Branches page.
const active = await json(await branches.GET(req("GET", "/api/branches", { as: t.owner })));
const all = await json(await branches.GET(req("GET", "/api/branches?includeInactive=1", { as: t.owner })));
expect(active.body.branches.map((b: any) => b._id)).toEqual([t.branchA]);
expect(all.body.branches).toHaveLength(2);
});
it("will not reactivate a branch past the plan's limit", async () => {
const t = await makeTenant();
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
await setPlan(t, "STARTER", "ACTIVE");
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
expect(res.status).toBe(403);
});
});
describe("F-03 report period validation", () => {
it("rejects a reversed period instead of showing an empty report", async () => {
const t = await makeTenant();
const res = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-12&toDate=2026-10-11", { as: t.owner }));
expect(res.status).toBe(400);
const sameDay = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-11&toDate=2026-10-11", { as: t.owner }));
expect(sameDay.status).toBe(200);
});
});
describe("F-07 cost of goods sold", () => {
it("costs only the units sold, at the average buying price, and reports purchases separately", async () => {
const t = await makeTenant();
const buy = (quantity: number, unitCost: number) =>
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
await buy(20, 10);
await buy(10, 13); // average (200 + 130) / 30 = 11
await cash.POST(
req("POST", "/x", {
as: t.cashierA,
body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 3 }, { productId: t.water5, quantity: 1 }] }] },
})
);
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({
revenue: 660, // 3 x 200 + 1 x 60
unitsSold: 4,
costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased
unitsWithoutCost: 1,
grossProfit: 627,
stockPurchases: 330,
cashSurplus: 330,
});
});
});
describe("purchase-to-stock backfill", () => {
it("turns pre-ledger purchases into stock, once", async () => {
const t = await makeTenant();
await Purchase.create({
businessId: t.businessId,
branchId: t.branchA,
supplierName: "Old supplier",
productId: t.water20,
productLabel: "20L Jerrycan",
quantity: 20,
unitCost: 10,
totalCost: 200,
recordedBy: t.owner.id,
});
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
expect(await backfillPurchaseMovements()).toBe(1);
expect(await backfillPurchaseMovements()).toBe(0);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20);
});
});
describe("F-04 / F-05 / CAT-12 catalogue", () => {
it("lists deactivated products for managers and keeps their stock visible", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
);
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
expect(forManager.body.products).toHaveLength(2);
expect(forCashier.body.products).toHaveLength(1);
const levels = await getStockLevels(t.businessId);
expect(levels.find((l) => l.productId === t.water5 && l.branchId === t.branchA)).toMatchObject({
quantity: 7,
productActive: false,
});
expect(levels.some((l) => l.productId === t.water5 && l.branchId === t.branchB)).toBe(false);
});
it("stores count units, refuses duplicate names and zero prices", async () => {
const t = await makeTenant();
const create = (body: object) => products.POST(req("POST", "/x", { as: t.owner, body }));
const tray = await json(await create({ label: "Tray of Eggs", sizeLiters: 30, unit: "pcs", unitPrice: 400 }));
expect(tray.status).toBe(201);
expect(tray.body.product.unit).toBe("pcs");
expect((await create({ label: "tray of eggs", sizeLiters: 1, unit: "tray", unitPrice: 400 })).status).toBe(409);
expect((await create({ label: "Free sample", sizeLiters: 1, unit: "pcs", unitPrice: 0 })).status).toBe(400);
expect((await create({ label: "Bucket", sizeLiters: 1, unit: "buckets", unitPrice: 50 })).status).toBe(400);
const rename = await productById.PATCH(
req("PATCH", "/x", { as: t.owner, body: { label: "20L JERRYCAN" } }),
params({ id: t.water5 })
);
expect(rename.status).toBe(409);
});
});
describe("STA-04 staff invites", () => {
async function invite(t: Tenant) {
const res = await json(
await users.POST(
req("POST", "/api/users", {
as: t.managerA,
body: { name: "New Cashier", role: "DELIVERY", phoneNumber: "0711222333", email: "new.cashier@aquaflow.test" },
})
)
);
expect(res.status).toBe(201);
return { userId: res.body.user._id as string, token: (res.body.inviteUrl as string).split("/invite/")[1] };
}
const signIn = (password: string) =>
login.POST(req("POST", "/x", { body: { email: "new.cashier@aquaflow.test", password, deviceId: uuid() } }));
it("lets the invited person set their own password, once", async () => {
const t = await makeTenant();
const { token } = await invite(t);
const who = await json(await invites.GET(req("GET", "/x"), params({ token })));
expect(who.body).toMatchObject({ name: "New Cashier", email: "new.cashier@aquaflow.test" });
expect((await invites.POST(req("POST", "/x", { body: { password: "short" } }), params({ token }))).status).toBe(400);
expect((await invites.POST(req("POST", "/x", { body: { password: "my-own-secret" } }), params({ token }))).status).toBe(200);
expect((await signIn("my-own-secret")).status).toBe(200);
// The link is single-use.
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
});
it("issues a fresh link that replaces the old one, and rejects expired links", async () => {
const t = await makeTenant();
const { userId, token } = await invite(t);
const again = await json(await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: userId })));
expect(again.status).toBe(200);
const fresh = (again.body.inviteUrl as string).split("/invite/")[1];
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
await db(() => User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
});
it("does not let a manager re-invite another branch's staff", async () => {
const t = await makeTenant();
await db(() => User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) }));
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
expect(res.status).toBe(403);
});
});