createToken(TokenKind::Admin, Role::Operator); $resp = $this->request( 'POST', '/api/v1/admin/maintenance/purge', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "PURGE"}', ); self::assertSame(403, $resp->getStatusCode()); } public function testPurgeRequiresLiteralConfirmString(): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/maintenance/purge', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "yes"}', ); self::assertSame(400, $resp->getStatusCode()); self::assertSame('validation_failed', $this->decode($resp)['error']); } public function testPurgeWipesOperationalDataButPreservesServiceToken(): void { // Seed a service token (bypassing the controller; the API protects it). $this->db->insert('api_tokens', [ 'kind' => TokenKind::Service->value, 'token_hash' => str_repeat('a', 64), 'token_prefix' => 'irdb_svc', ]); // And one operational token, plus a reporter and a stray report. $reporterId = $this->createReporter('purge-victim'); $catRow = $this->db->fetchAssociative('SELECT id FROM categories LIMIT 1'); self::assertNotFalse($catRow); $this->db->insert('reports', [ 'ip_bin' => str_repeat("\x00", 12) . "\x00\x00\x00\x01", 'ip_text' => '0.0.0.1', 'category_id' => (int) $catRow['id'], 'reporter_id' => $reporterId, 'weight_at_report' => '1.00', ]); $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/maintenance/purge', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "PURGE"}', ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('purged', $body['status']); self::assertGreaterThanOrEqual(1, (int) $body['deleted']['reports']); // Service token survives, the admin token does not. $svcCount = (int) $this->db->fetchOne( 'SELECT COUNT(*) FROM api_tokens WHERE kind = :k', ['k' => TokenKind::Service->value], ); self::assertSame(1, $svcCount); $allTokens = (int) $this->db->fetchOne('SELECT COUNT(*) FROM api_tokens'); self::assertSame(1, $allTokens, 'only the service token should remain'); // Reporters / reports gone. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters')); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports')); // Categories preserved. self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories')); } public function testSeedDemoPopulatesDataAndIsIdempotent(): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); $first = $this->request( 'POST', '/api/v1/admin/maintenance/seed-demo', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "SEED"}', ); self::assertSame(200, $first->getStatusCode()); $body = $this->decode($first); self::assertSame('seeded', $body['status']); self::assertGreaterThan(0, (int) $body['summary']['reporters']); self::assertGreaterThan(0, (int) $body['summary']['ips']); self::assertGreaterThan(0, (int) $body['summary']['reports']); // Real rows landed. self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters')); self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports')); // Second call short-circuits with 409. $second = $this->request( 'POST', '/api/v1/admin/maintenance/seed-demo', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "SEED"}', ); self::assertSame(409, $second->getStatusCode()); self::assertSame('already_seeded', $this->decode($second)['error']); } public function testSeedDemoForbiddenForViewer(): void { $token = $this->createToken(TokenKind::Admin, Role::Viewer); $resp = $this->request( 'POST', '/api/v1/admin/maintenance/seed-demo', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "SEED"}', ); self::assertSame(403, $resp->getStatusCode()); } /** * SEC_REVIEW F15: a single drive-by POST with no body must not load the * demo dataset. The API requires `confirm: "SEED"` symmetric with * `purge`'s `"PURGE"` gate. */ public function testSeedDemoRequiresLiteralConfirmString(): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); // No body at all. $bare = $this->request( 'POST', '/api/v1/admin/maintenance/seed-demo', ['Authorization' => 'Bearer ' . $token], ); self::assertSame(400, $bare->getStatusCode()); self::assertSame('validation_failed', $this->decode($bare)['error']); // Wrong literal. $wrong = $this->request( 'POST', '/api/v1/admin/maintenance/seed-demo', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{"confirm": "yes"}', ); self::assertSame(400, $wrong->getStatusCode()); self::assertSame('validation_failed', $this->decode($wrong)['error']); // Nothing landed. self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters')); self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports')); } }