Refactors OrganisationScope to support a declarative, recursive FK-chain
resolver and registers the scope on 14 models that previously relied on
caller-discipline for tenant isolation.
Scope resolver (app/Models/Scopes/OrganisationScope.php):
Models now declare their strategy via:
public static function tenantScopeStrategy(): array
{
return ['column' => 'organisation_id']; // terminal
// OR
return ['via' => FormSchema::class, 'fk' => 'form_schema_id'];
}
The apply() path walks the chain recursively, building whereIn subqueries
against parent models until it hits a column-based strategy. Max 3 hops;
deeper chains raise App\Exceptions\TenantScopeResolutionException. The
walker accepts BOTH the new tenantScopeStrategy() and the legacy
$organisationScopeColumn property at every hop — so PersonIdentityMatch
can chain via Person, which still uses the legacy event_id bridge, without
requiring Person/Event/Shift/FestivalSection/TimeSlot to migrate to the
new convention in this work package. That migration is a separate
backlog ticket — explicitly scope-controlled per the addendum.
Fourteen newly-scoped models:
Form-builder child models (D-03):
FormSchemaSection via FormSchema (1 hop)
FormField via FormSchema (1 hop)
FormSubmission column organisation_id (Commit 2)
FormValue via FormSubmission (1 hop)
FormValueOption via FormValue -> FormSubmission (2 hops)
FormSubmissionSectionStatus via FormSubmission (1 hop)
FormSubmissionDelegation via FormSubmission (1 hop)
FormSchemaWebhook via FormSchema (1 hop)
FormWebhookDelivery via FormSubmission (1 hop)
Event-data models (D-04 event-data subset):
ShiftAssignment via Shift (legacy festival_section_id)
ShiftWaitlist via Shift
VolunteerAvailability via TimeSlot (legacy event_id)
PersonSectionPreference via FestivalSection (legacy event_id)
PersonIdentityMatch via Person (legacy event_id)
Note — task directive specified VolunteerAvailability "via: Event, fk: event_id",
but the table has no event_id column (only person_id + time_slot_id).
Rerouted via TimeSlot, which carries the legacy event_id bridge; same
end result, correct FK.
Security-relevant callers made explicit:
PublicFormSchemaResource::toArray() now eagerly loads fields + sections
with withoutGlobalScope(OrganisationScope::class). Prior to this commit
the public form endpoint silently relied on those relations being
unscoped. The PublicFormCrossOrgScopeTest pre-existing assertions still
pass — behaviour unchanged, intent now explicit.
Test fix: FormSchemaApiTest::test_publish_sets_is_published_true was
flaky (factory randomly picked EVENT_REGISTRATION which requires
bindings). Pinned to USER_PROFILE for determinism; PurposeSchemaLifecycleTest
covers the binding-enforcement path.
Test flip: MultiTenancyTest::test_form_schema_webhook_is_not_globally_scoped
renamed to is_scoped_via_fk_chain and asserts the new behaviour: scope
filters by route org, withoutGlobalScope() still exposes cross-org rows.
The test's original purpose ("pin current behaviour so a future refactor
is intentional") is now satisfied by Commit 3 being that intentional
refactor.
Docs:
SCHEMA.md §3.5.11 Rule 5 — tenantScopeStrategy() convention documented;
the 14 newly-scoped models enumerated; link to addendum Q2.
ARCH-FORM-BUILDER.md §4.14 — new section "Multi-tenancy scope chain"
with the hop-count table for all 14 chains and the withoutGlobalScope
pattern for cross-org callers.
Tests: tests/Feature/MultiTenancy/ScopeLeakageTest.php — two orgs with
fully-populated record chains down to each of the 14 leaf models; asserts
scoped queries never cross, withoutGlobalScope still does. Plus: three-
hop chain (FormValueOption) explicitly exercised, legacy-column bridge
verified, over-deep chain raises TenantScopeResolutionException. 16 tests /
31 new assertions. Full suite: 1000 passed (2706 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
521 lines
19 KiB
PHP
521 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\MultiTenancy;
|
|
|
|
use App\Enums\FormBuilder\FormFieldType;
|
|
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
|
|
use App\Enums\IdentityMatchConfidence;
|
|
use App\Enums\IdentityMatchMethod;
|
|
use App\Enums\IdentityMatchStatus;
|
|
use App\Exceptions\TenantScopeResolutionException;
|
|
use App\Models\CrowdType;
|
|
use App\Models\Event;
|
|
use App\Models\FestivalSection;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\FormBuilder\FormSchemaSection;
|
|
use App\Models\FormBuilder\FormSchemaWebhook;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\FormBuilder\FormSubmissionDelegation;
|
|
use App\Models\FormBuilder\FormSubmissionSectionStatus;
|
|
use App\Models\FormBuilder\FormValue;
|
|
use App\Models\FormBuilder\FormValueOption;
|
|
use App\Models\FormBuilder\FormWebhookDelivery;
|
|
use App\Models\Organisation;
|
|
use App\Models\Person;
|
|
use App\Models\PersonIdentityMatch;
|
|
use App\Models\PersonSectionPreference;
|
|
use App\Models\Scopes\OrganisationScope;
|
|
use App\Models\Shift;
|
|
use App\Models\ShiftAssignment;
|
|
use App\Models\ShiftWaitlist;
|
|
use App\Models\TimeSlot;
|
|
use App\Models\User;
|
|
use App\Models\VolunteerAvailability;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Routing\Route;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* WS-4 Commit 3 coverage — addendum Q2 FK-chain scope registration.
|
|
*
|
|
* Two fully-populated tenants (orgA, orgB); for each of the 14 newly-
|
|
* registered models, a query scoped to orgA must never surface orgB
|
|
* rows. `withoutGlobalScope` must continue to expose everything for
|
|
* admin-wide lookups. The 3-hop max and deepest real chain
|
|
* (FormValueOption → FormValue → FormSubmission → organisation_id)
|
|
* are exercised explicitly.
|
|
*/
|
|
final class ScopeLeakageTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Organisation $orgA;
|
|
|
|
private Organisation $orgB;
|
|
|
|
private Event $eventA;
|
|
|
|
private Event $eventB;
|
|
|
|
private FormSchema $schemaA;
|
|
|
|
private FormSchema $schemaB;
|
|
|
|
private FormSubmission $submissionA;
|
|
|
|
private FormSubmission $submissionB;
|
|
|
|
private Person $personA;
|
|
|
|
private Person $personB;
|
|
|
|
private FestivalSection $sectionA;
|
|
|
|
private FestivalSection $sectionB;
|
|
|
|
private Shift $shiftA;
|
|
|
|
private Shift $shiftB;
|
|
|
|
private TimeSlot $timeSlotA;
|
|
|
|
private TimeSlot $timeSlotB;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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'];
|
|
}
|
|
}
|