|
@@ -0,0 +1,304 @@
|
|
|
|
|
+<?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 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|