import { describe, expect, it } from "vitest"; import mongoose from "mongoose"; import { readdirSync } from "fs"; import path from "path"; import { crossTenant, isTenantGuarded, TenantScopeError } from "@/lib/server/tenantGuard"; import { User } from "@/lib/server/models/User"; import { Transaction } from "@/lib/server/models/Transaction"; import { Expense } from "@/lib/server/models/Expense"; import { StockMovement } from "@/lib/server/models/StockMovement"; import { stockOf } from "@/lib/server/stock"; import * as cash from "@/app/api/transactions/cash/route"; import * as transactions from "@/app/api/transactions/route"; import * as expenses from "@/app/api/expenses/route"; import * as purchases from "@/app/api/purchases/route"; import * as stock from "@/app/api/stock/route"; import * as movements from "@/app/api/stock/movements/route"; import * as transfers from "@/app/api/stock/transfers/route"; import * as adjustments from "@/app/api/stock/adjustments/route"; import * as summary from "@/app/api/dashboard/summary/route"; import * as reconciliation from "@/app/api/dashboard/reconciliation/route"; import * as profit from "@/app/api/reports/profit/route"; import * as users from "@/app/api/users/route"; import * as userById from "@/app/api/users/[id]/route"; import * as reinvite from "@/app/api/users/[id]/invite/route"; import * as branchById from "@/app/api/branches/[id]/route"; import * as productById from "@/app/api/products/[id]/route"; import * as branchPrices from "@/app/api/branch-prices/route"; import * as dispatch from "@/app/api/inventory/dispatch/route"; import * as eveningCount from "@/app/api/inventory/evening-count/route"; import { db, json, makeTenant, params, req, uuid, type Tenant } from "./helpers"; // Models that hold one business's data but are deliberately unguarded (see tenantGuard.ts). const UNGUARDED = new Set(["Business", "UserSession", "MpesaCallbackLog", "StockLock"]); describe("the tenant guard", () => { it("is on every model that stores a businessId", async () => { // Load every model file so none can be skipped by accident. const dir = path.resolve(__dirname, "../src/lib/server/models"); for (const file of readdirSync(dir)) await import(path.join(dir, file)); const scoped = Object.values(mongoose.models).filter((m) => m.schema.path("businessId") && !UNGUARDED.has(m.modelName)); expect(scoped.length).toBeGreaterThanOrEqual(10); const unguarded = scoped.filter((m) => !isTenantGuarded(m.schema)).map((m) => m.modelName); expect(unguarded).toEqual([]); }); it("refuses queries that do not name exactly one business", async () => { const t = await makeTenant(); const attempts: [string, () => PromiseLike][] = [ ["find", () => Transaction.find({ branchId: t.branchA })], ["findById", () => User.findById(t.cashierA.id)], ["exists", () => StockMovement.exists({ type: "SALE" })], ["updateMany", () => Expense.updateMany({}, { amount: 0 })], ["deleteMany", () => Transaction.deleteMany({ paymentMethod: "CASH" })], ["countDocuments", () => User.countDocuments({})], ["$in over businesses", () => User.find({ businessId: { $in: [t.businessId, new mongoose.Types.ObjectId()] } })], ["$exists", () => User.find({ businessId: { $exists: true } })], ["aggregate without $match", () => Transaction.aggregate([{ $group: { _id: "$businessId" } }])], ["aggregate matching later", () => Transaction.aggregate([{ $sort: { createdAt: -1 } }, { $match: { businessId: t.businessId } }])], ["unscoped populate", () => Expense.find({ businessId: t.businessId }).populate("branchId")], ]; await Expense.create({ businessId: t.businessId, branchId: t.branchA, category: "Rent", amount: 1, description: "x", recordedBy: t.owner.id }); for (const [label, run] of attempts) { await expect(Promise.resolve().then(run), label).rejects.toBeInstanceOf(TenantScopeError); } }); it("allows scoped queries, and cross-business ones only with a stated reason", async () => { const t = await makeTenant(); expect(await User.countDocuments({ businessId: t.businessId })).toBe(4); expect(await User.countDocuments({ $and: [{ businessId: t.businessId }, { role: "DELIVERY" }] })).toBe(2); expect(await crossTenant("test", () => User.countDocuments({}))).toBe(4); expect(() => crossTenant(" ", () => User.countDocuments({}))).toThrow(/reason/); // The exemption ends with the callback. await expect(Promise.resolve().then(() => User.countDocuments({}))).rejects.toBeInstanceOf(TenantScopeError); }); }); /** Give tenant B one of everything, so a leak into tenant A's view would show. */ async function seedVictim(b: Tenant) { await adjustments.POST( req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "DELTA", quantity: 50 } }) ); await cash.POST( req("POST", "/x", { as: b.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 3 }] }] } }) ); await expenses.POST(req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, category: "Rent", amount: 7777, description: "B rent" } })); await purchases.POST( req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, supplierName: "B supplier", productId: b.water20, quantity: 5, unitCost: 99 } }) ); await transfers.POST( req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: b.branchB, productId: b.water20, quantity: 2 } }) ); } describe("route sweep: another business's ids get nothing", () => { it("every read route returns none of business B's data to business A, whatever ids A sends", async () => { const a = await makeTenant("Alpha"); const b = await makeTenant("Beta"); await seedVictim(b); const reads: [string, Response][] = []; const get = async (label: string, handler: (r: any) => Promise, url: string) => reads.push([label, await handler(req("GET", url, { as: a.owner }))]); const q = "?branchId=" + b.branchA; await get("transactions", transactions.GET, "/api/transactions" + q); await get("expenses", expenses.GET, "/api/expenses" + q); await get("purchases", purchases.GET, "/api/purchases" + q); await get("stock", stock.GET, "/api/stock" + q); await get("movements", movements.GET, "/api/stock/movements" + q); await get("transfers", transfers.GET, "/api/stock/transfers" + q); await get("summary", summary.GET, "/api/dashboard/summary" + q); await get("reconciliation", reconciliation.GET, "/api/dashboard/reconciliation" + q); await get("profit", profit.GET, "/api/reports/profit" + q); await get("users", users.GET, "/api/users" + q); await get("branch prices", branchPrices.GET, "/api/branch-prices" + q); const bIds = [b.businessId, b.branchA, b.branchB, b.water20, b.owner.id, b.cashierA.id]; for (const [label, res] of reads) { const text = await res.text(); for (const id of bIds) expect(text.includes(id), `${label} leaked ${id}`).toBe(false); expect(text.includes("7777") || text.includes("B supplier"), `${label} leaked B's figures`).toBe(false); } // Totals A sees are A's own (zero), not B's sale. const totals = JSON.parse(await (await summary.GET(req("GET", "/api/dashboard/summary" + q, { as: a.owner }))).text()); expect(totals.totals.cashTotal).toBe(0); }); it("every write route refuses business B's ids and leaves B's data untouched", async () => { const a = await makeTenant("Alpha"); const b = await makeTenant("Beta"); await seedVictim(b); const before = { stockA: await stockOf(b.businessId, b.branchA, b.water20), cashier: await db(() => User.findById(b.cashierA.id).lean()), sales: await Transaction.countDocuments({ businessId: b.businessId }), }; const as = a.owner; const writes: [string, Response][] = [ ["deactivate B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { isActive: false } }), params({ id: b.cashierA.id }))], ["move B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { branchId: a.branchA } }), params({ id: b.cashierA.id }))], ["re-invite B's cashier", await reinvite.POST(req("POST", "/x", { as }), params({ id: b.cashierA.id }))], ["rename B's branch", await branchById.PATCH(req("PATCH", "/x", { as, body: { branchName: "Pwned" } }), params({ id: b.branchA }))], ["reprice B's product", await productById.PATCH(req("PATCH", "/x", { as, body: { unitPrice: 1 } }), params({ id: b.water20 }))], ["branch price on B", await branchPrices.PUT(req("PUT", "/x", { as, body: { branchId: b.branchA, productId: b.water20, unitPrice: 1 } }))], ["adjust B's stock", await adjustments.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "COUNT", quantity: 0 } }))], ["transfer out of B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: a.branchA, productId: b.water20, quantity: 1 } }))], ["transfer into B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: a.branchA, toBranchId: b.branchA, productId: a.water20, quantity: 1 } }))], ["expense on B", await expenses.POST(req("POST", "/x", { as, body: { branchId: b.branchA, category: "Rent", amount: 1, description: "x" } }))], ["purchase into B", await purchases.POST(req("POST", "/x", { as, body: { branchId: b.branchA, supplierName: "x", productId: b.water20, quantity: 1, unitCost: 1 } }))], ["dispatch at B", await dispatch.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, morningDispatched: 1 } }))], ["count at B", await eveningCount.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, eveningPhysicalCount: 1 } }))], ["sale of B's product", await cash.POST(req("POST", "/x", { as: a.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 1 }] }] } }))], ]; for (const [label, res] of writes) { if (label.startsWith("sale")) { expect((await json(res)).body.results[0].status, label).toBe("rejected"); } else { expect(res.status, label).toBeGreaterThanOrEqual(400); } } expect(await stockOf(b.businessId, b.branchA, b.water20)).toBe(before.stockA); expect(await db(() => User.findById(b.cashierA.id).lean())).toMatchObject({ isActive: before.cashier!.isActive, branchId: before.cashier!.branchId, }); expect(await Transaction.countDocuments({ businessId: b.businessId })).toBe(before.sales); expect(await Expense.countDocuments({ businessId: b.businessId })).toBe(1); }); });