capstone project / multitenant-saas

Multi-Tenant SaaS Suite

A test suite for a multi-tenant SaaS dashboard — RBAC coverage, tenant isolation tests, onboarding flows, and subscription billing validation.

PlaywrightTypeScriptStripe BillingPostgreSQLGitHub Actions

Business Context

A SaaS platform hosts hundreds of tenant organisations, each with admin, member, and viewer roles. A security regression — one tenant reading another’s data — or an RBAC bypass — a viewer deleting records — would be a critical incident. The billing system uses Stripe Subscriptions with metered usage; plan transitions and trial expirations must also be verified.

Goals:

  • Tenant data isolation: cross-tenant API requests must receive 403 Forbidden
  • RBAC enforcement: every action × every role combination asserted
  • Onboarding flow: new tenant can sign up, invite members, and publish their first record
  • Billing transitions: trial → paid, plan upgrade/downgrade, cancellation

Architecture Overview

LayerResponsibility
Multi-user fixtureProvisions admin + member + viewer for the same tenant per test
Tenant fixtureCreates an isolated tenant org via API; deletes it in teardown
Billing fixtureCreates a Stripe test clock; advances time to trigger billing events
RBAC matrixParameterised spec — all actions × all roles; asserts allowed/forbidden
Isolation suiteActor authenticated as Tenant A attempts Tenant B’s resource IDs

Framework Structure

e2e/
├── fixtures/
│   ├── users.fixture.ts
│   ├── tenant.fixture.ts
│   └── billing.fixture.ts
├── tests/
│   ├── rbac.spec.ts
│   ├── tenant-isolation.spec.ts
│   ├── onboarding.spec.ts
│   └── billing.spec.ts
├── utils/
│   ├── rbac-matrix.ts
│   └── stripe-clock.ts
└── playwright.config.ts

Key Implementation Patterns

Multi-user fixture — all three roles provisioned for one tenant, available to any test:

// fixtures/users.fixture.ts
type RoleFixtures = {
  adminPage: Page;
  memberPage: Page;
  viewerPage: Page;
};

export const test = base.extend<RoleFixtures>({
  adminPage: async ({ browser, tenant }, use) => {
    const ctx = await browser.newContext({
      storageState: await loginAs(tenant, 'admin'),
    });
    await use(await ctx.newPage());
    await ctx.close();
  },
  memberPage: async ({ browser, tenant }, use) => {
    const ctx = await browser.newContext({
      storageState: await loginAs(tenant, 'member'),
    });
    await use(await ctx.newPage());
    await ctx.close();
  },
  viewerPage: async ({ browser, tenant }, use) => {
    const ctx = await browser.newContext({
      storageState: await loginAs(tenant, 'viewer'),
    });
    await use(await ctx.newPage());
    await ctx.close();
  },
});

RBAC matrix testing — parameterised over roles × actions:

// utils/rbac-matrix.ts
export const RBAC_MATRIX = [
  { action: 'create_record', role: 'admin',  allowed: true  },
  { action: 'create_record', role: 'member', allowed: true  },
  { action: 'create_record', role: 'viewer', allowed: false },
  { action: 'delete_record', role: 'admin',  allowed: true  },
  { action: 'delete_record', role: 'member', allowed: false },
  { action: 'delete_record', role: 'viewer', allowed: false },
  { action: 'invite_user',   role: 'admin',  allowed: true  },
  { action: 'invite_user',   role: 'member', allowed: false },
  { action: 'invite_user',   role: 'viewer', allowed: false },
] as const;

// rbac.spec.ts
for (const { action, role, allowed } of RBAC_MATRIX) {
  test(`${role} ${allowed ? 'can' : 'cannot'} ${action}`, async ({ request, tokens }) => {
    const response = await performAction(request, action, tokens[role]);
    expect(response.status()).toBe(allowed ? 200 : 403);
  });
}

Cross-tenant isolation — Tenant A’s token attempts Tenant B’s resource IDs:

// tests/tenant-isolation.spec.ts
test('cannot read another tenant record', async ({ request, tenantA, tenantB }) => {
  const recordId = await tenantB.createRecord();

  const response = await request.get(`/api/records/${recordId}`, {
    headers: { Authorization: `Bearer ${tenantA.adminToken}` },
  });

  expect(response.status()).toBe(403);
});

Stripe test clock for billing advancement:

// utils/stripe-clock.ts
export async function advanceToTrialEnd(clockId: string) {
  const trialEnd = Math.floor(Date.now() / 1000) + 14 * 86400; // 14 days
  await stripe.testHelpers.testClocks.advance(clockId, {
    frozen_time: trialEnd,
  });
}

// billing.spec.ts
test('trial expiry downgrades account', async ({ page, billingFixture }) => {
  await advanceToTrialEnd(billingFixture.clockId);
  await page.reload();
  await expect(page.getByRole('banner')).toContainText('Trial ended');
});

CI/CD Integration

# .github/workflows/saas-tests.yml
jobs:
  rbac-suite:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npx playwright install --with-deps
      - run: npx playwright test tests/rbac.spec.ts tests/tenant-isolation.spec.ts
          --workers=4 --reporter=allure-playwright
        env:
          API_URL: ${{ secrets.STAGING_URL }}

  billing-suite:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npx playwright install --with-deps
      - run: npx playwright test tests/billing.spec.ts
          --workers=1 --reporter=allure-playwright
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_KEY }}

  publish-allure:
    needs: [rbac-suite, billing-suite]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - uses: simple-elf/allure-report-action@v1

RBAC and isolation specs run with --workers=4 — they’re stateless per tenant fixture. The billing suite runs with --workers=1 because Stripe test clocks advance global time on the test account and concurrent advancement causes race conditions.