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>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import mongoose from "mongoose";
|
|
import { NextRequest } from "next/server";
|
|
import { Business } from "@/lib/server/models/Business";
|
|
import { Branch } from "@/lib/server/models/Branch";
|
|
import { User, UserRole } from "@/lib/server/models/User";
|
|
import { Product } from "@/lib/server/models/Product";
|
|
import { signAccessToken } from "@/lib/server/jwt";
|
|
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
|
|
|
let phoneSeq = 0;
|
|
function nextPhone(): string {
|
|
phoneSeq += 1;
|
|
return "2547" + String(10_000_000 + phoneSeq).slice(-8);
|
|
}
|
|
|
|
export interface Actor {
|
|
id: string;
|
|
businessId: string;
|
|
role: UserRole;
|
|
branchId: string | null;
|
|
email: string;
|
|
}
|
|
|
|
export interface Tenant {
|
|
businessId: string;
|
|
owner: Actor;
|
|
branchA: string;
|
|
branchB: string;
|
|
managerA: Actor;
|
|
cashierA: Actor;
|
|
cashierB: Actor;
|
|
water20: string;
|
|
water5: string;
|
|
}
|
|
|
|
async function makeUser(
|
|
businessId: string,
|
|
role: UserRole,
|
|
branchId: string | null,
|
|
label: string
|
|
): Promise<Actor> {
|
|
const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`;
|
|
const user = await User.create({
|
|
businessId,
|
|
name: label,
|
|
role,
|
|
branchId,
|
|
phoneNumber: nextPhone(),
|
|
email,
|
|
passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in
|
|
});
|
|
return { id: user._id.toString(), businessId, role, branchId, email };
|
|
}
|
|
|
|
/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */
|
|
export async function makeTenant(name = "Tenant"): Promise<Tenant> {
|
|
const business = await Business.create({
|
|
name,
|
|
ownerId: new mongoose.Types.ObjectId(),
|
|
tier: "ENTERPRISE",
|
|
subscriptionStatus: "ACTIVE",
|
|
});
|
|
const businessId = business._id.toString();
|
|
const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner");
|
|
business.ownerId = new mongoose.Types.ObjectId(owner.id);
|
|
await business.save();
|
|
|
|
const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" });
|
|
const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" });
|
|
const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA");
|
|
const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA");
|
|
const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB");
|
|
|
|
const [p20, p5] = await Product.insertMany([
|
|
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 },
|
|
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 },
|
|
]);
|
|
|
|
return {
|
|
businessId,
|
|
owner,
|
|
branchA: a._id.toString(),
|
|
branchB: b._id.toString(),
|
|
managerA,
|
|
cashierA,
|
|
cashierB,
|
|
water20: p20._id.toString(),
|
|
water5: p5._id.toString(),
|
|
};
|
|
}
|
|
|
|
export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string {
|
|
return signAccessToken({
|
|
sub: actor.id,
|
|
businessId: actor.businessId,
|
|
role: actor.role,
|
|
branchId: actor.branchId,
|
|
deviceId,
|
|
});
|
|
}
|
|
|
|
export function req(
|
|
method: string,
|
|
path: string,
|
|
opts: { as?: Actor; body?: unknown; cookies?: Record<string, string> } = {}
|
|
): NextRequest {
|
|
const cookies: Record<string, string> = { ...(opts.cookies ?? {}) };
|
|
if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as);
|
|
const cookieHeader = Object.entries(cookies)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join("; ");
|
|
return new NextRequest(new URL(path, "http://localhost"), {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
},
|
|
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
});
|
|
}
|
|
|
|
export function params<T extends Record<string, string>>(p: T) {
|
|
return { params: p };
|
|
}
|
|
|
|
export async function json<T = any>(res: Response): Promise<{ status: number; body: T }> {
|
|
return { status: res.status, body: (await res.json()) as T };
|
|
}
|
|
|
|
export function uuid(): string {
|
|
return randomUUID();
|
|
}
|
|
|
|
/** Tests may inspect and arrange any business's data directly. */
|
|
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
|
|
return crossTenant("test inspects the database directly", fn);
|
|
}
|