Merge pull request #2 from HolgerHatGarKeineNode/feature/api-course-event-write-endpoints

feat(api): authenticated course & course-event write endpoints
This commit is contained in:
The Ben
2026-06-07 21:26:07 +00:00
committed by GitHub
11 changed files with 558 additions and 29 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+10
View File
@@ -200,6 +200,14 @@ class SeoDataAttribute
twitter_username: $domainTwitter, twitter_username: $domainTwitter,
site_name: $domainSiteName, site_name: $domainSiteName,
), ),
'settings_api_tokens' => new SEOData(
title: __('API Tokens - Einstellungen'),
description: __('Verwalte deine persönlichen Zugriffstokens für den programmatischen API-Zugriff auf dein Bitcoin Meetup Konto.'),
author: $domainAuthor,
image: $domainImage,
twitter_username: $domainTwitter,
site_name: $domainSiteName,
),
'settings_delete_user_form' => new SEOData( 'settings_delete_user_form' => new SEOData(
title: __('Konto löschen - Bitcoin Meetups'), title: __('Konto löschen - Bitcoin Meetups'),
description: __('Informationen zum Löschen deines Bitcoin Meetup Kontos.'), description: __('Informationen zum Löschen deines Bitcoin Meetup Kontos.'),
@@ -298,6 +306,7 @@ class SeoDataAttribute
if (empty(self::$seoDefinitions)) { if (empty(self::$seoDefinitions)) {
self::initDefinitions(); self::initDefinitions();
} }
return self::$seoDefinitions[$key] ?? self::$seoDefinitions['default']; return self::$seoDefinitions[$key] ?? self::$seoDefinitions['default'];
} }
@@ -307,6 +316,7 @@ class SeoDataAttribute
if ($this->key) { if ($this->key) {
return self::getData($this->key); return self::getData($this->key);
} }
return self::getData('default'); // Fallback return self::getData('default'); // Fallback
} }
} }
+36 -5
View File
@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Course; use App\Models\Course;
use App\Models\Lecturer; use App\Models\Lecturer;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CourseController extends Controller class CourseController extends Controller
{ {
@@ -16,7 +18,7 @@ class CourseController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
return Course::query() return Course::query()
->select('id', 'name', ) ->select('id', 'name')
->orderBy('name') ->orderBy('name')
->when($request->has('user_id'), ->when($request->has('user_id'),
fn (Builder $query) => $query->where('created_by', $request->user_id)) fn (Builder $query) => $query->where('created_by', $request->user_id))
@@ -42,10 +44,24 @@ class CourseController extends Controller
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
*
* Allows an authenticated lecturer to create a course programmatically
* (e.g. to sync courses from an external system). Validation mirrors the
* Livewire course create form; `created_by` is set by the model's creating hook.
*/ */
public function store(Request $request) public function store(Request $request): JsonResponse
{ {
// abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'lecturer_id' => ['required', 'exists:lecturers,id'],
'description' => ['nullable', 'string'],
]);
$course = Course::create($validated);
return response()->json($course->fresh(), Response::HTTP_CREATED);
} }
/** /**
@@ -58,10 +74,25 @@ class CourseController extends Controller
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*
* Authorized for the course owner (or a super-admin).
*/ */
public function update(Request $request, Course $course) public function update(Request $request, Course $course): JsonResponse
{ {
// abort_unless(
(int) $course->created_by === $request->user()->id || $request->user()->hasRole('super-admin'),
Response::HTTP_FORBIDDEN
);
$validated = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:255'],
'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'],
'description' => ['sometimes', 'nullable', 'string'],
]);
$course->update($validated);
return response()->json($course->fresh());
} }
/** /**
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CourseEvent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CourseEventController extends Controller
{
/**
* Display a listing of the course events created by the authenticated user.
*
* Useful for an external sync client to detect which events already exist
* (idempotent syncing). Optionally filtered by course_id.
*
* @return Collection<int, CourseEvent>
*/
public function index(Request $request): Collection
{
return CourseEvent::query()
->with(['course:id,name', 'venue:id,name'])
->where('created_by', $request->user()->id)
->when(
$request->filled('course_id'),
fn (Builder $query) => $query->where('course_id', $request->integer('course_id'))
)
->orderByDesc('from')
->get();
}
/**
* Store a newly created course event in storage.
*
* Allows an authenticated lecturer to create a dated course event
* programmatically. Validation mirrors the Livewire course event form;
* `created_by` is set by the model's creating hook.
*/
public function store(Request $request): JsonResponse
{
abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'exists:courses,id'],
'venue_id' => ['required', 'integer', 'exists:venues,id'],
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
'link' => ['required', 'url', 'max:255'],
]);
$courseEvent = CourseEvent::create($validated);
return response()->json($courseEvent->fresh(), Response::HTTP_CREATED);
}
/**
* Update the specified course event in storage.
*
* Authorized for the course event owner (or a super-admin).
*/
public function update(Request $request, CourseEvent $courseEvent): JsonResponse
{
abort_unless(
(int) $courseEvent->created_by === $request->user()->id || $request->user()->hasRole('super-admin'),
Response::HTTP_FORBIDDEN
);
$validated = $request->validate([
'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'],
'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'],
'from' => ['sometimes', 'required', 'date'],
'to' => ['sometimes', 'required', 'date', 'after_or_equal:from'],
'link' => ['sometimes', 'required', 'url', 'max:255'],
]);
$courseEvent->update($validated);
return response()->json($courseEvent->fresh());
}
}
@@ -4,6 +4,7 @@
<flux:navlist.item :href="route('settings.profile', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Profile') }}</flux:navlist.item> <flux:navlist.item :href="route('settings.profile', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
{{--<flux:navlist.item :href="route('settings.password')" wire:navigate>{{ __('Password') }}</flux:navlist.item>--}} {{--<flux:navlist.item :href="route('settings.password')" wire:navigate>{{ __('Password') }}</flux:navlist.item>--}}
<flux:navlist.item :href="route('settings.appearance', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Appearance') }}</flux:navlist.item> <flux:navlist.item :href="route('settings.appearance', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
<flux:navlist.item :href="route('settings.api-tokens', ['country' => str(session('lang_country', 'de'))->after('-')->lower()])" wire:navigate>{{ __('API Tokens') }}</flux:navlist.item>
</flux:navlist> </flux:navlist>
</div> </div>
@@ -0,0 +1,163 @@
<?php
use App\Attributes\SeoDataAttribute;
use App\Traits\SeoTrait;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate;
use Livewire\Component;
new
#[SeoDataAttribute(key: 'settings_api_tokens')]
class extends Component {
use SeoTrait;
#[Validate('required|string|max:255')]
public string $name = '';
/**
* The plain-text token, shown to the user exactly once after creation.
*/
public ?string $plainTextToken = null;
/**
* Create a new personal access token for the authenticated user.
*/
public function createToken(): void
{
$this->validate();
$this->plainTextToken = Auth::user()
->createToken($this->name)
->plainTextToken;
$this->reset('name');
$this->dispatch('token-created');
}
/**
* Revoke (delete) one of the authenticated user's tokens.
*/
public function deleteToken(int $tokenId): void
{
Auth::user()->tokens()->whereKey($tokenId)->delete();
$this->dispatch('token-deleted');
}
/**
* Dismiss the one-time plain-text token display.
*/
public function dismissPlainTextToken(): void
{
$this->plainTextToken = null;
}
public function with(): array
{
return [
'tokens' => Auth::user()->tokens()->latest()->get(),
];
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<x-settings.layout :heading="__('API Tokens')"
:subheading="__('Erstelle persönliche Zugriffstokens, um über die API auf dein Konto zuzugreifen.')">
<div class="space-y-8">
<flux:text>
{{ __('Mit einem persönlichen Zugriffstoken kannst du deine Kurse und Kurs-Events programmatisch über die API verwalten (z. B. zum Synchronisieren aus einem externen System). Sende das Token als Bearer-Token im :header-Header.', ['header' => 'Authorization']) }}
</flux:text>
{{-- One-time token reveal --}}
@if ($plainTextToken)
<flux:callout variant="success" icon="key" x-data="{ copied: false }">
<flux:callout.heading>{{ __('Dein neues API Token') }}</flux:callout.heading>
<flux:callout.text>
<p class="mb-3">
{{ __('Kopiere dein Token jetzt. Aus Sicherheitsgründen wird es dir nur dieses eine Mal angezeigt.') }}
</p>
<div class="flex items-center gap-2">
<flux:input x-ref="token" readonly value="{{ $plainTextToken }}" class="font-mono" />
<flux:button type="button" icon="clipboard-document"
x-on:click="navigator.clipboard.writeText($refs.token.value); copied = true; setTimeout(() => copied = false, 2000)">
<span x-show="!copied">{{ __('Kopieren') }}</span>
<span x-show="copied" x-cloak>{{ __('Kopiert!') }}</span>
</flux:button>
</div>
</flux:callout.text>
<x-slot name="actions">
<flux:button variant="ghost" size="sm" wire:click="dismissPlainTextToken">
{{ __('Verstanden') }}
</flux:button>
</x-slot>
</flux:callout>
@endif
{{-- Create token form --}}
<form wire:submit="createToken" class="space-y-4">
<flux:input wire:model="name"
:label="__('Token-Name')"
:placeholder="__('z. B. Externer Kurs-Sync')"
:description="__('Ein aussagekräftiger Name hilft dir, das Token später wiederzuerkennen.')" />
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit" icon="plus">
{{ __('Token erstellen') }}
</flux:button>
<x-action-message on="token-created">
{{ __('Token erstellt.') }}
</x-action-message>
</div>
</form>
<flux:separator />
{{-- Existing tokens --}}
<div>
<flux:heading size="lg" class="mb-4">{{ __('Aktive Tokens') }}</flux:heading>
@if ($tokens->isEmpty())
<flux:text class="text-zinc-500 dark:text-zinc-400">
{{ __('Du hast noch keine API Tokens erstellt.') }}
</flux:text>
@else
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Zuletzt verwendet') }}</flux:table.column>
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
<flux:table.column />
</flux:table.columns>
<flux:table.rows>
@foreach ($tokens as $token)
<flux:table.row wire:key="token-{{ $token->id }}">
<flux:table.cell variant="strong">{{ $token->name }}</flux:table.cell>
<flux:table.cell>
@if ($token->last_used_at)
{{ $token->last_used_at->diffForHumans() }}
@else
<flux:badge size="sm" color="zinc">{{ __('Nie') }}</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>{{ $token->created_at->format('d.m.Y') }}</flux:table.cell>
<flux:table.cell align="end">
<flux:tooltip :content="__('Widerrufen')">
<flux:button variant="danger" size="sm" icon="trash"
:aria-label="__('Token widerrufen')"
wire:click="deleteToken({{ $token->id }})"
wire:confirm="{{ __('Token „:name“ wirklich widerrufen? Anwendungen, die es nutzen, verlieren den Zugriff.', ['name' => $token->name]) }}" />
</flux:tooltip>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@endif
</div>
</div>
</x-settings.layout>
</section>
+36 -8
View File
@@ -3,11 +3,17 @@
use App\Http\Controllers\Api\CityController; use App\Http\Controllers\Api\CityController;
use App\Http\Controllers\Api\CountryController; use App\Http\Controllers\Api\CountryController;
use App\Http\Controllers\Api\CourseController; use App\Http\Controllers\Api\CourseController;
use App\Http\Controllers\Api\CourseEventController;
use App\Http\Controllers\Api\HighscoreController; use App\Http\Controllers\Api\HighscoreController;
use App\Http\Controllers\Api\LecturerController; use App\Http\Controllers\Api\LecturerController;
use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\MeetupController;
use App\Http\Controllers\Api\VenueController; use App\Http\Controllers\Api\VenueController;
use App\Http\Controllers\LnurlAuthController;
use App\Models\LibraryItem;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\User; use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -18,7 +24,8 @@ Route::middleware(['throttle:60,1'])
Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical'); Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical');
Route::resource('meetup', MeetupController::class); Route::resource('meetup', MeetupController::class);
Route::resource('lecturers', LecturerController::class); Route::resource('lecturers', LecturerController::class);
Route::resource('courses', CourseController::class); Route::resource('courses', CourseController::class)
->only(['index', 'show']);
Route::resource('cities', CityController::class); Route::resource('cities', CityController::class);
Route::resource('venues', VenueController::class); Route::resource('venues', VenueController::class);
Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index'); Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index');
@@ -46,7 +53,7 @@ Route::middleware(['throttle:60,1'])
->pluck('nostr'); ->pluck('nostr');
}); });
Route::get('bindles', function () { Route::get('bindles', function () {
return \App\Models\LibraryItem::query() return LibraryItem::query()
->where('type', 'bindle') ->where('type', 'bindle')
->with([ ->with([
'media', 'media',
@@ -61,7 +68,7 @@ Route::middleware(['throttle:60,1'])
]); ]);
}); });
Route::get('meetups', function (Request $request) { Route::get('meetups', function (Request $request) {
return \App\Models\Meetup::query() return Meetup::query()
->where('visible_on_map', true) ->where('visible_on_map', true)
->with([ ->with([
'meetupEvents', 'meetupEvents',
@@ -95,9 +102,9 @@ Route::middleware(['throttle:60,1'])
}); });
Route::get('meetup-events/{date?}', function ($date = null) { Route::get('meetup-events/{date?}', function ($date = null) {
if ($date) { if ($date) {
$date = \Carbon\Carbon::parse($date); $date = Carbon::parse($date);
} }
$events = \App\Models\MeetupEvent::query() $events = MeetupEvent::query()
->with([ ->with([
'meetup.city.country', 'meetup.city.country',
'meetup.media', 'meetup.media',
@@ -139,7 +146,7 @@ Route::middleware(['throttle:60,1'])
}); });
Route::get('btc-map-communities', function () { Route::get('btc-map-communities', function () {
return response()->json( return response()->json(
\App\Models\Meetup::query() Meetup::query()
->with([ ->with([
'media', 'media',
'city.country', 'city.country',
@@ -184,8 +191,29 @@ Route::middleware(['throttle:60,1'])
}); });
}); });
Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::class, 'callback']) /*
* Authenticated write endpoints (Sanctum token auth).
* Lets a lecturer create/update their own courses and course events
* programmatically, e.g. to sync events from an external system.
*/
Route::middleware('auth:sanctum')
->as('api.')
->group(function () {
Route::post('courses', [CourseController::class, 'store'])
->name('courses.store');
Route::patch('courses/{course}', [CourseController::class, 'update'])
->name('courses.update');
Route::get('course-events', [CourseEventController::class, 'index'])
->name('course-events.index');
Route::post('course-events', [CourseEventController::class, 'store'])
->name('course-events.store');
Route::patch('course-events/{courseEvent}', [CourseEventController::class, 'update'])
->name('course-events.update');
});
Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback'])
->name('auth.ln.callback'); ->name('auth.ln.callback');
Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError']) Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError'])
->name('auth.check-error'); ->name('auth.check-error');
+1
View File
@@ -165,6 +165,7 @@ Route::middleware(['auth'])
Route::livewire('/settings/profile', 'settings.profile')->name('settings.profile'); Route::livewire('/settings/profile', 'settings.profile')->name('settings.profile');
Route::livewire('/settings/password', 'settings.password')->name('settings.password'); Route::livewire('/settings/password', 'settings.password')->name('settings.password');
Route::livewire('/settings/appearance', 'settings.appearance')->name('settings.appearance'); Route::livewire('/settings/appearance', 'settings.appearance')->name('settings.appearance');
Route::livewire('/settings/api-tokens', 'settings.api-tokens')->name('settings.api-tokens');
}); });
// Commented out feed routes (RSS/Atom feeds) // Commented out feed routes (RSS/Atom feeds)
@@ -0,0 +1,19 @@
<?php
it('shows the api token management UI and the one-time token reveal', function () {
$user = actingAsUser(['name' => 'Lecturer Demo', 'is_lecturer' => true]);
// Pre-existing token so the "Aktive Tokens" table is populated.
$user->createToken('Mein Laptop');
$page = visit('/de/settings/api-tokens');
$page->assertSee('API Tokens')
->fill('name', 'Externer Kurs-Sync')
->click('Token erstellen')
->wait(1)
->assertSee('Dein neues API Token')
->assertSee('Aktive Tokens')
->assertNoJavaScriptErrors()
->screenshot(filename: 'settings-api-tokens');
});
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\Lecturer;
use App\Models\User;
use App\Models\Venue;
use Laravel\Sanctum\Sanctum;
it('rejects a guest creating a course with 401', function () {
$lecturer = Lecturer::factory()->create();
$this->postJson('/api/courses', [
'name' => 'Specter Shield Lite Workshop',
'lecturer_id' => $lecturer->id,
])->assertUnauthorized();
});
it('forbids a non-lecturer from creating a course', function () {
Sanctum::actingAs(User::factory()->create(['is_lecturer' => false]));
$lecturer = Lecturer::factory()->create();
$this->postJson('/api/courses', [
'name' => 'Specter Shield Lite Workshop',
'lecturer_id' => $lecturer->id,
])->assertForbidden();
});
it('lets a lecturer create a course', function () {
Sanctum::actingAs($user = User::factory()->lecturer()->create());
$lecturer = Lecturer::factory()->create();
$this->postJson('/api/courses', [
'name' => 'Specter Shield Lite Workshop',
'lecturer_id' => $lecturer->id,
'description' => 'Hardware-Wallet selbst bauen.',
])
->assertCreated()
->assertJsonPath('name', 'Specter Shield Lite Workshop');
$this->assertDatabaseHas('courses', [
'name' => 'Specter Shield Lite Workshop',
'created_by' => $user->id,
]);
});
it('lets a lecturer create a course event', function () {
Sanctum::actingAs($user = User::factory()->lecturer()->create());
$course = Course::factory()->create();
$venue = Venue::factory()->create();
$this->postJson('/api/course-events', [
'course_id' => $course->id,
'venue_id' => $venue->id,
'from' => '2026-07-01 18:00:00',
'to' => '2026-07-01 21:00:00',
'link' => 'https://clavastack.com/produkt/specter-shield-lite-workshop',
])
->assertCreated()
->assertJsonPath('course_id', $course->id);
$this->assertDatabaseHas('course_events', [
'course_id' => $course->id,
'venue_id' => $venue->id,
'created_by' => $user->id,
]);
});
it('fails course event validation without required fields', function () {
Sanctum::actingAs(User::factory()->lecturer()->create());
$this->postJson('/api/course-events', [])
->assertUnprocessable()
->assertJsonValidationErrors(['course_id', 'venue_id', 'from', 'to', 'link']);
});
it('returns only the authenticated user\'s own course events', function () {
Sanctum::actingAs($user = User::factory()->lecturer()->create());
$other = User::factory()->lecturer()->create();
CourseEvent::factory()->count(2)->create(['created_by' => $user->id]);
CourseEvent::factory()->create(['created_by' => $other->id]);
$response = $this->getJson('/api/course-events');
$response->assertSuccessful();
expect($response->json())->toHaveCount(2);
collect($response->json())->each(
fn ($event) => expect($event['created_by'])->toBe($user->id)
);
});
it('filters own course events by course_id', function () {
Sanctum::actingAs($user = User::factory()->lecturer()->create());
$event = CourseEvent::factory()->create(['created_by' => $user->id]);
CourseEvent::factory()->create(['created_by' => $user->id]);
$response = $this->getJson('/api/course-events?course_id='.$event->course_id);
$response->assertSuccessful();
expect($response->json())->toHaveCount(1)
->and($response->json('0.id'))->toBe($event->id);
});
it('lets the owner update their course event', function () {
Sanctum::actingAs($user = User::factory()->lecturer()->create());
$event = CourseEvent::factory()->create(['created_by' => $user->id]);
$this->patchJson('/api/course-events/'.$event->id, [
'link' => 'https://einundzwanzig.space/courses/updated',
])
->assertSuccessful()
->assertJsonPath('link', 'https://einundzwanzig.space/courses/updated');
});
it('forbids updating a course event owned by someone else', function () {
$owner = User::factory()->lecturer()->create();
$event = CourseEvent::factory()->create(['created_by' => $owner->id]);
Sanctum::actingAs(User::factory()->lecturer()->create());
$this->patchJson('/api/course-events/'.$event->id, [
'link' => 'https://einundzwanzig.space/courses/hijacked',
])->assertForbidden();
});
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\User;
use Livewire\Livewire;
it('mounts the api tokens page when authenticated', function () {
actingAsUser();
Livewire::test('settings.api-tokens')->assertStatus(200);
});
it('creates a personal access token and reveals it once', function () {
$user = actingAsUser();
Livewire::test('settings.api-tokens')
->set('name', 'Externer Kurs-Sync')
->call('createToken')
->assertHasNoErrors()
->assertDispatched('token-created')
->assertSet('name', '')
->assertSet('plainTextToken', fn ($token) => is_string($token) && str_contains($token, '|'));
expect($user->tokens()->where('name', 'Externer Kurs-Sync')->exists())->toBeTrue();
});
it('requires a token name', function () {
actingAsUser();
Livewire::test('settings.api-tokens')
->set('name', '')
->call('createToken')
->assertHasErrors(['name' => 'required']);
});
it('revokes a token', function () {
$user = actingAsUser();
$token = $user->createToken('to-be-revoked')->accessToken;
Livewire::test('settings.api-tokens')
->call('deleteToken', $token->id)
->assertDispatched('token-deleted');
expect($user->tokens()->whereKey($token->id)->exists())->toBeFalse();
});
it('only lists the authenticated user\'s own tokens', function () {
$user = actingAsUser();
$user->createToken('mine');
$other = User::factory()->create();
$other->createToken('theirs');
Livewire::test('settings.api-tokens')
->assertViewHas('tokens', fn ($tokens) => $tokens->count() === 1 && $tokens->first()->name === 'mine');
});
it('cannot revoke a token belonging to another user', function () {
actingAsUser();
$other = User::factory()->create();
$foreignToken = $other->createToken('theirs')->accessToken;
Livewire::test('settings.api-tokens')
->call('deleteToken', $foreignToken->id);
expect($other->tokens()->whereKey($foreignToken->id)->exists())->toBeTrue();
});