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>
350 lines
12 KiB
PHP
350 lines
12 KiB
PHP
<?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();
|
|
}
|
|
}
|