Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
269 lines
8.8 KiB
PHP
269 lines
8.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Enums\PageStatus;
|
|
use App\Models\Category;
|
|
use App\Models\Page;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
|
|
class PageApiTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->seed(RolePermissionSeeder::class);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_rejects_a_request_without_a_token(): void
|
|
{
|
|
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
|
}
|
|
|
|
#[Test]
|
|
public function it_rejects_a_token_the_identity_server_does_not_recognise(): void
|
|
{
|
|
$this->fakePireonRejectingTokens();
|
|
|
|
$this->getJson('/api/v1/pages', ['Authorization' => 'Bearer revoked-token'])
|
|
->assertUnauthorized();
|
|
}
|
|
|
|
#[Test]
|
|
public function it_resolves_the_caller_and_their_local_permissions(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
$response = $this->getJson('/api/v1/me', $this->apiHeadersFor($user));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.email', $user->email)
|
|
->assertJsonPath('data.roles.0', 'editor');
|
|
|
|
// Permissions come from this app, not from the identity server.
|
|
$this->assertContains('pages.create', $response->json('data.permissions'));
|
|
$this->assertNotContains('roles.create', $response->json('data.permissions'));
|
|
}
|
|
|
|
#[Test]
|
|
public function a_locally_deactivated_user_is_denied_even_with_a_valid_token(): void
|
|
{
|
|
$user = $this->userWithRoles('editor', ['is_active' => false]);
|
|
|
|
$this->getJson('/api/v1/pages', $this->apiHeadersFor($user))
|
|
->assertUnauthorized();
|
|
}
|
|
|
|
#[Test]
|
|
public function it_returns_content_in_the_requested_locale(): void
|
|
{
|
|
// No stored locale preference, so Accept-Language is free to decide
|
|
// when no explicit ?lang= is given.
|
|
$user = $this->userWithRoles('editor', ['locale' => null]);
|
|
|
|
Page::factory()->published()->create([
|
|
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
|
|
'slug' => ['es' => 'hola', 'en' => 'hello', 'ca' => 'hola-ca'],
|
|
]);
|
|
|
|
$headers = $this->apiHeadersFor($user);
|
|
|
|
$this->getJson('/api/v1/pages?lang=en', $headers)
|
|
->assertOk()
|
|
->assertJsonPath('data.0.title', 'Hello')
|
|
->assertJsonPath('data.0.locale', 'en');
|
|
|
|
$this->getJson('/api/v1/pages?lang=ca', $headers)
|
|
->assertOk()
|
|
->assertJsonPath('data.0.title', 'Hola-ca');
|
|
|
|
// Accept-Language is honoured when no explicit ?lang= is given.
|
|
$this->getJson('/api/v1/pages', $headers + ['Accept-Language' => 'en-GB,en;q=0.9'])
|
|
->assertOk()
|
|
->assertJsonPath('data.0.title', 'Hello')
|
|
->assertHeader('Content-Language', 'en');
|
|
}
|
|
|
|
#[Test]
|
|
public function it_can_return_every_translation_on_request(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
Page::factory()->published()->create([
|
|
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
|
|
]);
|
|
|
|
$this->getJson('/api/v1/pages?with=translations', $this->apiHeadersFor($user))
|
|
->assertOk()
|
|
->assertJsonPath('data.0.translations.title.es', 'Hola')
|
|
->assertJsonPath('data.0.translations.title.en', 'Hello')
|
|
->assertJsonPath('data.0.translations.title.ca', 'Hola-ca');
|
|
}
|
|
|
|
#[Test]
|
|
public function it_falls_back_to_the_default_locale_for_a_missing_translation(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
Page::factory()->published()->create([
|
|
'title' => ['es' => 'Solo español'],
|
|
'slug' => ['es' => 'solo-espanol'],
|
|
]);
|
|
|
|
$this->getJson('/api/v1/pages?lang=ca', $this->apiHeadersFor($user))
|
|
->assertOk()
|
|
->assertJsonPath('data.0.title', 'Solo español');
|
|
}
|
|
|
|
#[Test]
|
|
public function an_editor_can_create_a_page_with_several_translations(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
$category = Category::factory()->create();
|
|
|
|
$response = $this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Nueva página', 'en' => 'New page'],
|
|
'slug' => ['es' => 'nueva-pagina', 'en' => 'new-page'],
|
|
'category_id' => $category->id,
|
|
'status' => 'draft',
|
|
], $this->apiHeadersFor($user));
|
|
|
|
$response->assertCreated();
|
|
|
|
$page = Page::firstWhere('id', $response->json('data.id'));
|
|
|
|
$this->assertSame('Nueva página', $page->getTranslation('title', 'es'));
|
|
$this->assertSame('New page', $page->getTranslation('title', 'en'));
|
|
$this->assertSame($user->id, $page->author_id);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_partial_update_does_not_wipe_other_languages(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
$page = Page::factory()->draft()->create([
|
|
'title' => ['es' => 'Original', 'en' => 'Original EN'],
|
|
'slug' => ['es' => 'original', 'en' => 'original-en'],
|
|
'author_id' => $user->id,
|
|
]);
|
|
|
|
$this->patchJson("/api/v1/pages/{$page->id}", [
|
|
'title' => ['ca' => 'Nou títol'],
|
|
], $this->apiHeadersFor($user))->assertOk();
|
|
|
|
$page->refresh();
|
|
|
|
$this->assertSame('Nou títol', $page->getTranslation('title', 'ca'));
|
|
$this->assertSame('Original', $page->getTranslation('title', 'es'));
|
|
$this->assertSame('Original EN', $page->getTranslation('title', 'en'));
|
|
}
|
|
|
|
#[Test]
|
|
public function it_rejects_a_slug_already_used_in_the_same_locale(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
Page::factory()->create(['slug' => ['es' => 'repetido']]);
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Otra'],
|
|
'slug' => ['es' => 'repetido'],
|
|
], $this->apiHeadersFor($user))
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('slug');
|
|
}
|
|
|
|
#[Test]
|
|
public function the_same_slug_is_allowed_in_a_different_locale(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
Page::factory()->create(['slug' => ['es' => 'compartido']]);
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Otra'],
|
|
'slug' => ['es' => 'distinto', 'en' => 'compartido'],
|
|
], $this->apiHeadersFor($user))->assertCreated();
|
|
}
|
|
|
|
#[Test]
|
|
public function it_rejects_an_unsupported_locale_key(): void
|
|
{
|
|
$user = $this->userWithRoles('editor');
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Título', 'de' => 'Titel'],
|
|
'slug' => ['es' => 'titulo'],
|
|
], $this->apiHeadersFor($user))
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('title');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_viewer_cannot_create_a_page(): void
|
|
{
|
|
$user = $this->userWithRoles('viewer');
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'No permitido'],
|
|
'slug' => ['es' => 'no-permitido'],
|
|
], $this->apiHeadersFor($user))->assertForbidden();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_viewer_only_sees_published_pages(): void
|
|
{
|
|
$user = $this->userWithRoles('viewer');
|
|
|
|
Page::factory()->published()->create();
|
|
Page::factory()->draft()->create();
|
|
|
|
$response = $this->getJson('/api/v1/pages', $this->apiHeadersFor($user))->assertOk();
|
|
|
|
$this->assertCount(1, $response->json('data'));
|
|
$this->assertSame(PageStatus::Published->value, $response->json('data.0.status'));
|
|
}
|
|
|
|
#[Test]
|
|
public function publishing_requires_its_own_permission(): void
|
|
{
|
|
// An editor who may write content but has had publish revoked.
|
|
$user = $this->userWithRoles('editor');
|
|
$user->revokePermissionTo('pages.publish');
|
|
$user->roles()->first()->revokePermissionTo('pages.publish');
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Intento'],
|
|
'slug' => ['es' => 'intento'],
|
|
'status' => 'published',
|
|
], $this->apiHeadersFor($user))
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('status');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_super_admin_bypasses_every_permission_check(): void
|
|
{
|
|
$user = $this->userWithRoles('super_admin');
|
|
|
|
$this->assertSame(0, $user->roles()->first()->permissions()->count());
|
|
|
|
$this->postJson('/api/v1/pages', [
|
|
'title' => ['es' => 'Puedo todo'],
|
|
'slug' => ['es' => 'puedo-todo'],
|
|
'status' => 'published',
|
|
], $this->apiHeadersFor($user))->assertCreated();
|
|
}
|
|
}
|