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(); } }