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:
301
tests/Feature/Api/TelemetryApiTest.php
Normal file
301
tests/Feature/Api/TelemetryApiTest.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\Maintenance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The frontend reporting what it could not resolve.
|
||||
*
|
||||
* The endpoint is small; what needs holding still is everything around it. It
|
||||
* is a write endpoint on the public API, so it is gated by a secret and can
|
||||
* never be open by accident. It is fed by strangers' traffic, so a scan must
|
||||
* not be able to turn the table into a log. And what it collects is only worth
|
||||
* collecting if it lines up exactly with what a redirect matches against.
|
||||
*/
|
||||
class TelemetryApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected string $token = 'ingest-secret';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['site.telemetry.token' => $this->token]);
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function report(array $payload): TestResponse
|
||||
{
|
||||
return $this->withHeaders(['X-Ingest-Token' => $this->token])
|
||||
->postJson('/api/public/v1/telemetry/not-found', $payload);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_an_address_that_could_not_be_resolved(): void
|
||||
{
|
||||
$this->report([
|
||||
'path' => '/proyectos/nombre-viejo',
|
||||
'locale' => 'es',
|
||||
'referrer' => 'https://example.net/enlace',
|
||||
])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 1)
|
||||
// A confirmation is nobody else's, and this one is not cacheable
|
||||
// for the same reason a submission's is not.
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', [
|
||||
'path' => '/proyectos/nombre-viejo',
|
||||
'locale' => 'es',
|
||||
'hits' => 1,
|
||||
'referrer' => 'https://example.net/enlace',
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property that makes this a counter rather than a log: a crawler
|
||||
* walking a dead section must cost one row, not one row per request.
|
||||
*/
|
||||
#[Test]
|
||||
public function reporting_the_same_address_again_counts_instead_of_repeating(): void
|
||||
{
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
$this->report(['path' => '/equipo/quien-sea', 'locale' => 'es'])->assertAccepted();
|
||||
}
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame(3, (int) MissingUrl::query()->first()->hits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths are normalised exactly as redirects are. If these two ever
|
||||
* disagree, the list fills with addresses that already have a rule.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_normalises_the_path_the_way_a_redirect_does(): void
|
||||
{
|
||||
$this->report(['path' => '/proyectos/algo/', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/proyectos/algo?utm_source=twitter', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame('/proyectos/algo', MissingUrl::query()->first()->path);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_accepts_a_batch(): void
|
||||
{
|
||||
$this->report(['hits' => [
|
||||
['path' => '/uno', 'locale' => 'es'],
|
||||
['path' => '/dos', 'locale' => 'en'],
|
||||
['path' => '/tres'],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 3);
|
||||
|
||||
$this->assertSame(3, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale is half of the unique key, so it can never be null — the
|
||||
* negotiated one stands in when the frontend does not say.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_report_without_a_locale_falls_back_to_the_negotiated_one(): void
|
||||
{
|
||||
$this->withHeaders(['Accept-Language' => 'ca'])
|
||||
->report(['path' => '/sense-idioma'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/sense-idioma', 'locale' => 'ca']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same address in two languages is two problems: they are fixed by two
|
||||
* different redirects, because the paths differ per language.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_same_path_in_two_languages_is_two_rows(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
$this->assertSame(2, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_drops_scans_for_things_that_were_never_here(): void
|
||||
{
|
||||
$this->report(['hits' => [
|
||||
['path' => '/wp-admin/setup-config.php'],
|
||||
['path' => '/.env'],
|
||||
['path' => '/proyectos/de-verdad'],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.received', 3)
|
||||
// Reported honestly: the frontend can tell its filter from ours.
|
||||
->assertJsonPath('data.recorded', 1);
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame('/proyectos/de-verdad', MissingUrl::query()->first()->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* A later sighting without a referrer is what a bot produces. It must not
|
||||
* erase the referrer that told us who is linking to a dead address.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_later_sighting_never_erases_a_known_referrer(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es', 'referrer' => 'https://example.net/post'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$this->assertSame('https://example.net/post', MissingUrl::query()->first()->referrer);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_without_the_secret(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->withHeaders(['X-Ingest-Token' => 'wrong'])
|
||||
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule the preview secret already follows: an unset secret disables
|
||||
* the endpoint, it never opens it.
|
||||
*/
|
||||
#[Test]
|
||||
public function an_unset_secret_grants_nobody_access(): void
|
||||
{
|
||||
config(['site.telemetry.token' => null]);
|
||||
|
||||
$this->withHeaders(['X-Ingest-Token' => ''])
|
||||
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_what_it_cannot_use(): void
|
||||
{
|
||||
$this->report(['path' => 'https://otro-dominio.example/algo'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('hits.0.path');
|
||||
|
||||
$this->report(['hits' => []])->assertUnprocessable();
|
||||
|
||||
$this->report(['path' => '/algo', 'locale' => 'de'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('hits.0.locale');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_caps_how_much_one_request_may_carry(): void
|
||||
{
|
||||
config(['site.telemetry.max_batch' => 2]);
|
||||
|
||||
$this->report(['hits' => [
|
||||
['path' => '/uno'], ['path' => '/dos'], ['path' => '/tres'],
|
||||
]])->assertUnprocessable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance is about what visitors are shown. A 404 during it is still a
|
||||
* 404 — and it is the most likely moment to produce the interesting ones.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_keeps_listening_while_the_site_is_down_for_maintenance(): void
|
||||
{
|
||||
app(Maintenance::class)->enable();
|
||||
|
||||
$this->report(['path' => '/durante-el-mantenimiento', 'locale' => 'es'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/durante-el-mantenimiento']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop this feature exists to close: a report becomes a redirect, and
|
||||
* the report stops asking to be dealt with.
|
||||
*/
|
||||
#[Test]
|
||||
public function writing_a_redirect_resolves_the_report_it_answers(): void
|
||||
{
|
||||
$this->report(['path' => '/proyectos/viejo', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
Redirect::create([
|
||||
'from' => '/proyectos/viejo',
|
||||
'to' => '/proyectos/nuevo',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
MissingUrlStatus::Resolved,
|
||||
MissingUrl::query()->first()->status,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_redirect_for_every_language_resolves_every_languages_report(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
Redirect::create([
|
||||
'from' => '/algo',
|
||||
'to' => '/otra-cosa',
|
||||
'status' => 301,
|
||||
'locale' => null,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->pending()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect that was written and then deleted leaves the address broken
|
||||
* again. Being told about it once must not mean being told never again.
|
||||
*/
|
||||
#[Test]
|
||||
public function an_address_seen_again_after_being_dealt_with_comes_back(): void
|
||||
{
|
||||
$url = MissingUrl::factory()->resolved()->create([
|
||||
'path' => '/vuelve',
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$this->report(['path' => '/vuelve', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$url->refresh();
|
||||
|
||||
$this->assertSame(MissingUrlStatus::Pending, $url->status);
|
||||
$this->assertNull($url->resolved_at);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user