createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/policies', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame(3, $body['total']); // strict, moderate, paranoid $names = array_map(static fn (array $p): string => $p['name'], $body['items']); self::assertContains('strict', $names); self::assertContains('moderate', $names); self::assertContains('paranoid', $names); } public function testShowIncludesThresholdsKeyedBySlug(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']); $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('moderate', $body['name']); self::assertNotEmpty($body['thresholds']); $slugs = array_map(static fn (array $t): string => $t['category_slug'], $body['thresholds']); self::assertContains('brute_force', $slugs); } public function testViewerCannotCreate(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode([ 'name' => 'aggressive', 'thresholds' => ['brute_force' => 0.5], ]) ?: null, ); self::assertSame(403, $response->getStatusCode()); } public function testAdminCanCreatePolicyWithThresholds(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode([ 'name' => 'aggressive', 'description' => 'block everything', 'include_manual_blocks' => true, 'thresholds' => [ 'brute_force' => 0.1, 'spam' => 0.2, ], ]) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('aggressive', $body['name']); self::assertCount(2, $body['thresholds']); } public function testAdminCanCreatePolicyWithoutThresholds(): void { // The UI's create form deliberately omits the threshold matrix // ("configure on edit page after creation"). A policy with zero // thresholds is valid per SPEC §4 (absent row = category not // considered) and may still emit manual blocks. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'empty', 'include_manual_blocks' => true]) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('empty', $body['name']); self::assertSame([], $body['thresholds']); } public function testCreateRejectsDuplicateName(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'moderate', 'thresholds' => ['brute_force' => 1.0]]) ?: null, ); self::assertSame(400, $response->getStatusCode()); $details = $this->decode($response)['details']; self::assertArrayHasKey('name', $details); } public function testCreateRejectsUnknownCategorySlug(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'bogus', 'thresholds' => ['unknown_slug' => 1.0]]) ?: null, ); self::assertSame(400, $response->getStatusCode()); $details = $this->decode($response)['details']; self::assertStringContainsString('unknown_slug', (string) $details['thresholds']); } public function testPatchReplacesThresholdsWholesale(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']); $response = $this->request( 'PATCH', "/api/v1/admin/policies/{$policyId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['thresholds' => ['brute_force' => 5.0]]) ?: null, ); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertCount(1, $body['thresholds']); self::assertSame('brute_force', $body['thresholds'][0]['category_slug']); self::assertEqualsWithDelta(5.0, $body['thresholds'][0]['threshold'], 0.0001); } public function testDeleteWithReferencingConsumerReturns409(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'moderate']); $this->db->insert('consumers', [ 'name' => 'fw-edge', 'policy_id' => $policyId, 'is_active' => 1, ]); $response = $this->request('DELETE', "/api/v1/admin/policies/{$policyId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(409, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('policy_in_use', $body['error']); self::assertNotEmpty($body['consumers']); } public function testDeleteSucceedsWithoutReferences(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $createResp = $this->request( 'POST', '/api/v1/admin/policies', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'tmp', 'thresholds' => ['brute_force' => 1.0]]) ?: null, ); $newId = (int) $this->decode($createResp)['id']; $deleteResp = $this->request('DELETE', "/api/v1/admin/policies/{$newId}", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(204, $deleteResp->getStatusCode()); } public function testPreviewReturnsCountSampleAndPolicyName(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => 'paranoid']); $response = $this->request('GET', "/api/v1/admin/policies/{$policyId}/preview", [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertArrayHasKey('count', $body); self::assertArrayHasKey('sample', $body); self::assertSame('paranoid', $body['policy']); self::assertIsArray($body['sample']); } }