Enforce roles and tenant status on the server, and use the Kenyan business day
The admin pages redirected cashiers away, but the API itself let them read profit, expenses and purchases and record expenses and purchases. Branch managers could also change product prices for every branch. Every gated route now goes through authorize(), which answers 401 when signed out and 403 for the wrong role. - Owners and managers only: profit report, expenses, purchases, users, morning dispatch and evening count. Owner only: create and edit products, rename branches. - PATCH /api/users/:id deactivates or reactivates staff (managers: their own cashiers only) and revokes the user's device sessions. PATCH /api/branches/:id renames a branch. - Login and token refresh refuse inactive or SUSPENDED businesses. - Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered operator email can no longer be claimed. - M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the pilot): its routes return 404 and the Daraja variables are optional. Turning it on in production requires the callback IP allowlist. - "Today" and the default month now follow Africa/Nairobi instead of the server's UTC clock, so the business day no longer starts at 03:00 EAT. - Malformed ids in query strings and bodies return 400 instead of a 500, the inventory routes check the product belongs to the tenant, and the reconciliation lookups are tenant-scoped. - Branch-scoped staff see their branch's override price from GET /api/products. - Adds a vitest suite against an in-memory MongoDB (npm test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import * as profit from "@/app/api/reports/profit/route";
|
||||
import * as expenses from "@/app/api/expenses/route";
|
||||
import * as purchases from "@/app/api/purchases/route";
|
||||
import * as products from "@/app/api/products/route";
|
||||
import * as productById from "@/app/api/products/[id]/route";
|
||||
import * as userById from "@/app/api/users/[id]/route";
|
||||
import * as branchById from "@/app/api/branches/[id]/route";
|
||||
import * as summary from "@/app/api/dashboard/summary/route";
|
||||
import * as login from "@/app/api/auth/login/route";
|
||||
import * as signup from "@/app/api/auth/signup/route";
|
||||
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||
import * as callback from "@/app/api/mpesa/callback/route";
|
||||
import { json, makeTenant, params, req, uuid } from "./helpers";
|
||||
|
||||
describe("role-based access on the backend", () => {
|
||||
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||
const t = await makeTenant();
|
||||
const as = t.cashierA;
|
||||
const responses = await Promise.all([
|
||||
profit.GET(req("GET", "/api/reports/profit", { as })),
|
||||
expenses.GET(req("GET", "/api/expenses", { as })),
|
||||
purchases.GET(req("GET", "/api/purchases", { as })),
|
||||
expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })),
|
||||
purchases.POST(
|
||||
req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } })
|
||||
),
|
||||
]);
|
||||
expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]);
|
||||
});
|
||||
|
||||
it("still lets a branch manager use them for their own branch", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await expenses.POST(
|
||||
req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } })
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401, not 403, when signed out", async () => {
|
||||
expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("only lets the owner create or reprice products", async () => {
|
||||
const t = await makeTenant();
|
||||
const body = { label: "1L", sizeLiters: 1, unitPrice: 20 };
|
||||
expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403);
|
||||
expect(
|
||||
(await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 })))
|
||||
.status
|
||||
).toBe(403);
|
||||
expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects a malformed branchId with 400 instead of crashing", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user deactivation", () => {
|
||||
it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => {
|
||||
const t = await makeTenant();
|
||||
await UserSession.create({
|
||||
userId: t.cashierA.id,
|
||||
deviceId: uuid(),
|
||||
refreshTokenHash: "h",
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
});
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||
params({ id: t.cashierA.id })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
|
||||
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let a manager touch another branch's staff", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||
params({ id: t.cashierB.id })
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not let the owner deactivate themselves", async () => {
|
||||
const t = await makeTenant();
|
||||
const res = await userById.PATCH(
|
||||
req("PATCH", "/x", { as: t.owner, body: { isActive: false } }),
|
||||
params({ id: t.owner.id })
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenant status", () => {
|
||||
it("refuses login for a suspended business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
|
||||
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||
|
||||
const res = await login.POST(
|
||||
req("POST", "/api/auth/login", {
|
||||
body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() },
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("still allows login for an active business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
|
||||
const res = await login.POST(
|
||||
req("POST", "/api/auth/login", {
|
||||
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform and M-Pesa hardening", () => {
|
||||
it("refuses public signup with a platform-operator email", async () => {
|
||||
const res = await signup.POST(
|
||||
req("POST", "/api/auth/signup", {
|
||||
body: {
|
||||
businessName: "Takeover Ltd",
|
||||
ownerName: "Mallory",
|
||||
email: "operator@aquaflow.test",
|
||||
phoneNumber: "0712345678",
|
||||
password: "password123",
|
||||
deviceId: uuid(),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("hides every M-Pesa route when FEATURE_MPESA is off", async () => {
|
||||
const t = await makeTenant();
|
||||
const push = await stkpush.POST(
|
||||
req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } })
|
||||
);
|
||||
const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } }));
|
||||
expect([push.status, cb.status]).toEqual([404, 404]);
|
||||
});
|
||||
|
||||
it("lets only the owner rename a branch", async () => {
|
||||
const t = await makeTenant();
|
||||
const body = { branchName: "Renamed" };
|
||||
expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403);
|
||||
const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA })));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.branch.branchName).toBe("Renamed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay";
|
||||
|
||||
describe("Nairobi business day", () => {
|
||||
it("rolls over at midnight EAT, not midnight UTC", () => {
|
||||
// 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct.
|
||||
expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12");
|
||||
// 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct.
|
||||
expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11");
|
||||
});
|
||||
|
||||
it("maps a business date to the UTC range 21:00 -> 21:00", () => {
|
||||
const { start, end } = dayRangeNairobi("2026-10-12");
|
||||
expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z");
|
||||
expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("starts the month on the 1st at 00:00 EAT", () => {
|
||||
expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z");
|
||||
expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects malformed dates", () => {
|
||||
expect(isBusinessDate("2026-02-30")).toBe(false);
|
||||
expect(isBusinessDate("2026-2-3")).toBe(false);
|
||||
expect(isBusinessDate("2026-02-28")).toBe(true);
|
||||
expect(() => dayRangeNairobi("nope")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MongoMemoryServer } from "mongodb-memory-server";
|
||||
|
||||
let server: MongoMemoryServer | null = null;
|
||||
|
||||
export async function setup({ provide }: { provide: (key: string, value: string) => void }) {
|
||||
server = await MongoMemoryServer.create();
|
||||
provide("mongoUri", server.getUri("aquaflow-test"));
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
await server?.stop();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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";
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterAll, beforeEach, inject } from "vitest";
|
||||
import mongoose from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface ProvidedContext {
|
||||
mongoUri: string;
|
||||
}
|
||||
}
|
||||
|
||||
process.env.MONGODB_URI = inject("mongoUri");
|
||||
process.env.JWT_ACCESS_SECRET = "test-access-secret-0123456789";
|
||||
process.env.JWT_REFRESH_SECRET = "test-refresh-secret-0123456789";
|
||||
process.env.PLATFORM_ADMIN_EMAILS = "operator@aquaflow.test";
|
||||
|
||||
beforeEach(async () => {
|
||||
await connectDb();
|
||||
const collections = await mongoose.connection.db!.collections();
|
||||
await Promise.all(collections.map((c) => c.deleteMany({})));
|
||||
await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes()));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
global.__mongooseCache = { conn: null, promise: null };
|
||||
});
|
||||
Reference in New Issue
Block a user