SearchPageTest.php 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Search;
  4. use App\Auth\UserContext;
  5. use App\Tests\Integration\Support\AppTestCase;
  6. /**
  7. * `/app/search` — global IP lookup behind the topnav search box.
  8. *
  9. * The controller fans out to three admin endpoints in this order:
  10. * 1. GET /api/v1/admin/ips
  11. * 2. GET /api/v1/admin/manual-blocks
  12. * 3. GET /api/v1/admin/allowlist
  13. *
  14. * Tests queue mock responses for that exact sequence.
  15. */
  16. final class SearchPageTest extends AppTestCase
  17. {
  18. protected function setUp(): void
  19. {
  20. $this->bootApp();
  21. // The first /app/* request in a process triggers session_start(),
  22. // which would clobber any $_SESSION values set here. Hit a public
  23. // route first so the session is already active by the time our
  24. // test request fires.
  25. $this->request('GET', '/healthz');
  26. $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
  27. $_SESSION['_last_active'] = time();
  28. $_SESSION['_authenticated_at'] = time();
  29. }
  30. public function testRendersEmptyFormWithoutQuery(): void
  31. {
  32. $resp = $this->request('GET', '/app/search');
  33. self::assertSame(200, $resp->getStatusCode());
  34. $body = (string) $resp->getBody();
  35. self::assertStringContainsString('Enter an IP address or prefix', $body);
  36. self::assertStringContainsString('name="q"', $body);
  37. }
  38. public function testRendersResultsForQuery(): void
  39. {
  40. // 1) IPs
  41. $this->enqueueApiResponse(200, [
  42. 'page' => 1,
  43. 'page_size' => 25,
  44. 'total' => 1,
  45. 'items' => [
  46. [
  47. 'ip' => '203.0.113.42',
  48. 'is_ipv4' => true,
  49. 'max_score' => 4.5,
  50. 'top_category' => 'brute_force',
  51. 'pair_count' => 1,
  52. 'last_report_at' => '2026-04-29T09:00:00Z',
  53. 'status' => 'scored',
  54. 'enrichment' => null,
  55. ],
  56. ],
  57. ]);
  58. // 2) Manual blocks
  59. $this->enqueueApiResponse(200, [
  60. 'items' => [
  61. ['id' => 1, 'kind' => 'subnet', 'cidr' => '203.0.113.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
  62. ['id' => 2, 'kind' => 'ip', 'ip' => '198.51.100.7', 'reason' => 'unrelated', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
  63. ],
  64. 'total' => 2,
  65. ]);
  66. // 3) Allowlist
  67. $this->enqueueApiResponse(200, [
  68. 'items' => [
  69. ['id' => 9, 'kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'office', 'created_at' => '2026-04-28T10:00:00Z', 'created_by_user_id' => null],
  70. ],
  71. 'total' => 1,
  72. ]);
  73. $resp = $this->request('GET', '/app/search?q=203.0.113');
  74. self::assertSame(200, $resp->getStatusCode());
  75. $body = (string) $resp->getBody();
  76. // IP result row + link to filtered IPs page.
  77. self::assertStringContainsString('203.0.113.42', $body);
  78. self::assertStringContainsString('/app/ips?q=203.0.113', $body);
  79. // Manual block row that matched the query.
  80. self::assertStringContainsString('203.0.113.0/24', $body);
  81. // Unrelated manual-block entry must not render.
  82. self::assertStringNotContainsString('198.51.100.7', $body);
  83. // Section links to the index pages.
  84. self::assertStringContainsString('href="/app/manual-blocks"', $body);
  85. self::assertStringContainsString('href="/app/allowlist"', $body);
  86. }
  87. public function testHandlesPartialApiFailureGracefully(): void
  88. {
  89. // IPs succeeds, manual-blocks 500s (twice — ApiClient retries on 5xx),
  90. // allowlist succeeds.
  91. $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 25, 'total' => 0, 'items' => []]);
  92. $this->enqueueApiResponse(500, ['error' => 'boom']);
  93. $this->enqueueApiResponse(500, ['error' => 'boom']);
  94. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  95. $resp = $this->request('GET', '/app/search?q=10.0.0.1');
  96. self::assertSame(200, $resp->getStatusCode());
  97. $body = (string) $resp->getBody();
  98. self::assertStringContainsString('Failed to load manual blocks', $body);
  99. // The other sections still render their no-results state.
  100. self::assertStringContainsString('No IPs match this query.', $body);
  101. self::assertStringContainsString('No allowlist entries match this query.', $body);
  102. }
  103. public function testRedirectsAnonymousToLogin(): void
  104. {
  105. $_SESSION = [];
  106. $resp = $this->request('GET', '/app/search?q=foo');
  107. self::assertSame(302, $resp->getStatusCode());
  108. self::assertSame('/login', $resp->getHeaderLine('Location'));
  109. }
  110. }