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:
349
tests/Feature/Api/SubmissionApiTest.php
Normal file
349
tests/Feature/Api/SubmissionApiTest.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ApplicationReceived;
|
||||
use App\Support\Maintenance;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The only endpoints on the public API a stranger can write to.
|
||||
*
|
||||
* Everything here is about the two ways that can go wrong: a real person
|
||||
* losing their message, and a bot filling the table. The tests are written so
|
||||
* that a change which trades one for the other fails.
|
||||
*/
|
||||
class SubmissionApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Symfony's Request::create() injects "Accept-Language: en-us", and
|
||||
// this API honours content negotiation. Ask for Spanish explicitly.
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function payload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.org',
|
||||
'message' => 'Tengo unas horas los fines de semana y me gustaría echar una mano.',
|
||||
'hours_per_week' => 4,
|
||||
'links' => ['github' => 'https://github.com/ada'],
|
||||
'consent' => true,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
protected function openCall(): Call
|
||||
{
|
||||
return Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'title' => ['es' => 'Backend', 'en' => 'Backend', 'ca' => 'Backend'],
|
||||
'slug' => ['es' => 'backend', 'en' => 'backend', 'ca' => 'rerefons'],
|
||||
'opens_at' => now()->subWeek(),
|
||||
'closes_at' => now()->addWeek(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_accepts_an_application_to_an_open_call(): void
|
||||
{
|
||||
$call = $this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', 'new')
|
||||
->assertJsonPath('data.duplicate', false)
|
||||
->assertJsonStructure(['data' => ['id'], 'message']);
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertSame($call->id, $application->call_id);
|
||||
$this->assertSame(ApplicationKind::Contribute, $application->kind);
|
||||
$this->assertSame(ApplicationStatus::New, $application->status);
|
||||
$this->assertSame('ada@example.org', $application->email);
|
||||
$this->assertSame(4, $application->hours_per_week);
|
||||
|
||||
// Snapshotted, so the record still reads correctly once the call is
|
||||
// renamed or deleted.
|
||||
$this->assertSame('Backend', $application->call_title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_an_application_to_a_call_that_is_not_open(): void
|
||||
{
|
||||
Call::factory()->closed()->create([
|
||||
'project_id' => null,
|
||||
'slug' => ['es' => 'cerrada'],
|
||||
]);
|
||||
|
||||
// Taking somebody's time to write a message for a position that is
|
||||
// already gone is worse than showing them no form at all.
|
||||
$this->postJson('/api/public/v1/calls/cerrada/apply', $this->payload())
|
||||
->assertNotFound();
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_requires_the_fields_that_make_a_reply_possible(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', [
|
||||
'name' => 'A',
|
||||
'email' => 'not-an-email',
|
||||
'message' => 'hola',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name', 'email', 'message', 'consent']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_will_not_store_personal_data_without_consent(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload(['consent' => false]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('consent');
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_honeypot_submission_is_filed_as_spam_rather_than_dropped(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
config('site.submissions.honeypot') => 'http://buy-cheap-things.example',
|
||||
]))->assertCreated()
|
||||
// The bot is told nothing: an obvious rejection is a hint to try
|
||||
// again differently.
|
||||
->assertJsonPath('data.status', 'new');
|
||||
|
||||
// Nothing is thrown away. The honeypot is a heuristic, and a browser
|
||||
// extension can trip it for a real person.
|
||||
$this->assertSame(ApplicationStatus::Spam, Application::sole()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_repeat_submission_is_treated_as_the_same_one(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// A double-clicked submit button is not a second person, and an error
|
||||
// would only make the visitor try harder.
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertOk()
|
||||
->assertJsonPath('data.duplicate', true);
|
||||
|
||||
$this->assertSame(1, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_same_person_may_still_write_about_something_else(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/contact', $this->payload())->assertCreated();
|
||||
|
||||
$this->assertSame(2, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_general_form_distinguishes_offering_help_from_asking_for_it(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/contact', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
'organisation' => 'Asociación Vecinal',
|
||||
'email' => 'hola@asociacion.example',
|
||||
]))->assertCreated();
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertSame(ApplicationKind::Request, $application->kind);
|
||||
$this->assertSame('Asociación Vecinal', $application->organisation);
|
||||
$this->assertNull($application->call_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_organisation_asking_for_help_has_to_say_who_it_is(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/contact', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
]))->assertUnprocessable()
|
||||
->assertJsonValidationErrors('organisation');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_kind_cannot_be_chosen_when_answering_a_call(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// Applying to a call is always an offer of time; letting the payload
|
||||
// say otherwise would put replies in the wrong queue.
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
]))->assertUnprocessable()
|
||||
->assertJsonValidationErrors('kind');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_the_language_the_visitor_wrote_in(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// Reached through the Catalan URL of the same call — a slug only
|
||||
// resolves inside its own language.
|
||||
$this->postJson('/api/public/v1/calls/rerefons/apply?lang=ca', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// So the reply goes out in the same language they used.
|
||||
$this->assertSame('ca', Application::sole()->locale);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_keeps_a_hash_of_the_address_rather_than_the_address(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertNotNull($application->ip_hash);
|
||||
$this->assertSame(64, strlen($application->ip_hash));
|
||||
$this->assertStringNotContainsString('127.0.0.1', $application->ip_hash);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_confirmation_is_never_cached(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$response = $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// A shared cache holding one of these would hand it to the next
|
||||
// visitor.
|
||||
$this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_tells_the_people_who_can_read_the_inbox(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$editor = User::factory()->create()->syncRoles('editor')->fresh();
|
||||
$viewer = User::factory()->create()->syncRoles('viewer')->fresh();
|
||||
|
||||
$this->openCall();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
|
||||
Notification::assertSentTo($editor, ApplicationReceived::class);
|
||||
|
||||
// A viewer has no business reading strangers' contact details, so
|
||||
// they are not told about them either.
|
||||
Notification::assertNotSentTo($viewer, ApplicationReceived::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spam_does_not_wake_anybody_up(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$editor = User::factory()->create()->syncRoles('editor')->fresh();
|
||||
|
||||
$this->openCall();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
config('site.submissions.honeypot') => 'spam',
|
||||
]))->assertCreated();
|
||||
|
||||
Notification::assertNotSentTo($editor, ApplicationReceived::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function submissions_stop_while_the_public_site_is_in_maintenance(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
Maintenance::enable(['es' => 'Volvemos en un rato']);
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('maintenance', true);
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_forms_can_be_closed_without_a_deploy(): void
|
||||
{
|
||||
config()->set('site.submissions.enabled', false);
|
||||
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('submissions_enabled', false);
|
||||
|
||||
// And the frontend learns it from /site, so it hides the form instead
|
||||
// of rendering one that would be refused.
|
||||
$this->getJson('/api/public/v1/site')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.submissions_enabled', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function one_address_cannot_flood_the_inbox(): void
|
||||
{
|
||||
config()->set('site.submissions.rate_limit', 2);
|
||||
// Otherwise the second request would be answered as a duplicate and
|
||||
// never reach the limiter.
|
||||
config()->set('site.submissions.deduplicate_minutes', 0);
|
||||
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertTooManyRequests();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_read_endpoints_stay_read_only(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// The write routes are explicit; nothing else on the public API
|
||||
// acquired a verb along the way.
|
||||
$this->postJson('/api/public/v1/projects')->assertMethodNotAllowed();
|
||||
$this->postJson('/api/public/v1/calls')->assertMethodNotAllowed();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user