orgA = Organisation::factory()->create(); $this->orgB = Organisation::factory()->create(); $this->eventA = Event::factory()->for($this->orgA)->create(); $this->eventB = Event::factory()->for($this->orgB)->create(); $this->sectionA = FestivalSection::factory()->for($this->eventA)->create(); $this->sectionB = FestivalSection::factory()->for($this->eventB)->create(); $this->timeSlotA = TimeSlot::factory()->for($this->eventA)->create(); $this->timeSlotB = TimeSlot::factory()->for($this->eventB)->create(); $this->shiftA = Shift::factory()->for($this->sectionA, 'festivalSection')->for($this->timeSlotA, 'timeSlot')->create(); $this->shiftB = Shift::factory()->for($this->sectionB, 'festivalSection')->for($this->timeSlotB, 'timeSlot')->create(); $crowdA = CrowdType::factory()->for($this->orgA)->create(); $crowdB = CrowdType::factory()->for($this->orgB)->create(); $this->personA = Person::factory()->for($this->eventA)->for($crowdA)->create(); $this->personB = Person::factory()->for($this->eventB)->for($crowdB)->create(); $this->schemaA = FormSchema::factory()->for($this->orgA)->create(); $this->schemaB = FormSchema::factory()->for($this->orgB)->create(); $this->submissionA = FormSubmission::factory()->for($this->schemaA, 'schema')->create(); $this->submissionB = FormSubmission::factory()->for($this->schemaB, 'schema')->create(); } private function withOrgRoute(Organisation $org): void { $route = new Route(['GET'], '/_test', static fn () => null); $route->bind(request()); $route->setParameter('organisation', $org); request()->setRouteResolver(static fn () => $route); } // ------------------------------------------------------------------ // D-03: Nine form-builder child models // ------------------------------------------------------------------ public function test_form_schema_section_scope_blocks_cross_org(): void { FormSchemaSection::factory()->for($this->schemaA, 'schema')->create(); FormSchemaSection::factory()->for($this->schemaB, 'schema')->create(); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormSchemaSection::query()->count()); $this->assertSame(2, FormSchemaSection::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_field_scope_blocks_cross_org(): void { FormField::factory()->for($this->schemaA, 'schema')->create(); FormField::factory()->for($this->schemaB, 'schema')->create(); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormField::query()->count()); $this->assertSame(2, FormField::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_submission_scope_blocks_cross_org_via_denormalized_column(): void { // Commit 2 added organisation_id as a direct column — this model // uses the `column` strategy, not `via`. Proves the column // strategy still works alongside the FK-chain. $this->withOrgRoute($this->orgA); $this->assertSame(1, FormSubmission::query()->count()); $this->assertSame(2, FormSubmission::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_value_scope_blocks_cross_org(): void { $fieldA = FormField::factory()->for($this->schemaA, 'schema')->create(); $fieldB = FormField::factory()->for($this->schemaB, 'schema')->create(); FormValue::create([ 'form_submission_id' => $this->submissionA->id, 'form_field_id' => $fieldA->id, 'value' => ['value' => 'a'], 'value_anonymised' => false, ]); FormValue::create([ 'form_submission_id' => $this->submissionB->id, 'form_field_id' => $fieldB->id, 'value' => ['value' => 'b'], 'value_anonymised' => false, ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormValue::query()->count()); $this->assertSame(2, FormValue::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_value_option_scope_handles_three_hop_chain(): void { // Deepest real chain in the codebase: // FormValueOption → FormValue → FormSubmission → organisation_id. $fieldA = FormField::factory()->for($this->schemaA, 'schema')->create([ 'field_type' => FormFieldType::MULTISELECT->value, ]); $fieldB = FormField::factory()->for($this->schemaB, 'schema')->create([ 'field_type' => FormFieldType::MULTISELECT->value, ]); $valueA = FormValue::create([ 'form_submission_id' => $this->submissionA->id, 'form_field_id' => $fieldA->id, 'value' => ['x'], 'value_anonymised' => false, ]); $valueB = FormValue::create([ 'form_submission_id' => $this->submissionB->id, 'form_field_id' => $fieldB->id, 'value' => ['y'], 'value_anonymised' => false, ]); FormValueOption::create([ 'form_value_id' => $valueA->id, 'form_field_id' => $fieldA->id, 'form_submission_id' => $this->submissionA->id, 'option_value' => 'x', ]); FormValueOption::create([ 'form_value_id' => $valueB->id, 'form_field_id' => $fieldB->id, 'form_submission_id' => $this->submissionB->id, 'option_value' => 'y', ]); $this->withOrgRoute($this->orgA); $this->assertSame( 1, FormValueOption::query()->count(), 'Three-hop chain must filter: FormValueOption → FormValue → FormSubmission → organisation_id' ); $this->assertSame(2, FormValueOption::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_submission_section_status_scope_blocks_cross_org(): void { $sectionA = FormSchemaSection::factory()->for($this->schemaA, 'schema')->create(); $sectionB = FormSchemaSection::factory()->for($this->schemaB, 'schema')->create(); FormSubmissionSectionStatus::factory()->create([ 'form_submission_id' => $this->submissionA->id, 'form_schema_section_id' => $sectionA->id, ]); FormSubmissionSectionStatus::factory()->create([ 'form_submission_id' => $this->submissionB->id, 'form_schema_section_id' => $sectionB->id, ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormSubmissionSectionStatus::query()->count()); $this->assertSame(2, FormSubmissionSectionStatus::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_submission_delegation_scope_blocks_cross_org(): void { $userA = User::factory()->create(); $userB = User::factory()->create(); FormSubmissionDelegation::create([ 'form_submission_id' => $this->submissionA->id, 'delegated_to_user_id' => $userA->id, 'delegated_by_user_id' => $userA->id, 'granted_at' => now(), ]); FormSubmissionDelegation::create([ 'form_submission_id' => $this->submissionB->id, 'delegated_to_user_id' => $userB->id, 'delegated_by_user_id' => $userB->id, 'granted_at' => now(), ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormSubmissionDelegation::query()->count()); $this->assertSame(2, FormSubmissionDelegation::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_schema_webhook_scope_blocks_cross_org(): void { FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create(); FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create(); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormSchemaWebhook::query()->count()); $this->assertSame(2, FormSchemaWebhook::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_form_webhook_delivery_scope_blocks_cross_org(): void { $webhookA = FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create(); $webhookB = FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create(); FormWebhookDelivery::create([ 'form_schema_webhook_id' => $webhookA->id, 'form_submission_id' => $this->submissionA->id, 'trigger_event' => 'submission.submitted', 'status' => FormWebhookDeliveryStatus::PENDING->value, 'attempts' => 0, 'payload_snapshot' => ['x' => 1], ]); FormWebhookDelivery::create([ 'form_schema_webhook_id' => $webhookB->id, 'form_submission_id' => $this->submissionB->id, 'trigger_event' => 'submission.submitted', 'status' => FormWebhookDeliveryStatus::PENDING->value, 'attempts' => 0, 'payload_snapshot' => ['y' => 1], ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, FormWebhookDelivery::query()->count()); $this->assertSame(2, FormWebhookDelivery::withoutGlobalScope(OrganisationScope::class)->count()); } // ------------------------------------------------------------------ // D-04 event-data subset: five models // ------------------------------------------------------------------ public function test_shift_assignment_scope_blocks_cross_org(): void { ShiftAssignment::factory() ->for($this->shiftA) ->for($this->personA) ->for($this->timeSlotA) ->create(); ShiftAssignment::factory() ->for($this->shiftB) ->for($this->personB) ->for($this->timeSlotB) ->create(); $this->withOrgRoute($this->orgA); $this->assertSame(1, ShiftAssignment::query()->count()); $this->assertSame(2, ShiftAssignment::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_shift_waitlist_scope_blocks_cross_org(): void { ShiftWaitlist::create([ 'shift_id' => $this->shiftA->id, 'person_id' => $this->personA->id, 'position' => 1, 'added_at' => now(), ]); ShiftWaitlist::create([ 'shift_id' => $this->shiftB->id, 'person_id' => $this->personB->id, 'position' => 1, 'added_at' => now(), ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, ShiftWaitlist::query()->count()); $this->assertSame(2, ShiftWaitlist::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_volunteer_availability_scope_blocks_cross_org(): void { VolunteerAvailability::create([ 'person_id' => $this->personA->id, 'time_slot_id' => $this->timeSlotA->id, 'preference_level' => 3, 'submitted_at' => now(), ]); VolunteerAvailability::create([ 'person_id' => $this->personB->id, 'time_slot_id' => $this->timeSlotB->id, 'preference_level' => 3, 'submitted_at' => now(), ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, VolunteerAvailability::query()->count()); $this->assertSame(2, VolunteerAvailability::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_person_section_preference_scope_blocks_cross_org(): void { PersonSectionPreference::create([ 'person_id' => $this->personA->id, 'festival_section_id' => $this->sectionA->id, 'priority' => 1, ]); PersonSectionPreference::create([ 'person_id' => $this->personB->id, 'festival_section_id' => $this->sectionB->id, 'priority' => 1, ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, PersonSectionPreference::query()->count()); $this->assertSame(2, PersonSectionPreference::withoutGlobalScope(OrganisationScope::class)->count()); } public function test_person_identity_match_scope_blocks_cross_org(): void { $matchedUserA = User::factory()->create(); $matchedUserB = User::factory()->create(); PersonIdentityMatch::create([ 'person_id' => $this->personA->id, 'matched_user_id' => $matchedUserA->id, 'matched_on' => IdentityMatchMethod::EMAIL, 'confidence' => IdentityMatchConfidence::HIGH, 'status' => IdentityMatchStatus::PENDING, 'match_details' => [], ]); PersonIdentityMatch::create([ 'person_id' => $this->personB->id, 'matched_user_id' => $matchedUserB->id, 'matched_on' => IdentityMatchMethod::EMAIL, 'confidence' => IdentityMatchConfidence::HIGH, 'status' => IdentityMatchStatus::PENDING, 'match_details' => [], ]); $this->withOrgRoute($this->orgA); $this->assertSame(1, PersonIdentityMatch::query()->count()); $this->assertSame(2, PersonIdentityMatch::withoutGlobalScope(OrganisationScope::class)->count()); } // ------------------------------------------------------------------ // Resolver mechanics: legacy-bridge compatibility + hop limit // ------------------------------------------------------------------ public function test_resolver_accepts_legacy_organisation_scope_column_bridge(): void { // PersonIdentityMatch → Person. Person uses the legacy // $organisationScopeColumn = 'event_id' bridge (not // tenantScopeStrategy()). The recursive resolver must treat it // as a terminal column strategy. $this->withOrgRoute($this->orgA); $this->assertInstanceOf( PersonIdentityMatch::class, PersonIdentityMatch::query()->getModel(), 'Scope must boot on models whose parent uses the legacy property' ); // Actually prove the chain works end-to-end: one row per org, // scoped query returns only the owned row. $matchedUserA = User::factory()->create(); $matchedUserB = User::factory()->create(); PersonIdentityMatch::create([ 'person_id' => $this->personA->id, 'matched_user_id' => $matchedUserA->id, 'matched_on' => IdentityMatchMethod::EMAIL, 'confidence' => IdentityMatchConfidence::HIGH, 'status' => IdentityMatchStatus::PENDING, 'match_details' => [], ]); PersonIdentityMatch::create([ 'person_id' => $this->personB->id, 'matched_user_id' => $matchedUserB->id, 'matched_on' => IdentityMatchMethod::EMAIL, 'confidence' => IdentityMatchConfidence::HIGH, 'status' => IdentityMatchStatus::PENDING, 'match_details' => [], ]); $this->assertSame(1, PersonIdentityMatch::query()->count()); } public function test_resolver_resolves_four_hop_chain_within_cap(): void { // 4 via-hops + terminal column = legitimate tree inside the cap. // Build the query and assert no exception; the SQL references every // intermediate table, proving the walker reached the terminal. $scope = new OrganisationScope($this->orgA->id); $builder = (new FourHopLevel1())::query(); $scope->apply($builder, new FourHopLevel1()); $sql = $builder->toSql(); $this->assertStringContainsString('form_value_options', $sql); $this->assertStringContainsString('form_values', $sql); $this->assertStringContainsString('form_submissions', $sql); $this->assertStringContainsString('form_schemas', $sql); $this->assertStringContainsString('organisation_id', $sql); } public function test_resolver_resolves_five_hop_chain_at_cap(): void { // 5 via-hops + terminal column — right at the cap ceiling. $scope = new OrganisationScope($this->orgA->id); $builder = (new FiveHopLevel1())::query(); $scope->apply($builder, new FiveHopLevel1()); $this->assertStringContainsString('organisation_id', $builder->toSql()); } public function test_resolver_raises_when_chain_exceeds_cap(): void { // 6 via-hops — one past the cap of 5. Must raise. $scope = new OrganisationScope($this->orgA->id); $builder = (new SevenHopLevel1())::query(); $this->expectException(TenantScopeResolutionException::class); $scope->apply($builder, new SevenHopLevel1()); } } /** * Synthetic chain models for the scope-cap tests. Tables referenced * exist in the schema so query compilation succeeds; the intermediate * relations are never actually executed in these tests (we assert on * compiled SQL, not on rows). * * FourHopLevel1 → Level2 → Level3 → Level4 (terminal column). * FiveHopLevel1 → Level2 → Level3 → Level4 → Level5 (terminal column). * SevenHopLevel1 → ... → Level7 (terminal column) — exceeds cap=5. */ final class FourHopLevel1 extends Model { protected $table = 'form_value_options'; public static function tenantScopeStrategy(): array { return ['via' => FourHopLevel2::class, 'fk' => 'form_value_id']; } } final class FourHopLevel2 extends Model { protected $table = 'form_values'; public static function tenantScopeStrategy(): array { return ['via' => FourHopLevel3::class, 'fk' => 'form_submission_id']; } } final class FourHopLevel3 extends Model { protected $table = 'form_submissions'; public static function tenantScopeStrategy(): array { return ['via' => FourHopLevel4::class, 'fk' => 'form_schema_id']; } } final class FourHopLevel4 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['column' => 'organisation_id']; } } final class FiveHopLevel1 extends Model { protected $table = 'form_value_options'; public static function tenantScopeStrategy(): array { return ['via' => FiveHopLevel2::class, 'fk' => 'form_value_id']; } } final class FiveHopLevel2 extends Model { protected $table = 'form_values'; public static function tenantScopeStrategy(): array { return ['via' => FiveHopLevel3::class, 'fk' => 'form_submission_id']; } } final class FiveHopLevel3 extends Model { protected $table = 'form_submissions'; public static function tenantScopeStrategy(): array { return ['via' => FiveHopLevel4::class, 'fk' => 'form_schema_id']; } } final class FiveHopLevel4 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['via' => FiveHopLevel5::class, 'fk' => 'id']; } } final class FiveHopLevel5 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['column' => 'organisation_id']; } } final class SevenHopLevel1 extends Model { protected $table = 'form_value_options'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel2::class, 'fk' => 'form_value_id']; } } final class SevenHopLevel2 extends Model { protected $table = 'form_values'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel3::class, 'fk' => 'form_submission_id']; } } final class SevenHopLevel3 extends Model { protected $table = 'form_submissions'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel4::class, 'fk' => 'form_schema_id']; } } final class SevenHopLevel4 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel5::class, 'fk' => 'id']; } } final class SevenHopLevel5 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel6::class, 'fk' => 'id']; } } final class SevenHopLevel6 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['via' => SevenHopLevel7::class, 'fk' => 'id']; } } final class SevenHopLevel7 extends Model { protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { return ['column' => 'organisation_id']; } }