Initial commit: Pireon Admin — headless admin panel and public API

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>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
<?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();
}
}