RFC-TIMETABLE v0.2 Session 1 — Artist Timetable foundation #15
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
Reference in New Issue
Block a user