| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Admin;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Ip\IpAddress;
- use App\Tests\Integration\Support\AppTestCase;
- use Doctrine\DBAL\ParameterType;
- /**
- * Covers SPEC §M10.1: categories CRUD + in-use refusal on delete.
- */
- final class CategoriesControllerTest extends AppTestCase
- {
- public function testListReturnsSeededCategories(): void
- {
- $token = $this->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());
- }
- }
|