<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Group;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\ReportOrganisationAttribute;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * The Oculi-backed attack endpoints on the mitigation tree, served by the Development\Oculi scenario
 * files: organisation #1 has one ongoing attack, organisation #2 has one completed and report-ready
 * attack, and the admin view sees both plus a third organisation's.
 *
 * The load bearing behaviour here is scope pinning. Oculi believes whatever organisation it is asked
 * about, so a user request must always carry the authenticated organisation no matter what the user
 * put in the query string - these tests hit the endpoints as different users and check who saw what.
 * Permission bouncing is covered generically by RouteSecurityTest; requests here run withoutMiddleware.
 */
class MitigationAttackControllerTest extends TestCase
{
    use RefreshDatabase;

    private array $users = [];
    private array $prefixes = [];

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'creatia.oculi.url'     => 'https://oculi.test',
            'creatia.oculi.secret'  => 'test',
            'creatia.oculi.cert'    => false,
        ]);

        $organisations = [
            '1' => Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]),
            '2' => Organisation::create([ 'id' => 2, 'name' => 'Organisation #2' ]),
            '3' => Organisation::create([ 'id' => 3, 'name' => 'Organisation #3' ]),
        ];

        $group = Group::create([ 'name' => 'Testing' ]);

        $default_user = [
            'phone'             => null,
            'password'          => '',
            'group_id'          => $group->id,
        ];

        $this->users = [
            '1' => User::create([ ...$default_user, 'organisation_id' => $organisations['1']->id, 'name' => 'User #1', 'email' => 'creatia+1@globalsecurelayer.com' ]),
            '2' => User::create([ ...$default_user, 'organisation_id' => $organisations['2']->id, 'name' => 'User #2', 'email' => 'creatia+2@globalsecurelayer.com' ]),
            '3' => User::create([ ...$default_user, 'organisation_id' => $organisations['3']->id, 'name' => 'User #3', 'email' => 'creatia+3@globalsecurelayer.com' ]),
        ];

        /**
         * One prefix each for #1 and #2, so the prefix-identity narrowing has something to allow and
         * something to refuse.
         */
        $this->prefixes = [
            '1' => Prefixes::factory()->address('10.0.0.0', 24)->name('Org One Site')->ownership($organisations['1'], Asns::create([ 'asn' => 64512, 'organisation_id' => 1, 'approved' => 1 ]))->create(),
            '2' => Prefixes::factory()->address('10.0.2.0', 24)->name('Customer Two')->ownership($organisations['2'], Asns::create([ 'asn' => 64513, 'organisation_id' => 2, 'approved' => 1 ]))->create(),
        ];
    }

    /**
     * The happy path: a user asks for their table and gets exactly their organisation's attacks,
     * because the controller pinned the Oculi scope to their authenticated organisation.
     */
    public function testAUserSeesTheirOwnAttackTable(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data'], 'Organisation #1 holds exactly one attack in the scenario.');
        $this->assertEquals('bb000000-0000-4000-8000-000000000001', $response->json()['data'][0]['attack_id']);
    }

    /**
     * The attack this suite exists to stop: a user smuggling `organisation_id=2` into the query.
     * That parameter is an admin affordance; on the user side the response must stay their own.
     */
    public function testAUserCannotChooseAnotherOrganisationsScope(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100&organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertEquals('bb000000-0000-4000-8000-000000000001', $response->json()['data'][0]['attack_id'], 'The request must stay pinned to the authenticated organisation.');
    }

    // An organisation with no attacks renders an empty table, not an error.
    public function testAUserWithNoAttacksSeesAnEmptyTable(): void
    {
        $response = $this->actingAs($this->users['3'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(0, $response->json()['data']);
    }

    // Order columns are whitelisted, so anything creative gets a 422 before Oculi is asked.
    public function testAnInvalidOrderColumnIsRejected(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100&order_column=;drop');

        $this->assertEquals(422, $response->status());
    }

    /**
     * The admin tree is the other half of the scope model: no organisation parameter means Oculi's
     * every-organisation view, naming one narrows to it.
     */
    public function testTheAdminTableSeesEveryOrganisationOrJustOne(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(3, $response->json()['data'], 'No organisation parameter means the whole estate.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack?start=0&length=100&organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data']);
        $this->assertEquals('bb000000-0000-4000-8000-000000000002', $response->json()['data'][0]['attack_id']);
    }

    /**
     * Narrowing is done by naming a prefix the caller holds - never by typing an address. Their own
     * prefix passes; someone else's is refused before Oculi is ever asked, on every endpoint that
     * narrows, the table filter included.
     */
    public function testNarrowingRequiresAHeldPrefixIdentity(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/summary?prefix=' . $this->prefixes['1']->id);

        $this->assertEquals(200, $response->status());

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/summary?prefix=' . $this->prefixes['2']->id);

        $this->assertEquals(422, $response->status(), 'A prefix the caller does not hold must be refused.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100&filter[column][prefix]=' . $this->prefixes['2']->id);

        $this->assertEquals(422, $response->status(), 'The table filter is held to the same rule.');
    }

    /**
     * Admins may name any prefix on file - a wider entitlement, but still an identity, so nothing
     * freeform ever reaches Oculi from either side.
     */
    public function testAdminNarrowingAcceptsAnyPrefixOnFile(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/summary?prefix=' . $this->prefixes['2']->id);

        $this->assertEquals(200, $response->status());

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/summary?prefix=999999');

        $this->assertEquals(422, $response->status(), 'Even admins can only name prefixes that exist.');
    }

    /**
     * What actually leaves for Oculi: the friendly filter shapes become the wire's strict ones - dates
     * to unix seconds, the ongoing flag to a bare 1 (or dropped entirely when false), and the prefix
     * identity to the CIDR from our own records. Oculi 400s anything else, so a regression here would
     * silently empty customer tables.
     */
    public function testTableFiltersAreTranslatedToTheWireFormat(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get(
            'api/mitigation/attack?start=0&length=100'
            . '&filter[column][prefix]=' . $this->prefixes['1']->id
            . '&filter[column][start_after]=2026-09-27'
            . '&filter[column][ongoing]=1'
        );

        $this->assertEquals(200, $response->status());

        Http::assertSent(function (Request $request) {
            if(!str_contains($request->url(), 'mitigation/attacks/table')) {
                return false;
            }

            $filters = $request->data()['filters'] ?? [];

            return ($filters['supernet'] ?? null) === '10.0.0.0/24'
                && !isset($filters['prefix'])
                && ($filters['start_after'] ?? null) == Carbon::parse('2026-09-27')->unix()
                && ($filters['ongoing'] ?? null) == 1;
        });

        // The false flag is not forwarded as 0 - Oculi treats any present value as a filter.
        $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100&filter[column][ongoing]=0');

        Http::assertSent(function (Request $request) {
            if(!str_contains($request->url(), 'mitigation/attacks/table')) {
                return false;
            }

            $filters = $request->data()['filters'] ?? [];

            return !isset($filters['supernet']) && !isset($filters['ongoing']);
        });
    }

    /**
     * The admin side of the per-attack endpoints mirrors the table's scope model: naming the
     * organisation narrows to it, naming none is the unscoped view - on events and reports alike.
     */
    public function testAdminEventsAndReportsFollowTheScopeModel(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/bb000000-0000-4000-8000-000000000002/events?organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data']);

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/bb000000-0000-4000-8000-000000000002/events');

        $this->assertEquals(200, $response->status(), 'No organisation named is the unscoped admin view.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/bb000000-0000-4000-8000-000000000002/report?organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertEquals('Customer Two', $response->json()['attack']['descriptor']);

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/bb000000-0000-4000-8000-000000000002/report');

        $this->assertEquals(200, $response->status());
    }

    // The rollup endpoints follow the same pinning: #1 has something live in the scenario, #2 does not.
    public function testSummaryAndOngoingAreScopedToTheUser(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/summary');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(1, $response->json()['ongoing']);

        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/attack/ongoing');

        $this->assertEquals(200, $response->status());
        $this->assertCount(0, $response->json()['data'], 'Organisation #2 has nothing live.');
    }

    /**
     * Table rows only carry an event_count these days; the detail lives on its own endpoint, scoped
     * the same way as everything else.
     */
    public function testTheEventsBehindAnAttackAreFetchable(): void
    {
        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/attack/bb000000-0000-4000-8000-000000000002/events');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data']);
        $this->assertEquals('SYN flood', $response->json()['data'][0]['description']);

        // Another organisation asking for the same attack gets the indistinguishable 404.
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/bb000000-0000-4000-8000-000000000002/events');

        $this->assertEquals(404, $response->status());
    }

    // Reports default to JSON so the frontend can render them natively.
    public function testAReportComesBackAsJsonByDefault(): void
    {
        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/attack/bb000000-0000-4000-8000-000000000002/report');

        $this->assertEquals(200, $response->status());
        $this->assertEquals('Customer Two', $response->json()['attack']['descriptor']);
        $this->assertArrayHasKey('timeline', $response->json());
    }

    // format=pdf streams the rendered document instead, as a download.
    public function testAReportStreamsAsAPdfWhenAsked(): void
    {
        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/attack/bb000000-0000-4000-8000-000000000002/report?format=pdf');

        /**
         * Streamed responses are asserted the way ReportingV2Test does it - getStatusCode() and a
         * captured sendContent(); the testing wrapper proxies status() straight to the stream.
         */
        $this->assertEquals(200, $response->getStatusCode());
        $response->assertHeader('Content-Type', 'application/pdf');

        ob_start();
        $response->baseResponse->sendContent();
        $content = ob_get_clean();

        $this->assertStringStartsWith('%PDF', $content);
    }

    /**
     * Cross-tenant probing must learn nothing: a report the caller cannot see answers exactly like
     * one that does not exist.
     */
    public function testAnotherOrganisationsReportIsA404(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/bb000000-0000-4000-8000-000000000002/report');

        $this->assertEquals(404, $response->status(), 'A report the organisation cannot see must be indistinguishable from one that does not exist.');
    }

    // Attack identifiers are validated as UUIDs before Oculi is ever asked.
    public function testAMangledAttackIdentifierIsRejectedBeforeOculiIsAsked(): void
    {
        // Hex-shaped so it gets past the route constraint, but no UUID, so validation has to catch it.
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/deadbeef-cafe/report');

        $this->assertEquals(422, $response->status());
    }

    /**
     * Settings on the mitigation tree are doors onto the SAME attribute store the legacy
     * goliath/report tree uses - prove that by writing through the new path and reading the change
     * back, both from the new path and from the underlying table.
     */
    public function testSettingsReadAndWriteTheSharedAttributeStore(): void
    {
        // No attribute row exists yet, so the read serves the documented defaults.
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/settings');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(3, $response->json()['minimum_threshold_gbps'], 'With no stored attributes the defaults should come back.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->postJson('api/mitigation/attack/settings', [
            'send_individual'           => true,
            'send_carpet'               => false,
            'send_webhook'              => true,
            'send_start'                => true,
            'send_finish'               => false,
            'minimum_threshold_gbps'    => 5
        ]);

        $this->assertEquals(200, $response->status());

        /**
         * The write landed in report_organisation_attributes - the same row the legacy tree and the
         * notification pipeline read.
         */
        $stored = ReportOrganisationAttribute::where('organisation_id', '=', 1)->first();

        $this->assertNotNull($stored);
        $this->assertEquals(5, $stored->minimum_threshold_gbps);
        $this->assertEquals(false, (bool) $stored->send_finish);

        $this->assertEquals(5, $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/settings')->json()['minimum_threshold_gbps']);
    }

    /**
     * The admin variant picks its organisation from the organisation_id input, exactly like the
     * legacy admin attribute handlers it points at.
     */
    public function testAdminSettingsTargetTheChosenOrganisation(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->postJson('api/admin/mitigation/attack/settings', [
            'organisation_id'           => 2,
            'send_individual'           => true,
            'send_carpet'               => false,
            'send_webhook'              => true,
            'send_start'                => true,
            'send_finish'               => true,
            'minimum_threshold_gbps'    => 9
        ]);

        $this->assertEquals(200, $response->status());
        $this->assertEquals(9, ReportOrganisationAttribute::where('organisation_id', '=', 2)->first()->minimum_threshold_gbps, 'The write must land on the organisation the admin named, not their own.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/settings?organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(9, $response->json()['minimum_threshold_gbps']);
    }

    /**
     * The settings contract's edges: a write missing its required booleans is the legacy 400, and the
     * read always carries the emails list - empty or not - because the frontend renders it.
     */
    public function testSettingsValidationAndEmailShape(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->postJson('api/mitigation/attack/settings', []);

        $this->assertEquals(400, $response->status(), 'The attribute write validates its full shape.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/settings');

        $this->assertEquals(200, $response->status());
        $this->assertIsArray($response->json()['emails']);
    }

    /**
     * The leaderboard needs its window and size spelled out; given them, it comes back ordered by
     * c_score from the scenario.
     */
    public function testTheAdminLeaderboardRequiresItsWindow(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/leaderboard?start=0&length=100');

        $this->assertEquals(422, $response->status(), 'The leaderboard window and size are not optional.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/attack/leaderboard?start=0&length=100&attack_start=2026-08-28&attack_finish=2026-09-28&attack_size=100');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(91.2, $response->json()['data'][0]['c_score']);
    }

    /**
     * Carpets ride the attack tree's scope model: a user's table is pinned to their organisation
     * whatever they put in the query, and the admin tree sees everything or the one it names.
     */
    public function testCarpetTablesFollowTheScopeModel(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet?start=0&length=100&organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data']);
        $this->assertEquals('cc000000-0000-4000-8000-000000000001', $response->json()['data'][0]['carpet_id'], 'The request must stay pinned to the authenticated organisation.');

        $response = $this->actingAs($this->users['3'])->withoutMiddleware()->get('api/mitigation/carpet?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(0, $response->json()['data']);

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/carpet?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(2, $response->json()['data'], 'No organisation parameter means the whole estate.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/carpet?start=0&length=100&organisation_id=2');

        $this->assertEquals(200, $response->status());
        $this->assertEquals('cc000000-0000-4000-8000-000000000002', $response->json()['data'][0]['carpet_id']);
    }

    /**
     * The carpet table's filters leave in the wire format the attack table's do, with its own extras:
     * the prefix identity narrows to carpets inside the CIDR, and a destination minimum passes through
     * as an integer. Order columns are the carpet's, not the attack's.
     */
    public function testCarpetFiltersAreTranslatedToTheWireFormat(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get(
            'api/mitigation/carpet?start=0&length=100&order_column=destination_count'
            . '&filter[column][prefix]=' . $this->prefixes['1']->id
            . '&filter[column][min_destinations]=30'
            . '&filter[column][ongoing]=1'
        );

        $this->assertEquals(200, $response->status());

        Http::assertSent(function (Request $request) {
            if(!str_contains($request->url(), 'mitigation/carpets/table')) {
                return false;
            }

            $filters = $request->data()['filters'] ?? [];

            return ($request->data()['order'] ?? null) === 'destination_count'
                && ($filters['supernet'] ?? null) === '10.0.0.0/24'
                && !isset($filters['prefix'])
                && ($filters['min_destinations'] ?? null) == 30
                && ($filters['ongoing'] ?? null) == 1;
        });

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet?start=0&length=100&order_column=destination');

        $this->assertEquals(422, $response->status(), 'The attack table\'s columns are not the carpet table\'s.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet?start=0&length=100&filter[column][prefix]=' . $this->prefixes['2']->id);

        $this->assertEquals(422, $response->status(), 'Narrowing is held to a prefix the caller holds, as on the attack table.');
    }

    // The attack table gained a descriptor filter; it is forwarded as the substring Oculi matches.
    public function testTheDescriptorFilterIsForwarded(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack?start=0&length=100&filter[column][descriptor]=Site');

        $this->assertEquals(200, $response->status());

        Http::assertSent(function (Request $request) {
            return str_contains($request->url(), 'mitigation/attacks/table')
                && (($request->data()['filters']['descriptor'] ?? null) === 'Site');
        });
    }

    /**
     * A carpet's member attacks come from the carpet's organisation, and a carpet the caller cannot
     * see answers like one that does not exist.
     */
    public function testACarpetsMemberAttacksAreScoped(): void
    {
        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/carpet/cc000000-0000-4000-8000-000000000002/attacks?start=0&length=100');

        $this->assertEquals(200, $response->status());
        $this->assertCount(1, $response->json()['data']);
        $this->assertEquals('bb000000-0000-4000-8000-000000000002', $response->json()['data'][0]['attack_id']);

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet/cc000000-0000-4000-8000-000000000002/attacks?start=0&length=100');

        $this->assertEquals(404, $response->status());

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/admin/mitigation/carpet/cc000000-0000-4000-8000-000000000002/attacks?start=0&length=100');

        $this->assertEquals(200, $response->status(), 'No organisation named is the unscoped admin view.');
    }

    /**
     * The carpet report is JSON with the destination breakdown; asking for a PDF is answered with the
     * 501 Oculi gives until its carpet template exists, which is not a 404.
     */
    public function testACarpetReportIsJsonAndPdfIsNotYetAvailable(): void
    {
        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/carpet/cc000000-0000-4000-8000-000000000002/report');

        $this->assertEquals(200, $response->status());
        $this->assertEquals('10.0.2.0/24', $response->json()['carpet']['prefix']);
        $this->assertArrayHasKey('dst_addr', $response->json());

        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/carpet/cc000000-0000-4000-8000-000000000002/report?format=pdf');

        $this->assertEquals(501, $response->status());

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet/cc000000-0000-4000-8000-000000000002/report');

        $this->assertEquals(404, $response->status(), 'A report the organisation cannot see must be indistinguishable from one that does not exist.');
    }

    // The rollups carry carpets too, pinned the same way: #1 has a carpet under way, #2 does not.
    public function testCarpetSummaryAndOngoingAreScopedToTheUser(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/attack/summary');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(1, $response->json()['carpets']['ongoing'], 'The attack summary carries the carpet counts.');

        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet/summary');

        $this->assertEquals(200, $response->status());
        $this->assertEquals(1, $response->json()['ongoing']);

        $response = $this->actingAs($this->users['2'])->withoutMiddleware()->get('api/mitigation/carpet/ongoing');

        $this->assertEquals(200, $response->status());
        $this->assertCount(0, $response->json()['data'], 'Organisation #2 has nothing live.');
    }

    /**
     * Only what the endpoint's own rules validated leaves for Oculi. An attack column on the carpet
     * table is dropped rather than relayed, whatever Oculi would make of it.
     */
    public function testOnlyValidatedFiltersAreForwarded(): void
    {
        $response = $this->actingAs($this->users['1'])->withoutMiddleware()->get('api/mitigation/carpet?start=0&length=100&filter[column][destination]=10.0.0.1&filter[column][min_destinations]=5');

        $this->assertEquals(200, $response->status());

        Http::assertSent(function (Request $request) {
            if(!str_contains($request->url(), 'mitigation/carpets/table')) {
                return false;
            }

            $filters = $request->data()['filters'] ?? [];

            return !isset($filters['destination']) && ($filters['min_destinations'] ?? null) == 5;
        });
    }
}
