PoliciesProxyTest.php 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Auth\UserContext;
  5. use App\Tests\Integration\Support\AppTestCase;
  6. /**
  7. * SEC_REVIEW F42 — `/app/policies/{id}/preview-proxy` and
  8. * `/app/policies/{id}/score-distribution-proxy` defence-in-depth role
  9. * enforcement. The api gates the underlying endpoints to Viewer-or-
  10. * higher; this test class proves the UI proxy enforces the same
  11. * expectation locally so a `none`-role session that somehow reached
  12. * the protected route group cannot use the proxy as a probe channel.
  13. */
  14. final class PoliciesProxyTest extends AppTestCase
  15. {
  16. protected function setUp(): void
  17. {
  18. $this->bootApp();
  19. // First /app/* request in a process triggers session_start();
  20. // hit a public route first so subsequent $_SESSION priming sticks.
  21. $this->request('GET', '/healthz');
  22. }
  23. public function testPreviewProxyAnonymousIsCaughtByAuthRequiredMiddleware(): void
  24. {
  25. // The proxy lives under /app/*, so AuthRequiredMiddleware
  26. // intercepts an anonymous request and redirects to /login
  27. // before the controller runs. The controller's own 401
  28. // branch is a defence-in-depth fallback for any future
  29. // route reshuffle that takes the proxy out of the /app/*
  30. // group.
  31. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  32. self::assertSame(302, $response->getStatusCode());
  33. self::assertSame('/login', $response->getHeaderLine('Location'));
  34. }
  35. public function testPreviewProxyRejectsNoneRoleWith403(): void
  36. {
  37. // SEC_REVIEW F42: a session with role='none' (or empty) must NOT
  38. // reach the api via the proxy. AuthRequiredMiddleware lets the
  39. // request through (a non-null user is present), so the controller
  40. // is the gate.
  41. $this->seedUser(role: 'none');
  42. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  43. self::assertSame(403, $response->getStatusCode());
  44. self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
  45. $body = json_decode((string) $response->getBody(), true);
  46. self::assertSame('forbidden', $body['error'] ?? null);
  47. // Critically: the api must NOT have been called.
  48. self::assertSame([], $this->apiHistory);
  49. }
  50. public function testScoreDistributionProxyRejectsNoneRoleWith403(): void
  51. {
  52. $this->seedUser(role: 'none');
  53. $response = $this->request('GET', '/app/policies/1/score-distribution-proxy');
  54. self::assertSame(403, $response->getStatusCode());
  55. self::assertSame([], $this->apiHistory);
  56. }
  57. public function testPreviewProxyAllowsViewer(): void
  58. {
  59. $this->seedUser(role: 'viewer');
  60. $this->enqueueApiResponse(200, ['blocked' => 12, 'allowed' => 88]);
  61. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  62. self::assertSame(200, $response->getStatusCode());
  63. self::assertCount(1, $this->apiHistory);
  64. }
  65. public function testPreviewProxyAllowsOperatorAndAdmin(): void
  66. {
  67. // Cover both remaining "allowed" roles in one test by issuing
  68. // two requests against fresh sessions.
  69. foreach (['operator', 'admin'] as $role) {
  70. $_SESSION = [];
  71. $this->bootApp();
  72. $this->request('GET', '/healthz');
  73. $this->seedUser(role: $role);
  74. $this->enqueueApiResponse(200, ['ok' => true]);
  75. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  76. self::assertSame(200, $response->getStatusCode(), "role {$role} should be allowed");
  77. }
  78. }
  79. public function testPreviewProxyRejectsUnknownRoleWith403(): void
  80. {
  81. // A drifted/poisoned session role string the UI doesn't recognise
  82. // must fail closed, not silently forward. Defence-in-depth even
  83. // though the api would 403 anyway.
  84. $this->seedUser(role: 'definitely-not-a-real-role');
  85. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  86. self::assertSame(403, $response->getStatusCode());
  87. self::assertSame([], $this->apiHistory);
  88. }
  89. public function testPreviewProxyEmptyRoleIsRejected(): void
  90. {
  91. $this->seedUser(role: '');
  92. $response = $this->request('GET', '/app/policies/1/preview-proxy');
  93. self::assertSame(403, $response->getStatusCode());
  94. self::assertSame([], $this->apiHistory);
  95. }
  96. private function seedUser(string $role): void
  97. {
  98. $_SESSION['_user'] = (new UserContext(1, 'Test', $role, null, UserContext::SOURCE_LOCAL))->toArray();
  99. $_SESSION['_last_active'] = time();
  100. $_SESSION['_authenticated_at'] = time();
  101. $_SESSION['_revalidated_at'] = time();
  102. }
  103. }