seed(RoleSeeder::class); } // ============================================================ // ORG-SCOPED ENDPOINTS — IDOR class (404, never 403) // ============================================================ public function test_org_index_unauthenticated_returns_401(): void { $orgA = Organisation::factory()->create(); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures") ->assertStatus(401); } public function test_org_index_for_other_orgs_admin_returns_404(): void { // RFC V3 IDOR-class — orgB's admin hitting orgA's index URL must // not even confirm the endpoint exists for that org. Without // this check, viewAny (any org_admin in any org) would pass, // and the controller's whereHas filter would emit orgA's // failures to orgB's admin. Tenant gate is the policy's // viewAnyInOrganisation; denied → 404. [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminB); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures") ->assertStatus(404); } public function test_org_index_for_correct_admin_returns_200_with_only_own_failures(): void { [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $failureInOrgA = $this->makeFailureInOrg($orgA); $this->makeFailureInOrg($orgB); // orgA admin hitting orgA's URL → sees only orgA's failures. Sanctum::actingAs($adminA); $response = $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures"); $response->assertOk(); $this->assertCount(1, $response->json('data')); $this->assertSame((string) $failureInOrgA->id, $response->json('data.0.id')); } public function test_org_index_for_authenticated_without_role_returns_404(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $this->makeFailureInOrg($orgA); $stranger = User::factory()->create(); Sanctum::actingAs($stranger); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures") ->assertStatus(404); } public function test_org_show_cross_tenant_returns_404_not_403(): void { [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $failureInOrgA = $this->makeFailureInOrg($orgA); // Attempt 1: orgB admin uses orgB's URL prefix to fetch orgA's failure. Sanctum::actingAs($adminB); $this->getJson("/api/v1/organisations/{$orgB->id}/form-failures/{$failureInOrgA->id}") ->assertStatus(404); // Attempt 2: orgB admin uses orgA's URL prefix (the "real" path) // — policy denies because orgB admin has no role in orgA → 404. Sanctum::actingAs($adminB); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}") ->assertStatus(404); } public function test_org_retry_cross_tenant_returns_404(): void { [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminB); $this->postJson("/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}/retry") ->assertStatus(404); } public function test_org_resolve_cross_tenant_returns_404(): void { [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminB); $this->postJson( "/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}/resolve", ['note' => 'attacker note'], )->assertStatus(404); } public function test_org_dismiss_cross_tenant_returns_404(): void { [$orgA, $adminA, $orgB, $adminB] = $this->makeTwoOrgsWithAdmins(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminB); $this->postJson( "/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}/dismiss", ['reason_type' => DismissalReasonType::OTHER->value, 'note' => 'attacker'], )->assertStatus(404); } public function test_org_show_with_correct_admin_returns_200(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminA); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}") ->assertOk() ->assertJsonPath('data.id', (string) $failureInOrgA->id); } // ============================================================ // PLATFORM ENDPOINTS — Role class (401/403, never 404 for role miss) // ============================================================ public function test_platform_index_unauthenticated_returns_401(): void { $this->getJson('/api/v1/admin/form-failures')->assertStatus(401); } public function test_platform_show_unauthenticated_returns_401(): void { [$orgA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); $this->getJson("/api/v1/admin/form-failures/{$failureInOrgA->id}") ->assertStatus(401); } public function test_platform_index_org_admin_returns_403(): void { // Role-class denial: org_admin lacks super_admin role → 403, // not 404. The endpoint exists; the user is forbidden. [$orgA, $adminA] = $this->makeOrgWithAdmin(); Sanctum::actingAs($adminA); $this->getJson('/api/v1/admin/form-failures')->assertStatus(403); } public function test_platform_show_org_admin_returns_403(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminA); $this->getJson("/api/v1/admin/form-failures/{$failureInOrgA->id}") ->assertStatus(403); } public function test_platform_retry_org_admin_returns_403(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminA); $this->postJson("/api/v1/admin/form-failures/{$failureInOrgA->id}/retry") ->assertStatus(403); } public function test_platform_resolve_org_admin_returns_403(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminA); $this->postJson( "/api/v1/admin/form-failures/{$failureInOrgA->id}/resolve", ['note' => 'attempt'], )->assertStatus(403); } public function test_platform_dismiss_org_admin_returns_403(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($adminA); $this->postJson( "/api/v1/admin/form-failures/{$failureInOrgA->id}/dismiss", ['reason_type' => DismissalReasonType::OTHER->value, 'note' => 'attempt'], )->assertStatus(403); } public function test_platform_show_super_admin_resolves_any_org(): void { $superAdmin = User::factory()->create(); $superAdmin->assignRole('super_admin'); $orgA = Organisation::factory()->create(); $failureInOrgA = $this->makeFailureInOrg($orgA); Sanctum::actingAs($superAdmin); $this->getJson("/api/v1/admin/form-failures/{$failureInOrgA->id}") ->assertOk() ->assertJsonPath('data.id', (string) $failureInOrgA->id); } // ============================================================ // EDGE CASES // ============================================================ public function test_org_show_soft_deleted_parent_submission_returns_404(): void { // Failure exists, but its parent submission is soft-deleted. // Policy treats parent-gone as resource-gone → 404. [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); // Soft-delete the parent submission directly via the model // (the FK is cascadeOnDelete, but soft-delete only sets deleted_at). $failureInOrgA->submission?->delete(); Sanctum::actingAs($adminA); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}") ->assertStatus(404); } public function test_org_show_invalid_ulid_format_returns_404(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); Sanctum::actingAs($adminA); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/notavalidulid") ->assertStatus(404); } public function test_org_show_non_existent_id_returns_404(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $nonExistent = '01HZZZZZZZZZZZZZZZZZZZZZZZ'; Sanctum::actingAs($adminA); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/{$nonExistent}") ->assertStatus(404); } public function test_platform_show_non_existent_id_super_admin_returns_404(): void { $superAdmin = User::factory()->create(); $superAdmin->assignRole('super_admin'); $nonExistent = '01HZZZZZZZZZZZZZZZZZZZZZZZ'; Sanctum::actingAs($superAdmin); $this->getJson("/api/v1/admin/form-failures/{$nonExistent}") ->assertStatus(404); } public function test_org_show_authenticated_without_role_returns_404(): void { // User is authenticated but has NO role on any org. // Policy denies (not org_admin anywhere) → 404 to keep // the IDOR contract: cross-tenant indistinguishable from // does-not-exist. [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); $strangerUser = User::factory()->create(); Sanctum::actingAs($strangerUser); $this->getJson("/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}") ->assertStatus(404); } public function test_org_resolve_authenticated_without_role_returns_404(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); $strangerUser = User::factory()->create(); Sanctum::actingAs($strangerUser); $this->postJson( "/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}/resolve", ['note' => 'stranger'], )->assertStatus(404); } public function test_org_dismiss_authenticated_without_role_returns_404(): void { [$orgA, $adminA] = $this->makeOrgWithAdmin(); $failureInOrgA = $this->makeFailureInOrg($orgA); $strangerUser = User::factory()->create(); Sanctum::actingAs($strangerUser); $this->postJson( "/api/v1/organisations/{$orgA->id}/form-failures/{$failureInOrgA->id}/dismiss", ['reason_type' => DismissalReasonType::OTHER->value, 'note' => 'stranger'], )->assertStatus(404); } // ============================================================ // HELPERS // ============================================================ /** * @return array{0: Organisation, 1: User, 2: Organisation, 3: User} */ private function makeTwoOrgsWithAdmins(): array { [$orgA, $adminA] = $this->makeOrgWithAdmin(); [$orgB, $adminB] = $this->makeOrgWithAdmin(); return [$orgA, $adminA, $orgB, $adminB]; } /** * Attach a fresh org_admin to a new organisation via the * organisation_users pivot (Crewli's user-org pivot pattern; * NOT Spatie's role table). * * @return array{0: Organisation, 1: User} */ private function makeOrgWithAdmin(): array { $org = Organisation::factory()->create(); $admin = User::factory()->create(); $org->users()->attach($admin, ['role' => 'org_admin']); return [$org, $admin]; } private function makeFailureInOrg(Organisation $org): FormSubmissionActionFailure { $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $submission = FormSubmission::factory()->create([ 'form_schema_id' => $schema->id, 'organisation_id' => $org->id, ]); return FormSubmissionActionFailure::factory() ->for($submission, 'submission') ->create(); } }