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