request('GET', '/api/v1/admin/jobs/status'); self::assertSame(401, $resp->getStatusCode()); } public function testStatusReturnsAllJobs(): void { $token = $this->createToken(TokenKind::Admin, Role::Viewer); $resp = $this->request('GET', '/api/v1/admin/jobs/status', ['Authorization' => 'Bearer ' . $token]); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertArrayHasKey('jobs', $body); foreach (['recompute-scores', 'cleanup-audit', 'enrich-pending', 'refresh-geoip', 'tick'] as $name) { self::assertArrayHasKey($name, $body['jobs']); } } public function testTriggerOperatorForbidden(): void { $token = $this->createToken(TokenKind::Admin, Role::Operator); $resp = $this->request( 'POST', '/api/v1/admin/jobs/trigger/recompute-scores', ['Authorization' => 'Bearer ' . $token], ); self::assertSame(403, $resp->getStatusCode()); } public function testTriggerUnknownJobReturns404(): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/jobs/trigger/does-not-exist', ['Authorization' => 'Bearer ' . $token], ); self::assertSame(404, $resp->getStatusCode()); } /** * @return iterable */ public static function malformedJobNames(): iterable { // SEC_REVIEW F44: any name outside `^[a-z0-9_-]+$` must 404 // BEFORE flowing into `registry->has()` or the `job.triggered` // audit row. The Slim default segment regex `[^/]+` only // protects against `/`; everything else (uppercase, dots, // spaces, control chars, brackets) needs the controller gate. yield 'uppercase' => ['Recompute-Scores']; yield 'dotted' => ['recompute.scores']; yield 'space' => ['recompute scores']; yield 'newline injection' => ["recompute\nfaked"]; yield 'cr injection' => ["recompute\rfaked"]; yield 'bracket' => ['recompute[scores]']; yield 'percent' => ['recompute%20scores']; yield 'empty after url decode' => ['..']; } #[\PHPUnit\Framework\Attributes\DataProvider('malformedJobNames')] public function testTriggerRejectsMalformedJobName(string $name): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/jobs/trigger/' . rawurlencode($name), ['Authorization' => 'Bearer ' . $token], ); self::assertSame(404, $resp->getStatusCode(), "expected 404 for malformed name '{$name}'"); // Critically: no audit row is emitted for the malformed name. $audit = $this->db->fetchOne( "SELECT COUNT(*) FROM audit_log WHERE action = 'job.triggered'" ); self::assertSame(0, (int) $audit, "no audit row for malformed name '{$name}'"); } public function testTriggerRecomputeRunsAndAuditsAsManual(): void { $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/jobs/trigger/recompute-scores', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], '{}', ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('recompute-scores', $body['job']); self::assertSame('success', $body['status']); // job_runs.triggered_by = manual $row = $this->db->fetchAssociative( "SELECT triggered_by FROM job_runs WHERE job_name = 'recompute-scores' ORDER BY id DESC LIMIT 1" ); self::assertSame('manual', $row['triggered_by']); // Audit row attributed to the admin token $audit = $this->db->fetchAssociative( "SELECT actor_kind, action, target_id, details_json FROM audit_log WHERE action = 'job.triggered' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($audit); self::assertSame('admin-token', $audit['actor_kind']); self::assertSame('recompute-scores', $audit['target_id']); $details = json_decode((string) $audit['details_json'], true); self::assertSame('manual', $details['triggered_by']); } public function testRefreshGeoip412UnderMaxmindWithoutKey(): void { // Swap the downloader binding to MaxMind without a key. if (method_exists($this->container, 'set')) { /** @var \DI\Container $c */ $c = $this->container; $c->set( \App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader::class, new \App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader(new \GuzzleHttp\Client(), licenseKey: ''), ); $c->set( \App\Application\Admin\JobsAdminController::class, $c->make(\App\Application\Admin\JobsAdminController::class), ); $this->app = \App\App\AppFactory::build($this->container); } $token = $this->createToken(TokenKind::Admin, Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/jobs/trigger/refresh-geoip', ['Authorization' => 'Bearer ' . $token], ); self::assertSame(412, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('no_credential', $body['error']); self::assertSame('maxmind', $body['provider']); self::assertSame('MAXMIND_LICENSE_KEY', $body['missing']); } }