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_raises_on_over_deep_chain(): void { // Construct a synthetic 5-hop chain by subclassing on the fly: // A → B → C → D → E (column). The resolver caps at 3 hops and // must raise TenantScopeResolutionException. $fiveHopModel = new class extends Model { protected $table = 'form_value_options'; public static function tenantScopeStrategy(): array { return ['via' => FiveHopLevel2::class, 'fk' => 'form_value_id']; } }; $scope = new OrganisationScope('01HZ01HZ01HZ01HZ01HZ01HZ01'); $builder = $fiveHopModel::query(); $this->expectException(TenantScopeResolutionException::class); $scope->apply($builder, $fiveHopModel); } } /** * Synthetic chain model for the over-deep-chain test — four levels * above a terminal column, so resolver walks past its cap. */ 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' => 'organisation_id']; } } final class FiveHopLevel5 extends Model { protected $table = 'organisations'; public static function tenantScopeStrategy(): array { return ['column' => 'id']; } }