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, db } 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 db(() => 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 db(() => 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 db(() => 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"); }); });