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(seephpunit.xml). A separatetenantx_testingdatabase is expected. Set it inbackend/.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.
| Case | What it asserts |
|---|---|
unauthenticated_request_is_rejected_from_platform_routes | No token → 401 |
regular_org_user_cannot_access_platform_organizations_list | Full org admin, no subscription.admin → 403 |
regular_org_user_cannot_access_any_platform_endpoint | Sweeps three platform routes → all 403 |
platform_admin_can_list_all_organizations | Platform admin sees orgs from multiple tenants |
platform_admin_can_list_all_subscriptions | Response 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.
| Case | What it asserts |
|---|---|
user_with_valid_workspace_can_access_workspace_gated_route | Valid profile + workspace → not blocked by middleware |
user_whose_default_workspace_belongs_to_another_org_is_blocked | Profile pointing at wrong-org workspace → 403 Invalid workspace context |
user_without_workspaces_access_all_cannot_switch_to_another_workspace | Passing ?workspace_id= without the flag → 403 Invalid workspace scope |
user_with_workspaces_access_all_can_switch_to_another_workspace_in_same_org | workspaces_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.
| Case | What 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_denied | GET /api/workspaces/{id} for a foreign workspace → 403 or 404 |
organization_details_from_another_org_cannot_be_accessed | GET /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
PermissionWorkspaceContextmatches 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 andworkspacessubscription feature keys (notschool_branding.*orschools). - 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; theschool_brandingVIEW alias still exists; theworkspacesVIEW 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, notdefault_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 hasworkspaces_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
402withfeature_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_webhooksfeature 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-liteis accessible withoutsubscription.readpermission (lightweight check used by the UI).- Full status, usage, and features endpoints require
subscription.read. status-litereturnsnonewhen 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.updatedupdates local subscription status.invoice.payment_succeededrecords payment and reactivates subscription from grace period.checkout.session.completedactivates a new subscription.invoice.payment_failedsets grace period.customer.subscription.deletedsets 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.yamlis 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:
| Helper | Purpose |
|---|---|
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.