domain project / saas-dashboard

SaaS Dashboard

Test strategies for multi-tenant dashboards, role-based access control, data visualisations, onboarding flows, and subscription management.

Multi-tenancy & RBACData visualisationsOnboarding & setupSubscription & billingTeam managementIntegrations & webhooks

Domain Overview

SaaS dashboards have a unique testing challenge: multi-tenancy means tenant A must never see tenant B’s data, even under race conditions or misconfigured caching. Complex RBAC (admin vs member vs viewer) must be enforced at the API layer — not just hidden in the UI. Data visualisations are hard to assert on because they render to canvas or SVG with no readable text nodes.

Typical stack: React or Vue SPA, REST or GraphQL API, PostgreSQL with row-level security or schema-per-tenant isolation, Stripe Billing for subscriptions, third-party OAuth integrations (Slack, GitHub, Google Workspace).

Most testing effort belongs at the API and integration layer for RBAC and data isolation. E2E covers onboarding and billing flows where multi-step UI state is the primary risk.

Key Test Areas

AreaWhat to testRisk
RBACAdmin actions blocked for member, viewer read-only, role upgrade/downgradePrivilege escalation, data deletion
Multi-tenancyAPI always scoped to tenant, no cross-tenant data leakage after switchData breach, compliance failure
Data vizChart renders with correct data, empty state, loading skeleton, error stateSilent wrong numbers
OnboardingWizard step progression, required fields enforced, skip/resumeAbandoned setup, misconfigured accounts
BillingPlan upgrade/downgrade, seat limits enforced, invoice generationRevenue leakage, overcharging
IntegrationsOAuth connect/disconnect, webhook delivery, re-auth on token expiryBroken integrations, missed events

Test Pyramid for SaaS Dashboard

Unit (50%):        RBAC rules, data transformation, billing proration, webhook signing
Integration (40%): Tenant isolation in API, webhook delivery pipeline, OAuth token refresh
E2E (10%):         Onboarding wizard, billing plan change, team member invite flow

Critical E2E tests (must pass before every deploy):

  1. Admin can invite member, member sees reduced permission set in UI
  2. Tenant A cannot access Tenant B data after authenticated switch
  3. Onboarding wizard completes successfully with all required fields
  4. Upgrade from free to paid plan reflects new feature access immediately
  5. Webhook fires on resource create, delivery log shows 200 response

Common Failure Scenarios

1. Admin action available in UI for non-admin role (RBAC not enforced on frontend)
   Test: log in as member, assert delete/admin buttons absent; call delete API directly, assert 403

2. Chart shows previous tenant's data after account switch (cache not scoped to tenant)
   Test: switch tenant via API, reload dashboard, intercept chart data request, assert tenant ID in URL

3. Onboarding wizard allows skip of required step (validation only on final submit)
   Test: click Next without filling required field, assert step does not advance and error is shown

4. Seat limit not enforced when inviting members via bulk CSV import
   Test: upload CSV with members exceeding seat limit, assert import fails with seat limit error

5. Webhook URL update doesn't flush pending queue (old URL receives events)
   Test: update webhook URL, trigger event, assert only new URL receives the payload

Playwright Patterns for SaaS Dashboard

// Test RBAC with two fixture users (admin + member)
test('member cannot see delete controls', async ({ browser }) => {
  const adminCtx = await browser.newContext({ storageState: 'admin.json' });
  const memberCtx = await browser.newContext({ storageState: 'member.json' });

  const adminPage = await adminCtx.newPage();
  const memberPage = await memberCtx.newPage();

  await adminPage.goto('/dashboard/resources');
  await expect(adminPage.getByRole('button', { name: 'Delete' })).toBeVisible();

  await memberPage.goto('/dashboard/resources');
  await expect(memberPage.getByRole('button', { name: 'Delete' })).toHaveCount(0);

  await adminCtx.close();
  await memberCtx.close();
});

// Test tenant isolation with two browser contexts
test('tenant isolation: tenant A cannot see tenant B data', async ({ browser }) => {
  const ctxA = await browser.newContext({ storageState: 'tenantA.json' });
  const ctxB = await browser.newContext({ storageState: 'tenantB.json' });

  const pageA = await ctxA.newPage();
  await pageA.goto('/api/resources');
  const bodyA = await pageA.textContent('body');

  const pageB = await ctxB.newPage();
  await pageB.goto('/api/resources');
  const bodyB = await pageB.textContent('body');

  expect(bodyA).not.toContain('tenant-b-resource-id');
  expect(bodyB).not.toContain('tenant-a-resource-id');

  await ctxA.close();
  await ctxB.close();
});