fix(timetable): wire portal-token auth through artist_engagements

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 19:15:13 +02:00
parent eb6d396672
commit 64878f2734
3 changed files with 73 additions and 69 deletions

View File

@@ -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]));