test(05-04): add comprehensive API RBAC integration tests
- 43 tests covering authentication (401), authorization (403), and tenant isolation - withPermission HOF returns 401 for unauthenticated, 403 for unauthorized - withPortalAuth returns 401/403 for non-portal users - All 5 roles (ADMIN, OFFICE_STAFF, COLLECTOR, TECHNICIAN, CLIENT) tested against all subjects - COLLECTOR cannot access billing/reports/subscriber management writes - TECHNICIAN cannot access payment/invoice/report endpoints - CLIENT can only read own data, create tickets - Two-tenant isolation: subscribers, invoices, payments invisible across tenants - Cross-tenant ID lookup returns null (no data leakage) - INFRA-03 satisfied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
851
src/lib/__tests__/integration/api-rbac.test.ts
Normal file
851
src/lib/__tests__/integration/api-rbac.test.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
/**
|
||||
* API RBAC Integration Tests
|
||||
*
|
||||
* Comprehensive integration tests verifying API-layer authorization enforcement
|
||||
* across all endpoints. Tests three critical security dimensions:
|
||||
*
|
||||
* 1. AUTHENTICATION (401): Unauthenticated requests rejected
|
||||
* 2. AUTHORIZATION (403): Role-restricted endpoints enforce RBAC boundaries
|
||||
* 3. TENANT ISOLATION: Cross-tenant data contamination impossible
|
||||
*
|
||||
* Approach:
|
||||
* - Tests withPermission HOF behavior by mocking getCurrentUser
|
||||
* - Tests CASL ability checks for all 5 roles against all subjects
|
||||
* - Tests tenant isolation with real database records (subscribers, invoices, payments)
|
||||
*
|
||||
* INFRA-03: Proves the authorization layer cannot be bypassed at the API level.
|
||||
*
|
||||
* CLEANUP ORDER:
|
||||
* paymentAllocations -> payments -> invoiceLines -> invoices ->
|
||||
* journalEntryLines -> null reversesEntryId -> journalEntries ->
|
||||
* subscribers -> servicePlans -> tenantSettings -> accountingPeriods ->
|
||||
* accounts -> ticketCategories -> users -> tenant
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withPortalAuth } from "@/lib/middleware/portal-auth";
|
||||
import { defineAbilityFor } from "@/lib/casl/ability";
|
||||
import { definePermissionsFor } from "@/lib/casl/permissions";
|
||||
import {
|
||||
BillingType,
|
||||
InvoiceStatus,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
Prisma,
|
||||
Role,
|
||||
TenantStatus,
|
||||
} from "@prisma/client";
|
||||
import type { AppSubjects, AppActions } from "@/lib/casl/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock getCurrentUser for withPermission HOF tests
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockGetCurrentUser = vi.fn();
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getCurrentUser: () => mockGetCurrentUser(),
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test state
|
||||
// ---------------------------------------------------------------------------
|
||||
const TS = Date.now();
|
||||
|
||||
let tenantAId: string;
|
||||
let tenantBId: string;
|
||||
let adminUserAId: string;
|
||||
let adminUserBId: string;
|
||||
let subscriberAId: string;
|
||||
let subscriberBId: string;
|
||||
let planAId: string;
|
||||
let planBId: string;
|
||||
let invoiceAId: string;
|
||||
let invoiceBId: string;
|
||||
let paymentAId: string;
|
||||
let paymentBId: string;
|
||||
|
||||
let invoiceCounter = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeRequest(url = "http://localhost:3000/api/test") {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup and Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
// --- Tenant A ---
|
||||
const tenantA = await prisma.tenant.create({
|
||||
data: {
|
||||
name: `RBAC Tenant A ${TS}`,
|
||||
slug: `rbac-a-${TS}`,
|
||||
ownerEmail: `rbac-admin-a-${TS}@test.example`,
|
||||
status: TenantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
tenantAId = tenantA.id;
|
||||
|
||||
const adminA = await prisma.user.create({
|
||||
data: {
|
||||
email: `rbac-admin-a-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "AdminA",
|
||||
lastName: "RBAC",
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.ADMIN],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
adminUserAId = adminA.id;
|
||||
|
||||
const planA = await prisma.servicePlan.create({
|
||||
data: {
|
||||
tenantId: tenantAId,
|
||||
name: `RBAC Plan A ${TS}`,
|
||||
speed: "50Mbps",
|
||||
monthlyPrice: 1500,
|
||||
billingType: BillingType.POSTPAID,
|
||||
},
|
||||
});
|
||||
planAId = planA.id;
|
||||
|
||||
const subA = await prisma.subscriber.create({
|
||||
data: {
|
||||
tenantId: tenantAId,
|
||||
accountNumber: `RBAC-A-${TS}`,
|
||||
firstName: "SubA",
|
||||
lastName: "RBAC",
|
||||
email: `sub-a-${TS}@test.example`,
|
||||
address: "123 Test St",
|
||||
servicePlanId: planAId,
|
||||
billingDay: 1,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
subscriberAId = subA.id;
|
||||
|
||||
invoiceCounter++;
|
||||
const invA = await prisma.invoice.create({
|
||||
data: {
|
||||
tenantId: tenantAId,
|
||||
invoiceNumber: `INV-RBAC-A-${TS}-${invoiceCounter}`,
|
||||
subscriberId: subscriberAId,
|
||||
periodStart: new Date("2025-01-01"),
|
||||
periodEnd: new Date("2025-01-31"),
|
||||
dueDate: new Date("2025-02-15"),
|
||||
subtotal: new Prisma.Decimal(1500),
|
||||
totalAmount: new Prisma.Decimal(1500),
|
||||
amountPaid: new Prisma.Decimal(0),
|
||||
status: InvoiceStatus.SENT,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
invoiceAId = invA.id;
|
||||
|
||||
const pmtA = await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: tenantAId,
|
||||
subscriberId: subscriberAId,
|
||||
amount: new Prisma.Decimal(1500),
|
||||
paymentMethod: PaymentMethod.CASH,
|
||||
paymentDate: new Date(),
|
||||
idempotencyKey: `pmt-rbac-a-${TS}`,
|
||||
recordedById: adminUserAId,
|
||||
status: PaymentStatus.COMPLETED,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
paymentAId = pmtA.id;
|
||||
|
||||
// --- Tenant B ---
|
||||
const tenantB = await prisma.tenant.create({
|
||||
data: {
|
||||
name: `RBAC Tenant B ${TS}`,
|
||||
slug: `rbac-b-${TS}`,
|
||||
ownerEmail: `rbac-admin-b-${TS}@test.example`,
|
||||
status: TenantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
tenantBId = tenantB.id;
|
||||
|
||||
const adminB = await prisma.user.create({
|
||||
data: {
|
||||
email: `rbac-admin-b-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "AdminB",
|
||||
lastName: "RBAC",
|
||||
tenantId: tenantBId,
|
||||
roles: [Role.ADMIN],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
adminUserBId = adminB.id;
|
||||
|
||||
const planB = await prisma.servicePlan.create({
|
||||
data: {
|
||||
tenantId: tenantBId,
|
||||
name: `RBAC Plan B ${TS}`,
|
||||
speed: "100Mbps",
|
||||
monthlyPrice: 2500,
|
||||
billingType: BillingType.POSTPAID,
|
||||
},
|
||||
});
|
||||
planBId = planB.id;
|
||||
|
||||
const subB = await prisma.subscriber.create({
|
||||
data: {
|
||||
tenantId: tenantBId,
|
||||
accountNumber: `RBAC-B-${TS}`,
|
||||
firstName: "SubB",
|
||||
lastName: "RBAC",
|
||||
email: `sub-b-${TS}@test.example`,
|
||||
address: "456 Test Ave",
|
||||
servicePlanId: planBId,
|
||||
billingDay: 15,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
subscriberBId = subB.id;
|
||||
|
||||
invoiceCounter++;
|
||||
const invB = await prisma.invoice.create({
|
||||
data: {
|
||||
tenantId: tenantBId,
|
||||
invoiceNumber: `INV-RBAC-B-${TS}-${invoiceCounter}`,
|
||||
subscriberId: subscriberBId,
|
||||
periodStart: new Date("2025-02-01"),
|
||||
periodEnd: new Date("2025-02-28"),
|
||||
dueDate: new Date("2025-03-15"),
|
||||
subtotal: new Prisma.Decimal(2500),
|
||||
totalAmount: new Prisma.Decimal(2500),
|
||||
amountPaid: new Prisma.Decimal(0),
|
||||
status: InvoiceStatus.SENT,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
invoiceBId = invB.id;
|
||||
|
||||
const pmtB = await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: tenantBId,
|
||||
subscriberId: subscriberBId,
|
||||
amount: new Prisma.Decimal(2500),
|
||||
paymentMethod: PaymentMethod.BANK_TRANSFER,
|
||||
paymentDate: new Date(),
|
||||
idempotencyKey: `pmt-rbac-b-${TS}`,
|
||||
recordedById: adminUserBId,
|
||||
status: PaymentStatus.COMPLETED,
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
paymentBId = pmtB.id;
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Comprehensive cleanup order following project convention
|
||||
for (const tid of [tenantAId, tenantBId]) {
|
||||
if (!tid) continue;
|
||||
|
||||
// Payment allocations
|
||||
await prisma.paymentAllocation
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Payments
|
||||
await prisma.payment
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Invoice lines
|
||||
await prisma.invoiceLine
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Invoices
|
||||
await prisma.invoice
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Subscribers
|
||||
await prisma.subscriber
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Service plans
|
||||
await prisma.servicePlan
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Users
|
||||
await prisma.user
|
||||
.deleteMany({ where: { tenantId: tid } })
|
||||
.catch(() => {});
|
||||
|
||||
// Tenant
|
||||
await prisma.tenant
|
||||
.delete({ where: { id: tid } })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}, 30000);
|
||||
|
||||
// =============================================================================
|
||||
// 1. AUTHENTICATION (401) — Unauthenticated requests rejected
|
||||
// =============================================================================
|
||||
|
||||
describe("Authentication (401)", () => {
|
||||
beforeEach(() => {
|
||||
mockGetCurrentUser.mockReset();
|
||||
});
|
||||
|
||||
it("withPermission returns 401 when getCurrentUser returns null", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue(null);
|
||||
|
||||
const handler = withPermission(
|
||||
"read",
|
||||
"Subscriber"
|
||||
)(async (_req, { user }) => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("withPortalAuth returns 401 when getCurrentUser returns null", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue(null);
|
||||
|
||||
const handler = withPortalAuth(async (_req, ctx) => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("withPermission returns 403 when user lacks required permission", async () => {
|
||||
// COLLECTOR trying to access Report (read)
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: "collector-001",
|
||||
email: "collector@test.example",
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.COLLECTOR],
|
||||
isSuperAdmin: false,
|
||||
firstName: "Test",
|
||||
lastName: "Collector",
|
||||
});
|
||||
|
||||
const handler = withPermission(
|
||||
"read",
|
||||
"Report"
|
||||
)(async (_req, { user }) => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("withPermission allows authorized user through to handler", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: adminUserAId,
|
||||
email: `rbac-admin-a-${TS}@test.example`,
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.ADMIN],
|
||||
isSuperAdmin: false,
|
||||
firstName: "AdminA",
|
||||
lastName: "RBAC",
|
||||
});
|
||||
|
||||
const handler = withPermission(
|
||||
"read",
|
||||
"Subscriber"
|
||||
)(async (_req, { user }) => {
|
||||
return NextResponse.json({ userId: user.id });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.userId).toBe(adminUserAId);
|
||||
});
|
||||
|
||||
it("withPortalAuth returns 403 when user has no subscriberId", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: adminUserAId,
|
||||
email: `rbac-admin-a-${TS}@test.example`,
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.ADMIN],
|
||||
isSuperAdmin: false,
|
||||
firstName: "AdminA",
|
||||
lastName: "RBAC",
|
||||
// No subscriberId - staff user, not portal user
|
||||
});
|
||||
|
||||
const handler = withPortalAuth(async (_req, ctx) => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. AUTHORIZATION BY ROLE (403) — CASL enforcement for all 5 roles
|
||||
// =============================================================================
|
||||
|
||||
describe("Authorization by Role (403)", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// ADMIN — can manage all
|
||||
// -------------------------------------------------------------------------
|
||||
describe("ADMIN", () => {
|
||||
const ability = defineAbilityFor({
|
||||
id: "admin-001",
|
||||
roles: [Role.ADMIN],
|
||||
tenantId: tenantAId,
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
const allSubjects: AppSubjects[] = [
|
||||
"Subscriber",
|
||||
"Invoice",
|
||||
"Payment",
|
||||
"Zone",
|
||||
"Ticket",
|
||||
"JobOrder",
|
||||
"Inventory",
|
||||
"Expense",
|
||||
"Vendor",
|
||||
"Report",
|
||||
"Account",
|
||||
"User",
|
||||
];
|
||||
|
||||
it("can manage all subjects", () => {
|
||||
for (const subject of allSubjects) {
|
||||
expect(ability.can("manage", subject)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("can perform all CRUD actions on any subject", () => {
|
||||
const actions: AppActions[] = [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
];
|
||||
for (const action of actions) {
|
||||
expect(ability.can(action, "Subscriber")).toBe(true);
|
||||
expect(ability.can(action, "Invoice")).toBe(true);
|
||||
expect(ability.can(action, "Account")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OFFICE_STAFF — broad access, cannot modify Chart of Accounts
|
||||
// -------------------------------------------------------------------------
|
||||
describe("OFFICE_STAFF", () => {
|
||||
const ability = defineAbilityFor({
|
||||
id: "office-001",
|
||||
roles: [Role.OFFICE_STAFF],
|
||||
tenantId: tenantAId,
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
it("can manage Subscriber, Invoice, Payment, Ticket, JobOrder, Inventory, Expense, Vendor", () => {
|
||||
const manageable: AppSubjects[] = [
|
||||
"Subscriber",
|
||||
"Invoice",
|
||||
"Payment",
|
||||
"Ticket",
|
||||
"JobOrder",
|
||||
"Inventory",
|
||||
"Expense",
|
||||
"Vendor",
|
||||
];
|
||||
for (const subject of manageable) {
|
||||
expect(ability.can("manage", subject)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("can read Report and Account", () => {
|
||||
expect(ability.can("read", "Report")).toBe(true);
|
||||
expect(ability.can("read", "Account")).toBe(true);
|
||||
});
|
||||
|
||||
it("CANNOT create, update, or delete Account (Chart of Accounts protected)", () => {
|
||||
expect(ability.can("create", "Account")).toBe(false);
|
||||
expect(ability.can("update", "Account")).toBe(false);
|
||||
expect(ability.can("delete", "Account")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// COLLECTOR — read subscribers/zones, create/read payments only
|
||||
// -------------------------------------------------------------------------
|
||||
describe("COLLECTOR", () => {
|
||||
const ability = defineAbilityFor({
|
||||
id: "collector-001",
|
||||
roles: [Role.COLLECTOR],
|
||||
tenantId: tenantAId,
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
it("can read Subscriber and Zone", () => {
|
||||
expect(ability.can("read", "Subscriber")).toBe(true);
|
||||
expect(ability.can("read", "Zone")).toBe(true);
|
||||
});
|
||||
|
||||
it("can create and read Payment", () => {
|
||||
expect(ability.can("create", "Payment")).toBe(true);
|
||||
expect(ability.can("read", "Payment")).toBe(true);
|
||||
});
|
||||
|
||||
it("CANNOT read Report", () => {
|
||||
expect(ability.can("read", "Report")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Invoice or create Invoice", () => {
|
||||
expect(ability.can("manage", "Invoice")).toBe(false);
|
||||
expect(ability.can("create", "Invoice")).toBe(false);
|
||||
expect(ability.can("read", "Invoice")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Subscriber (create, update, delete)", () => {
|
||||
expect(ability.can("create", "Subscriber")).toBe(false);
|
||||
expect(ability.can("update", "Subscriber")).toBe(false);
|
||||
expect(ability.can("delete", "Subscriber")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Ticket", () => {
|
||||
expect(ability.can("manage", "Ticket")).toBe(false);
|
||||
expect(ability.can("create", "Ticket")).toBe(false);
|
||||
expect(ability.can("read", "Ticket")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT access billing write endpoints (update Payment, delete Payment)", () => {
|
||||
expect(ability.can("update", "Payment")).toBe(false);
|
||||
expect(ability.can("delete", "Payment")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TECHNICIAN — own jobs/inventory only, no billing access
|
||||
// -------------------------------------------------------------------------
|
||||
describe("TECHNICIAN", () => {
|
||||
const userId = "technician-001";
|
||||
const ability = defineAbilityFor({
|
||||
id: userId,
|
||||
roles: [Role.TECHNICIAN],
|
||||
tenantId: tenantAId,
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
it("can read and update JobOrder (own only via conditions)", () => {
|
||||
expect(ability.can("read", "JobOrder")).toBe(true);
|
||||
expect(ability.can("update", "JobOrder")).toBe(true);
|
||||
});
|
||||
|
||||
it("can read Subscriber (contact info for jobs)", () => {
|
||||
expect(ability.can("read", "Subscriber")).toBe(true);
|
||||
});
|
||||
|
||||
it("can read Inventory (own only via conditions)", () => {
|
||||
expect(ability.can("read", "Inventory")).toBe(true);
|
||||
});
|
||||
|
||||
it("CANNOT manage Payment or create Payment", () => {
|
||||
expect(ability.can("manage", "Payment")).toBe(false);
|
||||
expect(ability.can("create", "Payment")).toBe(false);
|
||||
expect(ability.can("read", "Payment")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT access Invoice at all", () => {
|
||||
expect(ability.can("read", "Invoice")).toBe(false);
|
||||
expect(ability.can("create", "Invoice")).toBe(false);
|
||||
expect(ability.can("manage", "Invoice")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT read Report", () => {
|
||||
expect(ability.can("read", "Report")).toBe(false);
|
||||
expect(ability.can("manage", "Report")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Ticket", () => {
|
||||
expect(ability.can("manage", "Ticket")).toBe(false);
|
||||
expect(ability.can("create", "Ticket")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT create or delete JobOrder", () => {
|
||||
expect(ability.can("create", "JobOrder")).toBe(false);
|
||||
expect(ability.can("delete", "JobOrder")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLIENT — own data only, create tickets, no management
|
||||
// -------------------------------------------------------------------------
|
||||
describe("CLIENT", () => {
|
||||
const userId = "client-sub-001";
|
||||
const ability = defineAbilityFor({
|
||||
id: userId,
|
||||
roles: [Role.CLIENT],
|
||||
tenantId: tenantAId,
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
it("can read Invoice (own), Payment (own), Subscriber (own)", () => {
|
||||
expect(ability.can("read", "Invoice")).toBe(true);
|
||||
expect(ability.can("read", "Payment")).toBe(true);
|
||||
expect(ability.can("read", "Subscriber")).toBe(true);
|
||||
});
|
||||
|
||||
it("can create Ticket and read Ticket (own)", () => {
|
||||
expect(ability.can("create", "Ticket")).toBe(true);
|
||||
expect(ability.can("read", "Ticket")).toBe(true);
|
||||
});
|
||||
|
||||
it("CANNOT manage User", () => {
|
||||
expect(ability.can("manage", "User")).toBe(false);
|
||||
expect(ability.can("create", "User")).toBe(false);
|
||||
expect(ability.can("read", "User")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT read Report", () => {
|
||||
expect(ability.can("read", "Report")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Subscriber (create, update, delete)", () => {
|
||||
expect(ability.can("manage", "Subscriber")).toBe(false);
|
||||
expect(ability.can("create", "Subscriber")).toBe(false);
|
||||
expect(ability.can("update", "Subscriber")).toBe(false);
|
||||
expect(ability.can("delete", "Subscriber")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT manage Invoice (create, update, delete)", () => {
|
||||
expect(ability.can("manage", "Invoice")).toBe(false);
|
||||
expect(ability.can("create", "Invoice")).toBe(false);
|
||||
expect(ability.can("update", "Invoice")).toBe(false);
|
||||
});
|
||||
|
||||
it("CANNOT access Expense, Vendor, Account, Inventory", () => {
|
||||
expect(ability.can("read", "Expense")).toBe(false);
|
||||
expect(ability.can("read", "Vendor")).toBe(false);
|
||||
expect(ability.can("read", "Account")).toBe(false);
|
||||
expect(ability.can("read", "Inventory")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. WITHPERMISSION HOF ENFORCEMENT — Role-endpoint combinations via HTTP layer
|
||||
// =============================================================================
|
||||
|
||||
describe("withPermission HOF enforcement", () => {
|
||||
beforeEach(() => {
|
||||
mockGetCurrentUser.mockReset();
|
||||
});
|
||||
|
||||
it("COLLECTOR accessing read:Report via withPermission returns 403", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: "collector-hof-001",
|
||||
email: "collector-hof@test.example",
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.COLLECTOR],
|
||||
isSuperAdmin: false,
|
||||
firstName: "Collector",
|
||||
lastName: "HOF",
|
||||
});
|
||||
|
||||
const handler = withPermission("read", "Report")(async () => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("TECHNICIAN accessing create:Payment via withPermission returns 403", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: "tech-hof-001",
|
||||
email: "tech-hof@test.example",
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.TECHNICIAN],
|
||||
isSuperAdmin: false,
|
||||
firstName: "Tech",
|
||||
lastName: "HOF",
|
||||
});
|
||||
|
||||
const handler = withPermission("create", "Payment")(async () => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("CLIENT accessing manage:Subscriber via withPermission returns 403", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: "client-hof-001",
|
||||
email: "client-hof@test.example",
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.CLIENT],
|
||||
isSuperAdmin: false,
|
||||
firstName: "Client",
|
||||
lastName: "HOF",
|
||||
});
|
||||
|
||||
const handler = withPermission("manage", "Subscriber")(async () => {
|
||||
return NextResponse.json({ data: "should not reach" });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("ADMIN accessing manage:Subscriber via withPermission returns 200", async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({
|
||||
id: adminUserAId,
|
||||
email: `rbac-admin-a-${TS}@test.example`,
|
||||
tenantId: tenantAId,
|
||||
roles: [Role.ADMIN],
|
||||
isSuperAdmin: false,
|
||||
firstName: "AdminA",
|
||||
lastName: "RBAC",
|
||||
});
|
||||
|
||||
const handler = withPermission(
|
||||
"manage",
|
||||
"Subscriber"
|
||||
)(async (_req, { user }) => {
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await handler(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. TENANT ISOLATION — Cross-contamination prevention with real data
|
||||
// =============================================================================
|
||||
|
||||
describe("Tenant Isolation", () => {
|
||||
it("Tenant A context returns only Tenant A subscribers", async () => {
|
||||
const dbA = withTenantContext(tenantAId);
|
||||
const subscribers = await dbA.subscriber.findMany();
|
||||
|
||||
const tenantARecords = subscribers.filter(
|
||||
(s) => s.tenantId === tenantAId
|
||||
);
|
||||
const tenantBRecords = subscribers.filter(
|
||||
(s) => s.tenantId === tenantBId
|
||||
);
|
||||
|
||||
expect(tenantARecords.length).toBeGreaterThanOrEqual(1);
|
||||
expect(tenantBRecords.length).toBe(0);
|
||||
});
|
||||
|
||||
it("Tenant B context returns only Tenant B subscribers", async () => {
|
||||
const dbB = withTenantContext(tenantBId);
|
||||
const subscribers = await dbB.subscriber.findMany();
|
||||
|
||||
const tenantBRecords = subscribers.filter(
|
||||
(s) => s.tenantId === tenantBId
|
||||
);
|
||||
const tenantARecords = subscribers.filter(
|
||||
(s) => s.tenantId === tenantAId
|
||||
);
|
||||
|
||||
expect(tenantBRecords.length).toBeGreaterThanOrEqual(1);
|
||||
expect(tenantARecords.length).toBe(0);
|
||||
});
|
||||
|
||||
it("Tenant A subscriber ID queried via Tenant B context returns null", async () => {
|
||||
const dbB = withTenantContext(tenantBId);
|
||||
|
||||
const crossResult = await dbB.subscriber.findUnique({
|
||||
where: { id: subscriberAId },
|
||||
});
|
||||
|
||||
expect(crossResult).toBeNull();
|
||||
});
|
||||
|
||||
it("Invoice created in Tenant A is invisible to Tenant B", async () => {
|
||||
const dbB = withTenantContext(tenantBId);
|
||||
|
||||
// Try to find Tenant A's invoice via Tenant B's context
|
||||
const crossInvoice = await dbB.invoice.findUnique({
|
||||
where: { id: invoiceAId },
|
||||
});
|
||||
expect(crossInvoice).toBeNull();
|
||||
|
||||
// Verify Tenant B can see its own invoice
|
||||
const ownInvoice = await dbB.invoice.findUnique({
|
||||
where: { id: invoiceBId },
|
||||
});
|
||||
expect(ownInvoice).not.toBeNull();
|
||||
expect(ownInvoice?.tenantId).toBe(tenantBId);
|
||||
});
|
||||
|
||||
it("Payment recorded in Tenant A is invisible to Tenant B", async () => {
|
||||
const dbB = withTenantContext(tenantBId);
|
||||
|
||||
// Try to find Tenant A's payment via Tenant B's context
|
||||
const crossPayment = await dbB.payment.findUnique({
|
||||
where: { id: paymentAId },
|
||||
});
|
||||
expect(crossPayment).toBeNull();
|
||||
|
||||
// Verify Tenant B can see its own payment
|
||||
const ownPayment = await dbB.payment.findUnique({
|
||||
where: { id: paymentBId },
|
||||
});
|
||||
expect(ownPayment).not.toBeNull();
|
||||
expect(ownPayment?.tenantId).toBe(tenantBId);
|
||||
});
|
||||
|
||||
it("Tenant A findMany invoices returns zero from Tenant B", async () => {
|
||||
const dbA = withTenantContext(tenantAId);
|
||||
const invoices = await dbA.invoice.findMany();
|
||||
|
||||
const tenantBInvoices = invoices.filter(
|
||||
(i) => i.tenantId === tenantBId
|
||||
);
|
||||
expect(tenantBInvoices.length).toBe(0);
|
||||
|
||||
const tenantAInvoices = invoices.filter(
|
||||
(i) => i.tenantId === tenantAId
|
||||
);
|
||||
expect(tenantAInvoices.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("Tenant B findMany payments returns zero from Tenant A", async () => {
|
||||
const dbB = withTenantContext(tenantBId);
|
||||
const payments = await dbB.payment.findMany();
|
||||
|
||||
const tenantAPayments = payments.filter(
|
||||
(p) => p.tenantId === tenantAId
|
||||
);
|
||||
expect(tenantAPayments.length).toBe(0);
|
||||
|
||||
const tenantBPayments = payments.filter(
|
||||
(p) => p.tenantId === tenantBId
|
||||
);
|
||||
expect(tenantBPayments.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user