Files
crewli/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php
bert.hausmans 2064b9901e feat(form-builder): form_field_conditional_logic_{groups,conditions} tables + OrganisationScope cap raise to 5
WS-5c commit 1 of 4 — relational infrastructure for the conditional-
logic tree that replaces form_fields.conditional_logic JSON (ARCH-
FORM-BUILDER §8; addendum Q3 WS-5c).

Tables: groups (nesting via parent_group_id) + conditions (leaves,
value JSON nullable for empty/not_empty). Simple FK to form_fields —
addendum Q3 explicitly excludes form_field_library from conditional_
logic scope, so no polymorphic morph here.

OrganisationScope cap raised 3 → 5 hops. The conditions chain is
4 hops (condition → group → field → schema → organisation_id column)
and the new cap gives headroom for future deeper trees without
denormalising form_field_id onto conditions.

Cascade observer (FormFieldChildTablesCascadeObserver) extended to
physically delete the new groups table on FormField delete (hard or
soft). Conditions cascade automatically via the group_id FK on the
groups table.

Factories: FormFieldConditionalLogicGroupFactory, FormFieldConditional
LogicConditionFactory, and FormFieldFactory::withConditionalLogic($tree)
for concise test fixtures.

Tests: 16 new under tests/Feature/FormBuilder/ConditionalLogic/
(relation, scope, cascade, enum catalogue). 3 new scope-cap tests in
ScopeLeakageTest verify 4/5-hop chains pass and 6-hop throws. Hardcoded
rollback step counts in WS-5a/b migration tests bumped for the 2 new
WS-5c migrations. Baseline 1104 → 1122 green (2988 → 3032 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:43:34 +02:00

666 lines
23 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_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'];
}
}