diff --git a/api/app/Http/Middleware/PortalTokenMiddleware.php b/api/app/Http/Middleware/PortalTokenMiddleware.php index 7c671403..2d36f3dd 100644 --- a/api/app/Http/Middleware/PortalTokenMiddleware.php +++ b/api/app/Http/Middleware/PortalTokenMiddleware.php @@ -33,11 +33,9 @@ final class PortalTokenMiddleware return response()->json(['message' => 'Portal token required.'], 401); } - $request->merge([ - 'portal_context' => 'artist', - 'portal_person' => $artist, - 'portal_event' => $event, - ]); + $request->attributes->set('portal_context', 'artist'); + $request->attributes->set('portal_person', $artist); + $request->attributes->set('portal_event', $event); return $next($request); } @@ -53,11 +51,9 @@ final class PortalTokenMiddleware return response()->json(['message' => 'Portal token required.'], 401); } - $request->merge([ - 'portal_context' => 'supplier', - 'portal_person' => $productionRequest, - 'portal_event' => $event, - ]); + $request->attributes->set('portal_context', 'supplier'); + $request->attributes->set('portal_person', $productionRequest); + $request->attributes->set('portal_event', $event); return $next($request); } diff --git a/api/tests/Feature/Security/AuthenticationSecurityTest.php b/api/tests/Feature/Security/AuthenticationSecurityTest.php new file mode 100644 index 00000000..1340a698 --- /dev/null +++ b/api/tests/Feature/Security/AuthenticationSecurityTest.php @@ -0,0 +1,242 @@ +seed(RoleSeeder::class); + } + + // --- Rate Limiting --- + + public function test_login_rate_limited_after_5_attempts(): void + { + $user = User::factory()->create(); + + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'WrongPassword1', + ]); + } + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'WrongPassword1', + ]); + + $response->assertStatus(429); + } + + public function test_portal_token_auth_rate_limited(): void + { + for ($i = 0; $i < 10; $i++) { + $this->postJson('/api/v1/portal/token-auth', [ + 'token' => 'invalid-token-' . $i, + ]); + } + + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => 'invalid-token-final', + ]); + + $response->assertStatus(429); + } + + public function test_invitation_show_rate_limited(): void + { + for ($i = 0; $i < 10; $i++) { + $this->getJson('/api/v1/invitations/fake-token-' . $i); + } + + $response = $this->getJson('/api/v1/invitations/fake-token-final'); + + $response->assertStatus(429); + } + + // --- Account Enumeration Prevention --- + + public function test_failed_login_returns_generic_message(): void + { + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'nonexistent@example.com', + 'password' => 'SomePassword1', + ]); + + $response->assertUnauthorized(); + // Should NOT say "User not found" or "Email does not exist" + $response->assertJson(['message' => 'Invalid credentials']); + } + + public function test_failed_login_same_message_for_wrong_password(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'WrongPassword1', + ]); + + $response->assertUnauthorized(); + $response->assertJson(['message' => 'Invalid credentials']); + } + + public function test_password_reset_returns_success_for_unknown_email(): void + { + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'nonexistent@example.com', + ]); + + // Must return 200 regardless — don't leak whether email exists + $response->assertOk(); + } + + public function test_password_reset_returns_success_for_known_email(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => $user->email, + ]); + + $response->assertOk(); + } + + // --- Token Lifecycle --- + + public function test_logout_revokes_current_token(): void + { + $user = User::factory()->create(); + $token = $user->createToken('auth-token')->plainTextToken; + + // Use token for logout + $this->withHeaders(['Authorization' => "Bearer {$token}"]) + ->postJson('/api/v1/auth/logout') + ->assertOk(); + + // Verify the token record is deleted from the database + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_password_reset_revokes_all_tokens(): void + { + $user = User::factory()->create(['email' => 'tokens@test.nl']); + + // Create two tokens + $user->createToken('device-1'); + $user->createToken('device-2'); + $this->assertDatabaseCount('personal_access_tokens', 2); + + // Reset password + $resetToken = Password::createToken($user); + + $this->postJson('/api/v1/auth/reset-password', [ + 'token' => $resetToken, + 'email' => 'tokens@test.nl', + 'password' => 'NewPassword123', + 'password_confirmation' => 'NewPassword123', + ])->assertOk(); + + // All tokens should be deleted from the database + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_token_expiration_is_configured(): void + { + // Verify Sanctum expiration is set to 7 days (10080 minutes) + $expiration = config('sanctum.expiration'); + $this->assertEquals(60 * 24 * 7, $expiration); + + // Create a token and backdate its created_at to 8 days ago + $user = User::factory()->create(); + $accessToken = $user->createToken('auth-token'); + + // Sanctum checks created_at against the expiration window + $accessToken->accessToken->forceFill([ + 'created_at' => now()->subDays(8), + ])->save(); + + // Token created 8 days ago should be expired (7-day expiry) + $this->withHeaders(['Authorization' => "Bearer {$accessToken->plainTextToken}"]) + ->getJson('/api/v1/auth/me') + ->assertUnauthorized(); + } + + // --- Password Strength --- + + public function test_weak_password_rejected_on_registration(): void + { + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => 'fake', + 'email' => 'test@test.nl', + 'password' => 'weakpass', + 'password_confirmation' => 'weakpass', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('password'); + } + + public function test_password_without_uppercase_rejected(): void + { + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => 'fake', + 'email' => 'test@test.nl', + 'password' => 'lowercase1', + 'password_confirmation' => 'lowercase1', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('password'); + } + + public function test_password_without_numbers_rejected(): void + { + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => 'fake', + 'email' => 'test@test.nl', + 'password' => 'NoNumbersHere', + 'password_confirmation' => 'NoNumbersHere', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('password'); + } + + // --- Security Headers --- + + public function test_security_headers_present(): void + { + $response = $this->getJson('/api/v1/'); + + $response->assertHeader('X-Content-Type-Options', 'nosniff'); + $response->assertHeader('X-Frame-Options', 'DENY'); + $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + } + + // --- Protected Routes --- + + public function test_protected_routes_require_authentication(): void + { + $this->getJson('/api/v1/auth/me')->assertUnauthorized(); + $this->postJson('/api/v1/auth/logout')->assertUnauthorized(); + $this->getJson('/api/v1/organisations')->assertUnauthorized(); + $this->getJson('/api/v1/portal/me?event_id=fake')->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/Security/InputValidationSecurityTest.php b/api/tests/Feature/Security/InputValidationSecurityTest.php new file mode 100644 index 00000000..1c61aa99 --- /dev/null +++ b/api/tests/Feature/Security/InputValidationSecurityTest.php @@ -0,0 +1,367 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->organisation->users()->attach($this->admin, ['role' => 'org_admin']); + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + } + + // --- XSS Payload Storage --- + + public function test_xss_in_person_name_is_stored_safely(): void + { + Sanctum::actingAs($this->admin); + + $xssPayload = ''; + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => $xssPayload, + 'last_name' => 'Normal', + 'email' => 'xss-test@example.com', + ]); + + $response->assertCreated(); + + // Value is stored as-is (Laravel escapes on output via {{ }}) + $this->assertDatabaseHas('persons', [ + 'first_name' => $xssPayload, + 'email' => 'xss-test@example.com', + ]); + + // API resource should return the raw string (Vue's {{ }} escapes it) + $response->assertJsonPath('data.first_name', $xssPayload); + } + + public function test_xss_in_event_name_is_stored_safely(): void + { + Sanctum::actingAs($this->admin); + + $xssPayload = '">'; + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => $xssPayload, + 'slug' => 'xss-test-event', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.name', $xssPayload); + } + + public function test_xss_in_section_name_is_stored_safely(): void + { + Sanctum::actingAs($this->admin); + + $xssPayload = ''; + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [ + 'name' => $xssPayload, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.name', $xssPayload); + } + + // --- Oversized Input Rejection --- + + public function test_oversized_person_name_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => str_repeat('A', 256), + 'last_name' => 'Normal', + 'email' => 'oversize@example.com', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('first_name'); + } + + public function test_oversized_event_name_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => str_repeat('A', 256), + 'slug' => 'oversize-test', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('name'); + } + + public function test_oversized_remarks_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'remarks@example.com', + 'remarks' => str_repeat('A', 5001), + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('remarks'); + } + + // --- Invalid Enum Values --- + + public function test_invalid_event_status_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Enum Test', + 'slug' => 'enum-test', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + 'status' => 'hacked_status', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('status'); + } + + public function test_invalid_person_status_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'enum@example.com', + 'status' => 'superadmin', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('status'); + } + + public function test_invalid_event_type_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Type Test', + 'slug' => 'type-test', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + 'event_type' => 'malicious', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('event_type'); + } + + // --- Cross-Org Foreign Key Validation --- + + public function test_person_with_cross_org_crowd_type_rejected(): void + { + $otherOrg = Organisation::factory()->create(); + $otherCrowdType = CrowdType::factory()->create(['organisation_id' => $otherOrg->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $otherCrowdType->id, + 'first_name' => 'FK', + 'last_name' => 'Test', + 'email' => 'fk@example.com', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('crowd_type_id'); + } + + public function test_person_with_cross_org_company_rejected(): void + { + $otherOrg = Organisation::factory()->create(); + $otherCompany = Company::factory()->create(['organisation_id' => $otherOrg->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => 'Company', + 'last_name' => 'Test', + 'email' => 'company-fk@example.com', + 'company_id' => $otherCompany->id, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('company_id'); + } + + public function test_shift_with_cross_event_location_rejected(): void + { + $otherEvent = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $otherLocation = Location::factory()->create(['event_id' => $otherEvent->id]); + $section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$section->id}/shifts", + [ + 'time_slot_id' => $timeSlot->id, + 'location_id' => $otherLocation->id, + 'slots_total' => 5, + 'slots_open_for_claiming' => 3, + ] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('location_id'); + } + + public function test_event_with_cross_org_parent_rejected(): void + { + $otherOrg = Organisation::factory()->create(); + $otherEvent = Event::factory()->create(['organisation_id' => $otherOrg->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Sub Event', + 'slug' => 'sub-event-test', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + 'parent_event_id' => $otherEvent->id, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('parent_event_id'); + } + + public function test_assign_shift_with_cross_event_person_rejected(): void + { + $otherEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + $otherPerson = Person::factory()->approved()->create([ + 'event_id' => $otherEvent->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + ]); + + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$section->id}/shifts/{$shift->id}/assign", + ['person_id' => $otherPerson->id] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('person_id'); + } + + // --- ULID Format Validation --- + + public function test_invalid_ulid_format_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => 'not-a-valid-ulid', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'ulid@example.com', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('crowd_type_id'); + } + + public function test_nonexistent_ulid_rejected(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => '01JZZZZZZZZZZZZZZZZZZZZZZ', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'nonexistent@example.com', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('crowd_type_id'); + } + + // --- SQL Injection Resistance --- + + public function test_sql_injection_in_string_fields_harmless(): void + { + Sanctum::actingAs($this->admin); + + $sqlPayload = "'; DROP TABLE users; --"; + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'first_name' => $sqlPayload, + 'last_name' => 'Normal', + 'email' => 'sql@example.com', + ]); + + $response->assertCreated(); + + // Table still exists + $this->assertDatabaseCount('users', 1); // the admin user + $this->assertDatabaseHas('persons', ['first_name' => $sqlPayload]); + } +} diff --git a/api/tests/Feature/Security/MultiTenancyIsolationTest.php b/api/tests/Feature/Security/MultiTenancyIsolationTest.php index 9d8465eb..91ad776a 100644 --- a/api/tests/Feature/Security/MultiTenancyIsolationTest.php +++ b/api/tests/Feature/Security/MultiTenancyIsolationTest.php @@ -274,6 +274,94 @@ final class MultiTenancyIsolationTest extends TestCase $this->assertCount(1, $personIds); } + // --- Cross-tenant location access --- + + public function test_cannot_access_other_org_locations(): void + { + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/locations"); + + // Event-scoped routes: policy returns 403 (user not in org) — access is blocked + $response->assertForbidden(); + } + + // --- Cross-tenant section access --- + + public function test_cannot_access_other_org_sections(): void + { + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/sections"); + + $response->assertForbidden(); + } + + // --- Cross-tenant shift access --- + + public function test_cannot_access_other_org_shifts(): void + { + $sectionA = FestivalSection::factory()->create(['event_id' => $this->eventA->id]); + + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/sections/{$sectionA->id}/shifts"); + + $response->assertForbidden(); + } + + // --- Cross-tenant time-slot access --- + + public function test_cannot_access_other_org_time_slots(): void + { + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/time-slots"); + + $response->assertForbidden(); + } + + // --- Cross-tenant registration field access --- + + public function test_cannot_access_other_org_registration_fields(): void + { + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/registration-fields"); + + $response->assertForbidden(); + } + + // --- Cross-tenant shift assignment listing --- + + public function test_cannot_list_other_org_shift_assignments(): void + { + Sanctum::actingAs($this->adminB); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/shift-assignments"); + + $response->assertForbidden(); + } + + // --- Cross-tenant person update --- + + public function test_cannot_update_person_in_other_org(): void + { + Sanctum::actingAs($this->adminB); + + $personA = Person::factory()->approved()->create([ + 'event_id' => $this->eventA->id, + 'crowd_type_id' => $this->crowdTypeA->id, + ]); + + $response = $this->putJson( + "/api/v1/events/{$this->eventA->id}/persons/{$personA->id}", + ['first_name' => 'Hacked'] + ); + + $response->assertForbidden(); + } + // --- Portal cross-event access --- public function test_portal_me_cannot_access_other_org_event(): void diff --git a/api/tests/Feature/Security/PortalTokenSecurityTest.php b/api/tests/Feature/Security/PortalTokenSecurityTest.php new file mode 100644 index 00000000..0aa4dac4 --- /dev/null +++ b/api/tests/Feature/Security/PortalTokenSecurityTest.php @@ -0,0 +1,241 @@ +organisation = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + } + + // --- Token Validation --- + + public function test_invalid_token_returns_401(): void + { + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => 'completely-invalid-token-value', + ]); + + $response->assertStatus(401); + $response->assertJson(['message' => 'Invalid or expired portal token']); + } + + public function test_empty_token_returns_422(): void + { + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => '', + ]); + + $response->assertUnprocessable(); + } + + public function test_missing_token_returns_422(): void + { + $response = $this->postJson('/api/v1/portal/token-auth', []); + + $response->assertUnprocessable(); + } + + // --- Response Shape --- + + public function test_valid_artist_token_returns_safe_response(): void + { + $plainToken = bin2hex(random_bytes(32)); + $hashedToken = hash('sha256', $plainToken); + + DB::table('artists')->insert([ + 'id' => strtolower((string) Str::ulid()), + 'event_id' => $this->event->id, + 'name' => 'Test Artist', + 'booking_status' => 'confirmed', + 'star_rating' => 3, + 'project_leader_id' => null, + 'milestone_offer_in' => true, + 'milestone_offer_agreed' => true, + 'milestone_confirmed' => true, + 'milestone_announced' => false, + 'milestone_schedule_confirmed' => false, + 'milestone_itinerary_sent' => false, + 'milestone_advance_sent' => false, + 'milestone_advance_received' => false, + 'portal_token' => $hashedToken, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => $plainToken, + ]); + + $response->assertOk(); + $response->assertJsonStructure([ + 'context', + 'data' => ['id', 'name', 'booking_status'], + 'event' => ['id', 'name', 'slug', 'start_date', 'end_date', 'status', 'event_type'], + ]); + + // Must NOT contain internal fields + $data = $response->json('data'); + $this->assertArrayNotHasKey('star_rating', $data); + $this->assertArrayNotHasKey('project_leader_id', $data); + $this->assertArrayNotHasKey('milestone_offer_in', $data); + $this->assertArrayNotHasKey('milestone_confirmed', $data); + $this->assertArrayNotHasKey('portal_token', $data); + $this->assertArrayNotHasKey('advance_open_from', $data); + + // Event must NOT contain internal fields + $eventData = $response->json('event'); + $this->assertArrayNotHasKey('organisation_id', $eventData); + $this->assertArrayNotHasKey('recurrence_rule', $eventData); + $this->assertArrayNotHasKey('recurrence_exceptions', $eventData); + $this->assertArrayNotHasKey('registration_banner_url', $eventData); + } + + public function test_token_lookup_uses_hash_not_plaintext(): void + { + $plainToken = bin2hex(random_bytes(32)); + $hashedToken = hash('sha256', $plainToken); + + DB::table('artists')->insert([ + 'id' => strtolower((string) Str::ulid()), + 'event_id' => $this->event->id, + 'name' => 'Hash Test Artist', + 'booking_status' => 'concept', + 'star_rating' => 1, + 'portal_token' => $hashedToken, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Sending the hash directly should NOT work (must send plain token) + $this->postJson('/api/v1/portal/token-auth', ['token' => $hashedToken]) + ->assertStatus(401); + + // Sending the plain token should work (controller hashes it) + $this->postJson('/api/v1/portal/token-auth', ['token' => $plainToken]) + ->assertOk(); + } + + // --- Generic Error Messages --- + + public function test_error_message_does_not_leak_info(): void + { + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => 'this-token-does-not-exist', + ]); + + $response->assertStatus(401); + // Message should be generic — not "token not found in artists table" + $message = $response->json('message'); + $this->assertStringNotContainsString('artists', $message); + $this->assertStringNotContainsString('table', $message); + $this->assertStringNotContainsString('database', $message); + } + + // --- Portal Middleware --- + + public function test_portal_token_middleware_returns_401_without_token(): void + { + // If any route uses portal.token middleware, it should reject without token. + // We test the middleware directly via a simulated request. + $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $request = \Illuminate\Http\Request::create('/portal/test', 'GET'); + + $response = $middleware->handle($request, fn () => response()->json(['ok' => true])); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_portal_token_middleware_rejects_invalid_token(): void + { + $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $request = \Illuminate\Http\Request::create('/portal/test', 'GET', ['token' => 'fake-token']); + + $response = $middleware->handle($request, fn () => response()->json(['ok' => true])); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_portal_token_middleware_accepts_valid_token(): void + { + $plainToken = bin2hex(random_bytes(32)); + $hashedToken = hash('sha256', $plainToken); + + DB::table('artists')->insert([ + 'id' => strtolower((string) Str::ulid()), + 'event_id' => $this->event->id, + 'name' => 'Middleware Test', + 'booking_status' => 'confirmed', + 'star_rating' => 1, + 'portal_token' => $hashedToken, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $request = \Illuminate\Http\Request::create('/portal/test', 'GET', [], [], [], [ + 'HTTP_AUTHORIZATION' => "Bearer {$plainToken}", + ]); + + $nextCalled = false; + $response = $middleware->handle($request, function ($req) use (&$nextCalled) { + $nextCalled = true; + $this->assertEquals('artist', $req->attributes->get('portal_context')); + $this->assertNotNull($req->attributes->get('portal_event')); + + return response()->json(['ok' => true]); + }); + + $this->assertTrue($nextCalled); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_portal_token_middleware_rejects_draft_event(): void + { + $draftEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + $plainToken = bin2hex(random_bytes(32)); + + DB::table('artists')->insert([ + 'id' => strtolower((string) Str::ulid()), + 'event_id' => $draftEvent->id, + 'name' => 'Draft Event Artist', + 'booking_status' => 'concept', + 'star_rating' => 1, + 'portal_token' => hash('sha256', $plainToken), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $request = \Illuminate\Http\Request::create('/portal/test', 'GET', ['token' => $plainToken]); + + $response = $middleware->handle($request, fn () => response()->json(['ok' => true])); + + $this->assertEquals(401, $response->getStatusCode()); + } +}