| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Crud;
- use App\Auth\UserContext;
- use App\Http\CsrfMiddleware;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * SPEC §M10 acceptance: every list page renders and at least one
- * happy-path + one validation-error path per resource is covered.
- *
- * Each test queues responses for the AdminClient calls the controller
- * fires; the AppTestCase's MockHandler returns them in FIFO order.
- */
- final class CrudPagesTest extends AppTestCase
- {
- protected function setUp(): void
- {
- $this->bootApp();
- $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
- $_SESSION['_last_active'] = time();
- $_SESSION['_authenticated_at'] = time();
- }
- // ---- list pages render ----
- public function testManualBlocksListRenders(): void
- {
- $this->enqueueApiResponse(200, ['items' => [
- ['id' => 1, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
- ], 'total' => 1]);
- $response = $this->request('GET', '/app/manual-blocks');
- self::assertSame(200, $response->getStatusCode());
- $body = (string) $response->getBody();
- self::assertStringContainsString('192.0.2.0/24', $body);
- self::assertStringContainsString('Manual blocks', $body);
- }
- public function testSubnetsAliasFiltersToSubnets(): void
- {
- $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
- $response = $this->request('GET', '/app/subnets');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('Subnets', (string) $response->getBody());
- }
- public function testAllowlistListRenders(): void
- {
- $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
- $response = $this->request('GET', '/app/allowlist');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('Allowlist', (string) $response->getBody());
- }
- public function testPoliciesListRenders(): void
- {
- $this->enqueueApiResponse(200, ['items' => [
- ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
- ], 'total' => 1]);
- $response = $this->request('GET', '/app/policies');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('moderate', (string) $response->getBody());
- }
- public function testReportersListRenders(): void
- {
- $this->enqueueApiResponse(200, ['data' => [
- ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'],
- ], 'total' => 1, 'page' => 1, 'limit' => 200]);
- $response = $this->request('GET', '/app/reporters');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('web-prod-01', (string) $response->getBody());
- }
- public function testConsumersListRenders(): void
- {
- // First call: listConsumers; second: listPolicies (for the dropdown).
- $this->enqueueApiResponse(200, ['data' => [
- ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null],
- ], 'total' => 1, 'page' => 1, 'limit' => 200]);
- $this->enqueueApiResponse(200, ['items' => [
- ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
- ], 'total' => 1]);
- $response = $this->request('GET', '/app/consumers');
- self::assertSame(200, $response->getStatusCode());
- $body = (string) $response->getBody();
- self::assertStringContainsString('fw-1', $body);
- self::assertStringContainsString('moderate', $body);
- }
- public function testTokensListRenders(): void
- {
- // listTokens, listReporters, listConsumers in that order.
- $this->enqueueApiResponse(200, ['data' => [
- ['id' => 1, 'kind' => 'admin', 'token_prefix' => 'irdb_adm', 'role' => 'viewer', 'reporter_id' => null, 'consumer_id' => null, 'expires_at' => null, 'revoked_at' => null, 'last_used_at' => null, 'created_at' => '2026-04-29T10:00:00Z'],
- ], 'total' => 1, 'page' => 1, 'limit' => 200]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $response = $this->request('GET', '/app/tokens');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('irdb_adm', (string) $response->getBody());
- }
- public function testCategoriesListRenders(): void
- {
- $this->enqueueApiResponse(200, ['items' => [
- ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
- ], 'total' => 1]);
- $response = $this->request('GET', '/app/categories');
- self::assertSame(200, $response->getStatusCode());
- self::assertStringContainsString('brute_force', (string) $response->getBody());
- }
- // ---- happy + validation paths per resource ----
- public function testManualBlockCreateHappyPath(): void
- {
- $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']);
- $token = $this->csrfFromManualBlocks();
- $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']);
- $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
- $flash = $_SESSION['_flash'] ?? [];
- self::assertNotEmpty($flash);
- self::assertSame('success', $flash[0]['type']);
- }
- public function testManualBlockCreateExpandsBareDateToEndOfDay(): void
- {
- // The form uses <input type="date">, which submits "YYYY-MM-DD".
- // The controller must expand that to T23:59:59Z so the block
- // covers the full selected calendar day.
- $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']);
- $token = $this->csrfFromManualBlocks();
- $body = http_build_query([
- 'csrf_token' => $token,
- 'kind' => 'ip',
- 'ip' => '203.0.113.5',
- 'reason' => 'eod test',
- 'expires_at' => '2026-12-31',
- ]);
- $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- // The last captured outgoing call is the POST to the api;
- // earlier history entries are the GETs from csrfFromManualBlocks.
- $last = end($this->apiHistory);
- self::assertNotFalse($last);
- self::assertSame('POST', $last['request']->getMethod());
- $payload = json_decode((string) $last['request']->getBody(), true);
- self::assertIsArray($payload);
- self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']);
- }
- public function testManualBlockCreateValidationErrorFlashesField(): void
- {
- // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
- // would consume any earlier-queued response, so queue the 400
- // AFTER the helper's GET has been served.
- $token = $this->csrfFromManualBlocks();
- $this->enqueueApiResponse(400, [
- 'error' => 'validation_failed',
- 'details' => ['cidr' => 'invalid format'],
- ]);
- $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
- $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- $flash = $_SESSION['_flash'] ?? [];
- self::assertNotEmpty($flash);
- self::assertSame('error', $flash[0]['type']);
- self::assertStringContainsString('cidr', $flash[0]['message']);
- }
- public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
- {
- // GET /app/tokens warm-up to set CSRF + session.
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->request('GET', '/app/tokens');
- $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
- self::assertNotEmpty($token);
- // POST creates a new admin token.
- $this->enqueueApiResponse(201, [
- 'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
- 'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
- 'role' => 'viewer',
- ]);
- $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
- $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $createResp->getStatusCode());
- // The follow-up GET surfaces the raw token in the response body
- // and clears the session slot afterwards.
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $listResp = $this->request('GET', '/app/tokens');
- $html = (string) $listResp->getBody();
- self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
- self::assertArrayNotHasKey('_token_just_created', $_SESSION);
- }
- public function testCategoryCreateValidationFlash(): void
- {
- // Warm-up GET for CSRF token.
- $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
- $this->request('GET', '/app/categories');
- $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
- $this->enqueueApiResponse(400, [
- 'error' => 'validation_failed',
- 'details' => ['slug' => 'already exists'],
- ]);
- $body = http_build_query([
- 'csrf_token' => $token,
- 'slug' => 'brute_force',
- 'name' => 'Dup',
- 'decay_function' => 'exponential',
- 'decay_param' => '14',
- ]);
- $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- self::assertSame('/app/categories', $response->getHeaderLine('Location'));
- $flash = $_SESSION['_flash'] ?? [];
- self::assertNotEmpty($flash);
- self::assertStringContainsString('slug', $flash[0]['message']);
- }
- public function testPolicyDeleteForwardsToApi(): void
- {
- $this->enqueueApiResponse(200, ['items' => [
- ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
- ], 'total' => 1]);
- $this->request('GET', '/app/policies');
- $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
- $this->enqueueApiResponse(204, []);
- $body = http_build_query(['csrf_token' => $token]);
- $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- self::assertSame('/app/policies', $response->getHeaderLine('Location'));
- }
- public function testReporterCreateHappyPath(): void
- {
- $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
- $this->request('GET', '/app/reporters');
- $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
- $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
- $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
- $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');
- self::assertSame(303, $response->getStatusCode());
- self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
- }
- public function testIpDetailRendersActionButtonsForOperator(): void
- {
- $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
- $this->enqueueApiResponse(200, [
- 'ip' => '203.0.113.10',
- 'is_ipv4' => true,
- 'status' => 'scored',
- 'scores' => [],
- 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
- 'manual_block' => null,
- 'allowlist' => null,
- 'history' => [],
- 'has_more' => false,
- ]);
- $response = $this->request('GET', '/app/ips/203.0.113.10');
- $body = (string) $response->getBody();
- self::assertStringContainsString('Add to allowlist', $body);
- self::assertStringContainsString('Manually block', $body);
- }
- public function testIpDetailHidesActionButtonsForViewer(): void
- {
- $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
- $this->enqueueApiResponse(200, [
- 'ip' => '203.0.113.10',
- 'is_ipv4' => true,
- 'status' => 'scored',
- 'scores' => [],
- 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
- 'manual_block' => null,
- 'allowlist' => null,
- 'history' => [],
- 'has_more' => false,
- ]);
- $response = $this->request('GET', '/app/ips/203.0.113.10');
- $body = (string) $response->getBody();
- self::assertStringNotContainsString('Add to allowlist', $body);
- self::assertStringNotContainsString('Manually block', $body);
- }
- private function csrfFromManualBlocks(): string
- {
- // GET to set the CSRF token in the session.
- $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
- $this->request('GET', '/app/manual-blocks');
- $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
- self::assertNotEmpty($token);
- return $token;
- }
- }
|