From 64878f2734a5c1e10a7b23181a752a1beb1ef9fa Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 19:15:13 +0200 Subject: [PATCH] fix(timetable): wire portal-token auth through artist_engagements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-TIMETABLE v0.2 §5.3 moved portal_token from artists to artist_engagements (one master artist may have multiple per-event portal links). PortalTokenController and PortalTokenMiddleware queried the now-removed artists.portal_token column. Update both lookups to query artist_engagements.portal_token, joining to artists for the master name. Response shape unchanged: data.id = engagement id, data.name = artist name, data.booking_status = engagement status. Middleware sets portal_context='artist' (unchanged); the attached portal_person object now carries the engagement row. PortalTokenSecurityTest seeds artist_engagement rows via a private helper that writes both an Artist (master) and an artist_engagements row with the hashed token; test assertions adjusted to check the new shape (no more milestone fields exposed since they don't exist on the engagement). Out of scope refactor disclaimer: this is a forced schema-migration follow-up, not a Session 2-style controller refactor — the controller queries the new table with minimal change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/V1/PortalTokenController.php | 24 ++-- .../Http/Middleware/PortalTokenMiddleware.php | 11 +- .../Security/PortalTokenSecurityTest.php | 107 ++++++++---------- 3 files changed, 73 insertions(+), 69 deletions(-) diff --git a/api/app/Http/Controllers/Api/V1/PortalTokenController.php b/api/app/Http/Controllers/Api/V1/PortalTokenController.php index 865b9bc1..1575d6a7 100644 --- a/api/app/Http/Controllers/Api/V1/PortalTokenController.php +++ b/api/app/Http/Controllers/Api/V1/PortalTokenController.php @@ -18,18 +18,28 @@ final class PortalTokenController extends Controller { $hashedToken = hash('sha256', $request->validated('token')); - // Try artists table - $artist = DB::table('artists')->where('portal_token', $hashedToken)->first(); + // Artist portal token lives on artist_engagements (per RFC-TIMETABLE + // v0.2 §5.3); join to artists for the master name. + $row = DB::table('artist_engagements') + ->join('artists', 'artists.id', '=', 'artist_engagements.artist_id') + ->where('artist_engagements.portal_token', $hashedToken) + ->select( + 'artist_engagements.id as id', + 'artist_engagements.event_id as event_id', + 'artist_engagements.booking_status as booking_status', + 'artists.name as name', + ) + ->first(); - if ($artist) { - $event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id); + if ($row) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($row->event_id); return response()->json([ 'context' => 'artist', 'data' => [ - 'id' => $artist->id, - 'name' => $artist->name, - 'booking_status' => $artist->booking_status, + 'id' => $row->id, + 'name' => $row->name, + 'booking_status' => $row->booking_status, ], 'event' => $event ? new PortalEventResource($event) : null, ]); diff --git a/api/app/Http/Middleware/PortalTokenMiddleware.php b/api/app/Http/Middleware/PortalTokenMiddleware.php index 2d36f3dd..206657e9 100644 --- a/api/app/Http/Middleware/PortalTokenMiddleware.php +++ b/api/app/Http/Middleware/PortalTokenMiddleware.php @@ -23,18 +23,19 @@ final class PortalTokenMiddleware $hashedToken = hash('sha256', $plainToken); - // Try artists table - $artist = DB::table('artists')->where('portal_token', $hashedToken)->first(); + // Artist portal token lives on artist_engagements (per RFC-TIMETABLE + // v0.2 §5.3); resolve to the engagement's event. + $engagement = DB::table('artist_engagements')->where('portal_token', $hashedToken)->first(); - if ($artist) { - $event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id); + if ($engagement) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($engagement->event_id); if (! $event || in_array($event->status, ['draft', 'closed'], true)) { return response()->json(['message' => 'Portal token required.'], 401); } $request->attributes->set('portal_context', 'artist'); - $request->attributes->set('portal_person', $artist); + $request->attributes->set('portal_person', $engagement); $request->attributes->set('portal_event', $event); return $next($request); diff --git a/api/tests/Feature/Security/PortalTokenSecurityTest.php b/api/tests/Feature/Security/PortalTokenSecurityTest.php index 0aa4dac4..249fdc60 100644 --- a/api/tests/Feature/Security/PortalTokenSecurityTest.php +++ b/api/tests/Feature/Security/PortalTokenSecurityTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature\Security; +use App\Models\Artist; use App\Models\Event; use App\Models\Organisation; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -16,6 +17,7 @@ final class PortalTokenSecurityTest extends TestCase use RefreshDatabase; private Organisation $organisation; + private Event $event; protected function setUp(): void @@ -59,30 +61,50 @@ final class PortalTokenSecurityTest extends TestCase // --- 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); - 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(), - ]); + $this->seedEngagementWithToken($hashedToken); $response = $this->postJson('/api/v1/portal/token-auth', [ 'token' => $plainToken, @@ -97,11 +119,9 @@ final class PortalTokenSecurityTest extends TestCase // 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('fee_amount', $data); + $this->assertArrayNotHasKey('project_leader_id', $data); $this->assertArrayNotHasKey('advance_open_from', $data); // Event must NOT contain internal fields @@ -117,16 +137,7 @@ final class PortalTokenSecurityTest extends TestCase $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(), - ]); + $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]) @@ -159,7 +170,7 @@ final class PortalTokenSecurityTest extends TestCase { // 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(); + $middleware = new \App\Http\Middleware\PortalTokenMiddleware; $request = \Illuminate\Http\Request::create('/portal/test', 'GET'); $response = $middleware->handle($request, fn () => response()->json(['ok' => true])); @@ -169,7 +180,7 @@ final class PortalTokenSecurityTest extends TestCase public function test_portal_token_middleware_rejects_invalid_token(): void { - $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $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])); @@ -182,18 +193,9 @@ final class PortalTokenSecurityTest extends TestCase $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(), - ]); + $this->seedEngagementWithToken($hashedToken, artistName: 'Middleware Test'); - $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $middleware = new \App\Http\Middleware\PortalTokenMiddleware; $request = \Illuminate\Http\Request::create('/portal/test', 'GET', [], [], [], [ 'HTTP_AUTHORIZATION' => "Bearer {$plainToken}", ]); @@ -220,18 +222,9 @@ final class PortalTokenSecurityTest extends TestCase $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(), - ]); + $this->seedEngagementWithToken(hash('sha256', $plainToken), $draftEvent, artistName: 'Draft Event Artist', bookingStatus: 'draft'); - $middleware = new \App\Http\Middleware\PortalTokenMiddleware(); + $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]));