seed(RoleSeeder::class); $this->orgA = Organisation::factory()->create(); $this->orgB = Organisation::factory()->create(); $this->superAdmin = User::factory()->create(); $this->superAdmin->assignRole('super_admin'); $this->orgAdminA = User::factory()->create(); $this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']); $this->orgAdminB = User::factory()->create(); $this->orgB->users()->attach($this->orgAdminB, ['role' => 'org_admin']); $schema = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]); $submission = FormSubmission::factory()->create([ 'form_schema_id' => $schema->id, 'organisation_id' => $this->orgA->id, ]); $this->failureInOrgA = FormSubmissionActionFailure::factory() ->for($submission, 'submission') ->create(); } public function test_unauthenticated_returns_401(): void { $this->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}") ->assertStatus(401); } public function test_super_admin_can_view_failure(): void { $this->withoutExceptionHandling(); Sanctum::actingAs($this->superAdmin); $this ->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}") ->assertOk() ->assertJsonPath('data.id', (string) $this->failureInOrgA->id); } public function test_org_admin_can_view_own_org_failure(): void { Sanctum::actingAs($this->orgAdminA); $this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}") ->assertOk(); } /** * RFC V3 IDOR-class — admin from org B must NOT see a failure * whose submission belongs to org A. 404, NOT 403. */ public function test_cross_tenant_access_returns_404_not_403(): void { Sanctum::actingAs($this->orgAdminB); $this ->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}") ->assertStatus(404); } public function test_resolve_endpoint_with_note(): void { Sanctum::actingAs($this->superAdmin); $this ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/resolve", [ 'note' => 'fixed via direct edit', ]) ->assertOk() ->assertJsonPath('data.state', 'resolved') ->assertJsonPath('data.resolved_note', 'fixed via direct edit'); } public function test_dismiss_endpoint_with_enum_reason(): void { Sanctum::actingAs($this->superAdmin); $this ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [ 'reason_type' => DismissalReasonType::SCHEMA_DELETED->value, ]) ->assertOk() ->assertJsonPath('data.state', 'dismissed') ->assertJsonPath('data.dismissed_reason_type', DismissalReasonType::SCHEMA_DELETED->value); } public function test_dismiss_other_without_note_fails_422(): void { Sanctum::actingAs($this->superAdmin); $this ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [ 'reason_type' => DismissalReasonType::OTHER->value, ]) ->assertStatus(422) ->assertJsonValidationErrors(['note']); } public function test_cross_tenant_dismiss_returns_404(): void { Sanctum::actingAs($this->orgAdminB); $this ->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/dismiss", [ 'reason_type' => DismissalReasonType::OTHER->value, 'note' => 'evil', ]) ->assertStatus(404); } public function test_cross_tenant_resolve_returns_404(): void { Sanctum::actingAs($this->orgAdminB); $this ->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/resolve", []) ->assertStatus(404); } }