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 --- /** * Insert a master artist + per-event engagement with a hashed portal_token. * * RFC-TIMETABLE v0.2 §5.3 moved portal_token from artists to * artist_engagements; the auth lookup now joins both. */ private function seedEngagementWithToken(string $hashedToken, ?Event $event = null, string $artistName = 'Test Artist', string $bookingStatus = 'confirmed'): void { $event ??= $this->event; $artist = Artist::create([ 'organisation_id' => $event->organisation_id, 'name' => $artistName, ]); DB::table('artist_engagements')->insert([ 'id' => strtolower((string) Str::ulid()), 'organisation_id' => $event->organisation_id, 'artist_id' => $artist->id, 'event_id' => $event->id, 'booking_status' => $bookingStatus, 'fee_currency' => 'EUR', 'buma_applicable' => true, 'buma_percentage' => 7.00, 'buma_handled_by' => 'organisation', 'vat_applicable' => true, 'vat_percentage' => 21.00, 'payment_status' => 'none', 'crew_count' => 0, 'guests_count' => 0, 'advancing_completed_count' => 0, 'advancing_total_count' => 0, 'portal_token' => $hashedToken, 'created_at' => now(), 'updated_at' => now(), ]); } public function test_valid_artist_token_returns_safe_response(): void { $plainToken = bin2hex(random_bytes(32)); $hashedToken = hash('sha256', $plainToken); $this->seedEngagementWithToken($hashedToken); $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('portal_token', $data); $this->assertArrayNotHasKey('fee_amount', $data); $this->assertArrayNotHasKey('project_leader_id', $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); $this->seedEngagementWithToken($hashedToken, artistName: 'Hash Test Artist', bookingStatus: 'draft'); // 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); $this->seedEngagementWithToken($hashedToken, artistName: 'Middleware Test'); $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)); $this->seedEngagementWithToken(hash('sha256', $plainToken), $draftEvent, artistName: 'Draft Event Artist', bookingStatus: 'draft'); $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()); } }