createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/categories', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertGreaterThanOrEqual(5, $body['total']); $slugs = array_map(static fn (array $r): string => $r['slug'], $body['items']); self::assertContains('brute_force', $slugs); } public function testCreateRejectsBadSlug(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'BAD-Slug', 'name' => 'X', 'decay_function' => 'exponential', 'decay_param' => 14, ]), ); self::assertSame(400, $response->getStatusCode()); $details = $this->decode($response)['details']; self::assertArrayHasKey('slug', $details); } public function testCreateAndShow(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'phishing', 'name' => 'Phishing', 'description' => 'fake-domain reports', 'decay_function' => 'exponential', 'decay_param' => 14, 'is_active' => true, ]), ); self::assertSame(201, $response->getStatusCode()); $created = $this->decode($response); self::assertSame('phishing', $created['slug']); $get = $this->request('GET', '/api/v1/admin/categories/' . $created['id'], [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $get->getStatusCode()); self::assertSame('phishing', $this->decode($get)['slug']); } public function testCreateRejectsDuplicateSlug(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'brute_force', // already seeded 'name' => 'Dup', 'decay_function' => 'linear', 'decay_param' => 30, ]), ); self::assertSame(400, $response->getStatusCode()); self::assertArrayHasKey('slug', $this->decode($response)['details']); } public function testPatchUpdatesFields(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'spam']); $response = $this->request( 'PATCH', '/api/v1/admin/categories/' . $catId, ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode(['name' => 'Spam (renamed)', 'is_active' => false]), ); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('Spam (renamed)', $body['name']); self::assertFalse($body['is_active']); } public function testDeleteRefusedWhenReferencedByPolicy(): void { // Seeded policies reference all five seeded categories, so any // seeded category will trip the in-use guard. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']); $response = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(409, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('category_in_use', $body['error']); self::assertGreaterThan(0, $body['usage']['policies']); } public function testDeleteRefusedWhenReferencedByReports(): void { // Create a fresh category, attach a single report, then try to // delete it. Policy refs are zero (we didn't add it to any // policy); only the report ref blocks the delete. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $createResp = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'tmp_only', 'name' => 'Tmp', 'decay_function' => 'linear', 'decay_param' => 5, ]), ); $catId = (int) $this->decode($createResp)['id']; $reporterId = $this->createReporter('rep-cat-test'); $stmt = $this->db->prepare( 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at) ' . 'VALUES (:b, :t, :c, :r, :w, :now)' ); $stmt->bindValue('b', IpAddress::fromString('203.0.113.5')->binary(), ParameterType::LARGE_OBJECT); $stmt->bindValue('t', '203.0.113.5'); $stmt->bindValue('c', $catId, ParameterType::INTEGER); $stmt->bindValue('r', $reporterId, ParameterType::INTEGER); $stmt->bindValue('w', '1.00'); $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s')); $stmt->executeStatement(); $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(409, $delete->getStatusCode()); $body = $this->decode($delete); self::assertSame('category_in_use', $body['error']); self::assertSame(0, $body['usage']['policies']); self::assertGreaterThan(0, $body['usage']['reports']); } public function testDeleteSucceedsWhenNotInUse(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $createResp = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'orphan', 'name' => 'Orphan', 'decay_function' => 'linear', 'decay_param' => 5, ]), ); $catId = (int) $this->decode($createResp)['id']; $delete = $this->request('DELETE', '/api/v1/admin/categories/' . $catId, [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(204, $delete->getStatusCode()); } public function testNonAdminCannotCreate(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request( 'POST', '/api/v1/admin/categories', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], (string) json_encode([ 'slug' => 'x', 'name' => 'X', 'decay_function' => 'linear', 'decay_param' => 5, ]), ); self::assertSame(403, $response->getStatusCode()); } }