|
|
@@ -0,0 +1,127 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Integration\Auth;
|
|
|
+
|
|
|
+use App\Auth\UserContext;
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SEC_REVIEW F42 — `/app/policies/{id}/preview-proxy` and
|
|
|
+ * `/app/policies/{id}/score-distribution-proxy` defence-in-depth role
|
|
|
+ * enforcement. The api gates the underlying endpoints to Viewer-or-
|
|
|
+ * higher; this test class proves the UI proxy enforces the same
|
|
|
+ * expectation locally so a `none`-role session that somehow reached
|
|
|
+ * the protected route group cannot use the proxy as a probe channel.
|
|
|
+ */
|
|
|
+final class PoliciesProxyTest extends AppTestCase
|
|
|
+{
|
|
|
+ protected function setUp(): void
|
|
|
+ {
|
|
|
+ $this->bootApp();
|
|
|
+ // First /app/* request in a process triggers session_start();
|
|
|
+ // hit a public route first so subsequent $_SESSION priming sticks.
|
|
|
+ $this->request('GET', '/healthz');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyAnonymousIsCaughtByAuthRequiredMiddleware(): void
|
|
|
+ {
|
|
|
+ // The proxy lives under /app/*, so AuthRequiredMiddleware
|
|
|
+ // intercepts an anonymous request and redirects to /login
|
|
|
+ // before the controller runs. The controller's own 401
|
|
|
+ // branch is a defence-in-depth fallback for any future
|
|
|
+ // route reshuffle that takes the proxy out of the /app/*
|
|
|
+ // group.
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+ self::assertSame(302, $response->getStatusCode());
|
|
|
+ self::assertSame('/login', $response->getHeaderLine('Location'));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyRejectsNoneRoleWith403(): void
|
|
|
+ {
|
|
|
+ // SEC_REVIEW F42: a session with role='none' (or empty) must NOT
|
|
|
+ // reach the api via the proxy. AuthRequiredMiddleware lets the
|
|
|
+ // request through (a non-null user is present), so the controller
|
|
|
+ // is the gate.
|
|
|
+ $this->seedUser(role: 'none');
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
|
|
|
+ $body = json_decode((string) $response->getBody(), true);
|
|
|
+ self::assertSame('forbidden', $body['error'] ?? null);
|
|
|
+ // Critically: the api must NOT have been called.
|
|
|
+ self::assertSame([], $this->apiHistory);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testScoreDistributionProxyRejectsNoneRoleWith403(): void
|
|
|
+ {
|
|
|
+ $this->seedUser(role: 'none');
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/score-distribution-proxy');
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame([], $this->apiHistory);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyAllowsViewer(): void
|
|
|
+ {
|
|
|
+ $this->seedUser(role: 'viewer');
|
|
|
+ $this->enqueueApiResponse(200, ['blocked' => 12, 'allowed' => 88]);
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
+ self::assertCount(1, $this->apiHistory);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyAllowsOperatorAndAdmin(): void
|
|
|
+ {
|
|
|
+ // Cover both remaining "allowed" roles in one test by issuing
|
|
|
+ // two requests against fresh sessions.
|
|
|
+ foreach (['operator', 'admin'] as $role) {
|
|
|
+ $_SESSION = [];
|
|
|
+ $this->bootApp();
|
|
|
+ $this->request('GET', '/healthz');
|
|
|
+ $this->seedUser(role: $role);
|
|
|
+ $this->enqueueApiResponse(200, ['ok' => true]);
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+
|
|
|
+ self::assertSame(200, $response->getStatusCode(), "role {$role} should be allowed");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyRejectsUnknownRoleWith403(): void
|
|
|
+ {
|
|
|
+ // A drifted/poisoned session role string the UI doesn't recognise
|
|
|
+ // must fail closed, not silently forward. Defence-in-depth even
|
|
|
+ // though the api would 403 anyway.
|
|
|
+ $this->seedUser(role: 'definitely-not-a-real-role');
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame([], $this->apiHistory);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testPreviewProxyEmptyRoleIsRejected(): void
|
|
|
+ {
|
|
|
+ $this->seedUser(role: '');
|
|
|
+
|
|
|
+ $response = $this->request('GET', '/app/policies/1/preview-proxy');
|
|
|
+
|
|
|
+ self::assertSame(403, $response->getStatusCode());
|
|
|
+ self::assertSame([], $this->apiHistory);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function seedUser(string $role): void
|
|
|
+ {
|
|
|
+ $_SESSION['_user'] = (new UserContext(1, 'Test', $role, null, UserContext::SOURCE_LOCAL))->toArray();
|
|
|
+ $_SESSION['_last_active'] = time();
|
|
|
+ $_SESSION['_authenticated_at'] = time();
|
|
|
+ $_SESSION['_revalidated_at'] = time();
|
|
|
+ }
|
|
|
+}
|