61 tests across 4 test files covering all OWASP categories: MultiTenancyIsolationTest (19 tests): - Cross-org event, person, shift, section, time-slot, location, registration field, shift assignment, crowd list access - Cross-org FK references (crowd_type, company, parent_event) - Bulk operation isolation, invitation revocation - Portal cross-event access prevention AuthenticationSecurityTest (15 tests): - Rate limiting: login (5/min), portal token-auth (10/min), invitation show (10/min) - Account enumeration prevention: generic error on failed login, 200 response on password reset for unknown email - Token lifecycle: logout revokes token, password reset revokes all tokens, expired tokens rejected (7-day config verified) - Password strength: weak/no-uppercase/no-numbers rejected - Security headers present on all responses - Protected routes require authentication PortalTokenSecurityTest (10 tests): - Invalid/empty/missing token handling - Response shape: only safe fields (no milestones, no portal_token, no organisation_id, no internal event fields) - Hash-based lookup: plain token works, hash does not - Error messages: no schema/table info leakage - Middleware: rejects without token, rejects invalid, accepts valid, rejects draft event status InputValidationSecurityTest (17 tests): - XSS payloads stored safely in person name, event name, section name - Oversized inputs rejected (name >255, remarks >5000) - Invalid enum values rejected (status, event_type) - Cross-org FK references rejected (crowd_type, company, location, parent_event, person assignment) - Invalid/nonexistent ULID format rejected - SQL injection payloads harmless (PDO binding verified) Also fixes PortalTokenMiddleware to use request->attributes->set() instead of request->merge() for stdClass objects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.4 KiB
PHP
242 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Security;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\Organisation;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
final class PortalTokenSecurityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Organisation $organisation;
|
|
private Event $event;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->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());
|
|
}
|
|
}
|