Files
crewli/api/tests/Feature/Security/MultiTenancyIsolationTest.php

298 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Security;
use App\Models\Company;
use App\Models\CrowdList;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\TimeSlot;
use App\Models\User;
use App\Models\UserInvitation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class MultiTenancyIsolationTest extends TestCase
{
use RefreshDatabase;
private Organisation $orgA;
private Organisation $orgB;
private Event $eventA;
private Event $eventB;
private User $adminA;
private User $adminB;
private CrowdType $crowdTypeA;
private CrowdType $crowdTypeB;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
// Organisation A
$this->orgA = Organisation::factory()->create();
$this->adminA = User::factory()->create();
$this->orgA->users()->attach($this->adminA, ['role' => 'org_admin']);
$this->crowdTypeA = CrowdType::factory()->systemType('VOLUNTEER')->create(['organisation_id' => $this->orgA->id]);
$this->eventA = Event::factory()->create([
'organisation_id' => $this->orgA->id,
'status' => 'registration_open',
]);
// Organisation B
$this->orgB = Organisation::factory()->create();
$this->adminB = User::factory()->create();
$this->orgB->users()->attach($this->adminB, ['role' => 'org_admin']);
$this->crowdTypeB = CrowdType::factory()->systemType('VOLUNTEER')->create(['organisation_id' => $this->orgB->id]);
$this->eventB = Event::factory()->create([
'organisation_id' => $this->orgB->id,
'status' => 'registration_open',
]);
}
// --- Cross-tenant event access ---
public function test_cannot_view_other_org_event(): void
{
Sanctum::actingAs($this->adminA);
$response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/events/{$this->eventB->id}");
$response->assertNotFound();
}
public function test_cannot_list_other_org_events(): void
{
Sanctum::actingAs($this->adminA);
$response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/events");
$response->assertOk();
$eventIds = collect($response->json('data'))->pluck('id');
$this->assertContains($this->eventA->id, $eventIds->all());
$this->assertNotContains($this->eventB->id, $eventIds->all());
}
// --- Cross-tenant person assignment ---
public function test_cannot_create_person_with_other_org_crowd_type(): void
{
Sanctum::actingAs($this->adminA);
$response = $this->postJson("/api/v1/events/{$this->eventA->id}/persons", [
'crowd_type_id' => $this->crowdTypeB->id,
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('crowd_type_id');
}
public function test_cannot_assign_person_from_other_org_to_shift(): void
{
Sanctum::actingAs($this->adminA);
$personB = Person::factory()->approved()->create([
'event_id' => $this->eventB->id,
'crowd_type_id' => $this->crowdTypeB->id,
]);
$sectionA = FestivalSection::factory()->create(['event_id' => $this->eventA->id]);
$timeSlotA = TimeSlot::factory()->create(['event_id' => $this->eventA->id]);
$shiftA = Shift::factory()->create([
'festival_section_id' => $sectionA->id,
'time_slot_id' => $timeSlotA->id,
]);
$response = $this->postJson(
"/api/v1/events/{$this->eventA->id}/sections/{$sectionA->id}/shifts/{$shiftA->id}/assign",
['person_id' => $personB->id]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('person_id');
}
// --- Cross-tenant crowd list operations ---
public function test_cannot_add_person_from_other_org_to_crowd_list(): void
{
Sanctum::actingAs($this->adminA);
$personB = Person::factory()->approved()->create([
'event_id' => $this->eventB->id,
'crowd_type_id' => $this->crowdTypeB->id,
]);
$crowdListA = CrowdList::factory()->create([
'event_id' => $this->eventA->id,
'crowd_type_id' => $this->crowdTypeA->id,
]);
$response = $this->postJson(
"/api/v1/events/{$this->eventA->id}/crowd-lists/{$crowdListA->id}/persons",
['person_id' => $personB->id]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('person_id');
}
// --- Cross-tenant bulk operations ---
public function test_cannot_bulk_approve_other_org_assignments(): void
{
Sanctum::actingAs($this->adminA);
$sectionB = FestivalSection::factory()->create(['event_id' => $this->eventB->id]);
$timeSlotB = TimeSlot::factory()->create(['event_id' => $this->eventB->id]);
$shiftB = Shift::factory()->create([
'festival_section_id' => $sectionB->id,
'time_slot_id' => $timeSlotB->id,
]);
$personB = Person::factory()->approved()->create([
'event_id' => $this->eventB->id,
'crowd_type_id' => $this->crowdTypeB->id,
]);
$assignmentB = ShiftAssignment::factory()->create([
'shift_id' => $shiftB->id,
'person_id' => $personB->id,
'time_slot_id' => $timeSlotB->id,
]);
$response = $this->postJson(
"/api/v1/events/{$this->eventA->id}/shift-assignments/bulk-approve",
['assignment_ids' => [$assignmentB->id]]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('assignment_ids.0');
}
// --- Cross-tenant invitation revocation ---
public function test_cannot_revoke_other_org_invitation(): void
{
Sanctum::actingAs($this->adminA);
$invitationB = UserInvitation::factory()->create([
'organisation_id' => $this->orgB->id,
'invited_by_user_id' => $this->adminB->id,
]);
$response = $this->deleteJson(
"/api/v1/organisations/{$this->orgA->id}/invitations/{$invitationB->id}"
);
$response->assertNotFound();
}
// --- Cross-tenant company reference ---
public function test_cannot_create_person_with_other_org_company(): void
{
Sanctum::actingAs($this->adminA);
$companyB = Company::factory()->create(['organisation_id' => $this->orgB->id]);
$response = $this->postJson("/api/v1/events/{$this->eventA->id}/persons", [
'crowd_type_id' => $this->crowdTypeA->id,
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com',
'company_id' => $companyB->id,
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('company_id');
}
// --- Cross-tenant crowd list creation ---
public function test_cannot_create_crowd_list_with_other_org_crowd_type(): void
{
Sanctum::actingAs($this->adminA);
$response = $this->postJson("/api/v1/events/{$this->eventA->id}/crowd-lists", [
'crowd_type_id' => $this->crowdTypeB->id,
'name' => 'Test List',
'type' => 'accreditation',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('crowd_type_id');
}
// --- Cross-tenant event parent reference ---
public function test_cannot_set_parent_event_from_other_org(): void
{
Sanctum::actingAs($this->adminA);
$response = $this->putJson(
"/api/v1/organisations/{$this->orgA->id}/events/{$this->eventA->id}",
['parent_event_id' => $this->eventB->id]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('parent_event_id');
}
// --- OrganisationScope filters correctly ---
public function test_organisation_scope_filters_persons(): void
{
$personA = Person::factory()->approved()->create([
'event_id' => $this->eventA->id,
'crowd_type_id' => $this->crowdTypeA->id,
]);
Person::factory()->approved()->create([
'event_id' => $this->eventB->id,
'crowd_type_id' => $this->crowdTypeB->id,
]);
Sanctum::actingAs($this->adminA);
$response = $this->getJson("/api/v1/events/{$this->eventA->id}/persons");
$response->assertOk();
$personIds = collect($response->json('data'))->pluck('id');
$this->assertContains($personA->id, $personIds->all());
$this->assertCount(1, $personIds);
}
// --- Portal cross-event access ---
public function test_portal_me_cannot_access_other_org_event(): void
{
$volunteer = User::factory()->create();
Person::factory()->approved()->create([
'event_id' => $this->eventA->id,
'crowd_type_id' => $this->crowdTypeA->id,
'user_id' => $volunteer->id,
'email' => $volunteer->email,
]);
Sanctum::actingAs($volunteer);
// Volunteer tries to access event from org B (where they have no person record)
$response = $this->getJson("/api/v1/portal/me?event_id={$this->eventB->id}");
$response->assertNotFound();
}
}