A token acts as the person who made it, within the permissions it was given and those they still hold, checked on every request. Permission checks now go through one gate rule, since spatie's answered first and never asked the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
6.0 KiB
PHP
183 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\ApiToken;
|
|
use App\Models\Page;
|
|
use App\Models\Technology;
|
|
use App\Models\User;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Machine tokens: made by a person, acting as them, never as more.
|
|
*/
|
|
class MachineTokenTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->seed(RolePermissionSeeder::class);
|
|
}
|
|
|
|
protected function person(string $role): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->syncRoles([$role]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $abilities
|
|
*/
|
|
protected function tokenFor(User $user, array $abilities, ?\DateTimeInterface $expires = null): string
|
|
{
|
|
[, $plain] = ApiToken::issue($user, 'CI', $abilities, $expires ?? now()->addDay());
|
|
|
|
$this->app['auth']->forgetGuards();
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
|
|
|
return $plain;
|
|
}
|
|
|
|
#[Test]
|
|
public function a_person_makes_a_token_and_sees_it_only_once(): void
|
|
{
|
|
$this->withHeaders($this->apiHeadersFor($this->person('editor')));
|
|
|
|
$response = $this->postJson('/api/v1/tokens', [
|
|
'name' => 'Despliegue',
|
|
'abilities' => ['technologies.*', 'pages.view_any'],
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('data.status', 'active')
|
|
->assertJsonPath('data.abilities', ['technologies.*', 'pages.view_any']);
|
|
|
|
$plain = $response->json('token');
|
|
|
|
$this->assertStringStartsWith(ApiToken::PREFIX, $plain);
|
|
$this->assertSame(ApiToken::hash($plain), ApiToken::query()->sole()->token_hash);
|
|
|
|
$this->getJson('/api/v1/tokens')
|
|
->assertJsonCount(1, 'data')
|
|
->assertJsonMissingPath('data.0.token');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_token_cannot_be_given_what_its_maker_lacks(): void
|
|
{
|
|
$this->withHeaders($this->apiHeadersFor($this->person('writer')));
|
|
|
|
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['users.update']])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('abilities.0');
|
|
|
|
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['nothing.*']])
|
|
->assertUnprocessable();
|
|
|
|
$this->postJson('/api/v1/tokens', [
|
|
'name' => 'X',
|
|
'abilities' => ['pages.view_any'],
|
|
'expires_at' => now()->addYears(2)->toIso8601String(),
|
|
])->assertUnprocessable()->assertJsonValidationErrors('expires_at');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_token_acts_as_its_maker_within_its_abilities(): void
|
|
{
|
|
Http::fake();
|
|
|
|
$editor = $this->person('editor');
|
|
$this->tokenFor($editor, ['technologies.*']);
|
|
|
|
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
|
|
$this->getJson('/api/v1/pages')->assertForbidden();
|
|
|
|
$permissions = $this->getJson('/api/v1/me')->assertOk()->json('data.permissions');
|
|
|
|
$this->assertContains('technologies.create', $permissions);
|
|
$this->assertNotContains('pages.view_any', $permissions);
|
|
|
|
// Never sent to the identity server to be checked.
|
|
Http::assertNothingSent();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_super_admin_token_is_still_limited(): void
|
|
{
|
|
$this->tokenFor($this->person('super_admin'), ['pages.view_any', 'pages.view']);
|
|
|
|
$page = Page::factory()->create();
|
|
|
|
$this->getJson('/api/v1/pages')->assertOk();
|
|
$this->getJson("/api/v1/pages/{$page->id}")->assertOk();
|
|
$this->deleteJson("/api/v1/pages/{$page->id}")->assertForbidden();
|
|
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
|
}
|
|
|
|
#[Test]
|
|
public function losing_a_role_narrows_the_token_at_once(): void
|
|
{
|
|
$editor = $this->person('editor');
|
|
$this->tokenFor($editor, ['technologies.*']);
|
|
|
|
$editor->syncRoles(['viewer']);
|
|
|
|
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
|
$this->assertSame(0, Technology::query()->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function expired_revoked_or_deactivated_tokens_do_not_work(): void
|
|
{
|
|
$editor = $this->person('editor');
|
|
|
|
$this->tokenFor($editor, ['pages.*'], now()->subMinute());
|
|
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
|
|
|
$plain = $this->tokenFor($editor, ['pages.*']);
|
|
ApiToken::query()->where('token_hash', ApiToken::hash($plain))->sole()->revoke();
|
|
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
|
|
|
$this->tokenFor($editor, ['pages.*']);
|
|
$editor->update(['is_active' => false]);
|
|
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_token_cannot_make_or_revoke_tokens(): void
|
|
{
|
|
$editor = $this->person('editor');
|
|
$this->tokenFor($editor, ['pages.*']);
|
|
|
|
$this->postJson('/api/v1/tokens', ['name' => 'Forever', 'abilities' => ['pages.*']])->assertForbidden();
|
|
$this->getJson('/api/v1/tokens')->assertForbidden();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_revoked_token_stays_listed(): void
|
|
{
|
|
$editor = $this->person('editor');
|
|
[$token] = ApiToken::issue($editor, 'Old', ['pages.*'], now()->addDay());
|
|
|
|
$this->withHeaders($this->apiHeadersFor($editor));
|
|
|
|
$this->deleteJson("/api/v1/tokens/{$token->id}")->assertNoContent();
|
|
$this->getJson('/api/v1/tokens')->assertJsonPath('data.0.status', 'revoked');
|
|
|
|
// Someone else's is not theirs to revoke.
|
|
[$other] = ApiToken::issue($this->person('editor'), 'Theirs', ['pages.*'], now()->addDay());
|
|
$this->deleteJson("/api/v1/tokens/{$other->id}")->assertForbidden();
|
|
}
|
|
}
|