Docs
Testing Guide

Testing Guide

Tenantx ships with a **PHPUnit** suite of **~145** test methods across

Testing Guide

Tenantx ships with a PHPUnit suite of ~145 test methods across backend/tests/Feature/ (~34 classes) and backend/tests/Unit/ (6 classes), covering security isolation, permissions, multi-tenancy, subscriptions, webhooks, platform administration, and billing helpers. Tests use PHPUnit attributes (#[Test]) and traditional test_* method names.

Run php artisan test (from backend/) to see the exact current count and status.

CI (GitHub Actions): .github/workflows/ci.yml runs gitleaks, frontend TypeScript + Vitest + ESLint, backend migrations (pretend) + PHPUnit on PostgreSQL, and a Playwright smoke build against vite preview (no live API required for that step).


Running Tests

# Full suite
cd backend
php artisan test

# Single file
php artisan test tests/Feature/PlatformAdminIsolationTest.php

# Group by filter
php artisan test --filter "isolation"

# With coverage (requires Xdebug or PCOV)
php artisan test --coverage

Database: Tests run against DB_CONNECTION=pgsql (see phpunit.xml). A separate tenantx_testing database is expected. Set it in backend/.env.testing.


Test Categories

Security & Isolation (Critical)

These tests prove the three-layer security model is enforced at the HTTP layer, not just by convention.


PlatformAdminIsolationTest — 5 cases

Purpose: The platform console (/api/platform/*) must be completely unreachable by regular tenant users, regardless of their org role or permissions.

CaseWhat it asserts
unauthenticated_request_is_rejected_from_platform_routesNo token → 401
regular_org_user_cannot_access_platform_organizations_listFull org admin, no subscription.admin → 403
regular_org_user_cannot_access_any_platform_endpointSweeps three platform routes → all 403
platform_admin_can_list_all_organizationsPlatform admin sees orgs from multiple tenants
platform_admin_can_list_all_subscriptionsResponse is paginated { data, total, per_page, current_page }

WorkspaceContextEnforcementTest — 4 cases

Purpose: The workspace.context middleware injects the correct workspace scope and must reject any attempt to access a workspace that does not belong to the user's organization.

CaseWhat it asserts
user_with_valid_workspace_can_access_workspace_gated_routeValid profile + workspace → not blocked by middleware
user_whose_default_workspace_belongs_to_another_org_is_blockedProfile pointing at wrong-org workspace → 403 Invalid workspace context
user_without_workspaces_access_all_cannot_switch_to_another_workspacePassing ?workspace_id= without the flag → 403 Invalid workspace scope
user_with_workspaces_access_all_can_switch_to_another_workspace_in_same_orgworkspaces_access_all=true + same-org workspace → allowed

CrossOrgDataIsolationTest — 4 cases

Purpose: Data-layer proof that query scopes prevent cross-org leaks even if the middleware were bypassed.

CaseWhat it asserts
user_listing_never_includes_users_from_another_organization/api/users only returns users in the caller's org
workspace_listing_never_includes_workspaces_from_another_organization/api/workspaces only returns the caller's org workspaces
direct_access_to_another_orgs_workspace_is_deniedGET /api/workspaces/{id} for a foreign workspace → 403 or 404
organization_details_from_another_org_cannot_be_accessedGET /api/organizations/{id} for a foreign org → 404

OrganizationMultiTenancyTest — 3 cases

Org-level isolation at the API boundary.

  • Users only see their own organization in the org list.
  • Users cannot view another organization's details by ID.
  • User listing (/api/users) excludes members of other organizations.

WorkspaceBrandingKernelTest — 1 case

  • A workspace-scoped admin cannot update a workspace belonging to a different workspace scope, and cannot access that workspace's logo.

🔑 Authentication

AuthenticationTest — 12 cases

Full login/logout flow and token lifecycle.

  • Login with valid credentials → 200, returns { user, profile, token }.
  • Login with wrong password, non-existent email → 422 / 401.
  • Inactive user cannot log in.
  • Validation rejects missing fields.
  • Logout invalidates the bearer token.
  • Authenticated profile fetch and update.
  • Password change: correct flow and wrong-current-password rejection.
  • User without an organization gets auto-assigned on first login.

PublicRegistrationTest — 2 cases

  • Self-registration creates an organization owner and returns a token.
  • Duplicate email is rejected with a validation error.

TwoFactorRecoveryCodesTest — 1 case

  • Consuming a 2FA recovery code persists the remaining codes correctly as a JSON array (regression guard against code doubling/deletion).

🔐 Permissions & Roles

PermissionAuthorizationTest — 7 cases

Core permission-check contract:

  • User with a permission can call the gated endpoint → 200.
  • User without a permission is blocked → 403.
  • Permissions are organization-scoped (same name, different org = different permission).
  • Roles are assignable to users.
  • Roles can hold multiple permissions.
  • Users in org A cannot be assigned permissions by users in org B.
  • Permissions are checked live on API endpoints (not just model-layer).

PermissionAliasCompatibilityTest — 2 cases

Verifies the permission-alias resolution layer: legacy permission names (e.g. school_branding.read) resolve to their canonical equivalents (workspaces.read) both on roles and direct user assignments.

WorkspaceScopedRolePermissionTest — 2 cases

  • A role with a workspace-scoped permission only activates when the PermissionWorkspaceContext matches that workspace.
  • An org-wide role activates in any workspace context within the org.

WorkspacePermissionAndSubscriptionKeyMigrationTest — 2 cases

Regression tests for the naming migration:

  • A fresh seed uses canonical workspaces.* permission names and workspaces subscription feature keys (not school_branding.* or schools).
  • The upgrade migration merges any legacy names into canonical ones without data loss.

🏢 Organizations

OrganizationKernelCrudTest — 4 cases

  • Org admin can update and view their own organization.
  • Tenant API cannot create or delete organizations (platform-only operations return 4xx).

OrganizationOnboardingTest — 3 cases

  • Onboarding endpoint returns steps with correct visibility flags.
  • Org admin can dismiss the onboarding wizard.
  • Org admin can acknowledge the billing step.

OrganizationSystemSettingsControllerTest — 2 cases

  • Org admin can read and update system settings.
  • Workspace-scoped admin (non-org admin) cannot access system settings.

🏗️ Workspaces

WorkspacesTableAliasTest — 1 case

  • The canonical table is workspace_branding; the school_branding VIEW alias still exists; the workspaces VIEW alias also exists. Guards against accidental table renames breaking the schema.

WorkspaceApiCompatibilityTest — 3 cases

  • Profile update payloads use canonical field names only (default_workspace_id, not default_school_id).
  • Workspace branding payloads use canonical keys.
  • Subscription price payloads use canonical keys.

WorkspaceContextSwitchSearchTest — 1 case

  • Passing ?workspace_id= on the search endpoint switches the active workspace for a user who has workspaces_access_all = true.

🪝 Outgoing Webhooks

OrganizationOutgoingWebhookApiTest — 5 cases

  • Org admin full CRUD: create, list, update, delete webhooks.
  • Org admin can create a webhook scoped to a specific workspace.
  • Creating a webhook with a workspace from a different org is rejected.
  • Starter-plan orgs receive 402 with feature_key: outgoing_webhooks.
  • Downgrading plan deactivates previously active webhook rows.

OutgoingWebhookDispatchTest — 7 cases

  • Dispatcher posts a signed JSON payload to the subscribed URL.
  • Dispatcher skips inactive webhook endpoints.
  • The dispatch job can be invoked directly (unit-style).
  • Workspace-scoped endpoints skip org-level dispatches.
  • Workspace-level dispatch targets both org-wide and matching workspace hooks.
  • Dispatcher skips deliveries entirely when the outgoing_webhooks feature is disabled for the organization.
  • Delivery job does not POST when the feature is disabled (queued jobs included).

💳 Subscriptions & Billing

SubscriptionEndpointsTest — 4 cases

  • GET /subscription/status-lite is accessible without subscription.read permission (lightweight check used by the UI).
  • Full status, usage, and features endpoints require subscription.read.
  • status-lite returns none when no active subscription exists.
  • All subscription endpoints require an organization context.

SubscriptionRenewalPaymentLifecycleTest — 1 case

End-to-end renewal lifecycle: submit renewal request → submit payment → platform admin confirms → subscription transitions to active. Idempotency is verified (double-confirm is safe).

StripeWebhookControllerTest — 7 cases

  • customer.subscription.updated updates local subscription status.
  • invoice.payment_succeeded records payment and reactivates subscription from grace period.
  • checkout.session.completed activates a new subscription.
  • invoice.payment_failed sets grace period.
  • customer.subscription.deleted sets cancelled status.
  • Invalid Stripe signature → 400 (security check).
  • Duplicate event (same Stripe event ID) is acknowledged but not reprocessed (idempotency).

🛡️ Platform Administration

LoginAuditControllerTest — 6 cases

  • Successful and failed logins are written to the audit log.
  • Platform admin can list attempts, view alerts, get locked accounts, unlock accounts, and export the log.
  • Regular users cannot access the login audit endpoints.

LoginAttemptLoggingTest — 3 cases

  • Successful login is recorded in tenantx_logs.login_attempts.
  • User-not-found failure is recorded.
  • Invalid-password failure is recorded.

LoginLockoutTest — 2 cases

  • Account locks after the maximum number of failed attempts.
  • Successful login after lockout expiry clears the lock.

LoginRateLimitTest — 1 case

  • Exceeding the per-IP rate limit on the login endpoint returns 429.

PlatformBackupControllerTest — 5 cases

Full lifecycle: create backup → list → download → delete. Plus: restore from existing backup reverts DB changes; upload-and-restore reinstates DB and storage; backup archive contains the correct manifest/DB/storage sections; personal access tokens and sessions tables are excluded from backups.

PlatformOrganizationOrderFormTest — 9 cases

Full CRUD on the order-form resource: show (empty and persisted), create via update, update, status enum validation, file upload with MIME check, soft-delete, and 404 on unknown ID.


🔑 API Tokens

ApiTokenControllerTest — 3 cases

  • Org admin can create, list, and revoke their own API tokens.
  • Org admin cannot revoke another user's token.
  • Workspace-scoped admin (not org admin) cannot manage API tokens.

🗂️ Miscellaneous

FileStorageTest — 2 cases

  • Order form document upload is org-scoped (a user from org A cannot access org B's file).
  • Delete file removes the file from storage.

GoogleOAuthRedirectTest — 3 cases

  • Web route redirects browser to Google OAuth.
  • Legacy API OAuth route forwards to the web route.
  • Legacy callback route preserves query parameters.

OpenApiDocumentationRouteTest — 1 case

  • GET /api/docs/openapi.yaml is publicly served and returns valid YAML content.

ExampleTest — 1 case

  • The application boots and returns HTTP 200 on the root health route.

Test Infrastructure

TestCase Base Class

All feature tests extend Tests\TestCase, which provides:

HelperPurpose
authenticate(attrs, profileAttrs, org, workspace)Creates a user + profile + role + subscription and calls Sanctum::actingAs()
createUser(...)Same as authenticate but does not set the acting-as guard
actingAsUser($user)Thin wrapper around Sanctum::actingAs()
jsonAs($user, $method, $uri, $data)actingAs + json() in one call
ensureActiveSubscription($org, $planSlug)Creates an active subscription for the org if one doesn't exist
ensureSubscriptionPlan($slug)Runs SubscriptionSeeder if the plan doesn't exist, then returns it
ensureLoginAuditLogsSchemaExists()Creates the tenantx_logs PostgreSQL schema if needed (used by login audit tests)

RefreshDatabase

All tests use RefreshDatabase, which wraps each test in a database transaction that is rolled back after the test completes. This means:

  • Each test starts with a clean slate.
  • Tests are isolated from each other.
  • The test database is never left in a dirty state.

Default createUser Options

$options = [
    'withRole'         => true,      // assign the 'admin' role by default
    'role'             => 'admin',
    'withSubscription' => true,      // create an active 'enterprise' subscription
    'planSlug'         => 'enterprise',
];

Override any option via the $options argument:

$user = $this->createUser(
    ['email' => 'test@example.com'],
    ['organization_id' => $org->id],
    $org,
    $workspace,
    null,
    ['withRole' => false]   // create user with NO role/permissions
);

Adding New Tests

Follow these conventions to stay consistent with the existing suite.

1. File naming

{Noun}{What it tests}Test.php — be specific:

  • WorkspaceContextEnforcementTest.php
  • StripeWebhookControllerTest.php
  • WebhookTest.php (too vague)

2. Method naming

Use the #[Test] attribute (PHP 8 style) with snake_case names that read as plain English sentences:

#[Test]
public function user_without_permission_cannot_create_workspace(): void

3. Use $this->authenticate() for happy-path users

$user = $this->authenticate(
    ['email' => 'alice@example.com'],
    ['organization_id' => $org->id],
    $org,
    $workspace,
);

4. Grant extra permissions manually

The base authenticate() assigns the admin role which has no permissions by default in a fresh database. Grant specific permissions for the endpoint under test:

$permId = DB::table('permissions')
    ->where('name', 'workspaces.read')
    ->where('organization_id', $org->id)
    ->value('id');

if (! $permId) {
    $permId = DB::table('permissions')->insertGetId([
        'name'            => 'workspaces.read',
        'guard_name'      => 'web',
        'organization_id' => $org->id,
        'resource'        => 'workspaces',
        'action'          => 'read',
        'created_at'      => now(),
        'updated_at'      => now(),
    ]);
}
// ... then create role, role_has_permissions, model_has_roles rows

(See WorkspaceBrandingKernelTest::grantPermission() for the full reusable pattern.)

5. Platform admin setup

use App\Support\PlatformConstants;

Organization::firstOrCreate(
    ['id' => PlatformConstants::PLATFORM_ORG_ID],
    ['name' => 'Platform (Global)', 'slug' => 'platform-global', 'settings' => []]
);

$permission = Permission::firstOrCreate(
    ['name' => 'subscription.admin', 'guard_name' => 'web', 'organization_id' => null],
    ['resource' => 'subscription', 'action' => 'admin']
);

setPermissionsTeamId(PlatformConstants::PLATFORM_ORG_ID);
$admin->givePermissionTo($permission);
setPermissionsTeamId(null);

app(PermissionRegistrar::class)->forgetCachedPermissions();

(See PlatformAdminIsolationTest::createPlatformAdmin() for the canonical helper.)


CI Integration

Add the following to your CI pipeline (GitHub Actions example):

- name: Run backend tests
  working-directory: backend
  env:
    DB_CONNECTION: pgsql
    DB_HOST: localhost
    DB_DATABASE: tenantx_testing
    DB_USERNAME: postgres
    DB_PASSWORD: password
  run: php artisan test --no-coverage

The TypeScript gate should run alongside:

- name: TypeScript check
  working-directory: frontend
  run: npx tsc --noEmit

Both must pass before merging to main.