createToken(TokenKind::Service); $issuer = $this->createUser(Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $service, 'X-Acting-User-Id' => (string) $issuer, 'Content-Type' => 'application/json', ], json_encode(['kind' => 'admin', 'role' => 'operator']) ?: null, ); self::assertSame(201, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame($issuer, (int) $body['user_id']); // The audit row carries the issuer id too — so a SOC reader can // attribute the mint without joining api_tokens. $auditRow = $this->db->fetchAssociative( "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($auditRow); $details = json_decode((string) $auditRow['details_json'], true); self::assertIsArray($details); self::assertSame($issuer, (int) $details['user_id']); } public function testReporterTokenCreatedViaApiIsNotBoundToUser(): void { $service = $this->createToken(TokenKind::Service); $issuer = $this->createUser(Role::Admin); $reporterId = $this->createReporter('rep-bind-check'); $resp = $this->request( 'POST', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $service, 'X-Acting-User-Id' => (string) $issuer, 'Content-Type' => 'application/json', ], json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null, ); self::assertSame(201, $resp->getStatusCode()); $body = $this->decode($resp); self::assertArrayHasKey('user_id', $body); self::assertNull($body['user_id']); } public function testListSurfacesIssuerLabel(): void { $service = $this->createToken(TokenKind::Service); $issuer = $this->createUser(Role::Admin); $this->db->update('users', ['display_name' => 'Carol Admin'], ['id' => $issuer]); // Mint via the API so the binding actually fires. $this->request( 'POST', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $service, 'X-Acting-User-Id' => (string) $issuer, 'Content-Type' => 'application/json', ], json_encode(['kind' => 'admin', 'role' => 'viewer']) ?: null, ); $list = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $service, 'X-Acting-User-Id' => (string) $issuer, ]); self::assertSame(200, $list->getStatusCode()); $rows = $this->decode($list)['data']; $minted = null; foreach ($rows as $row) { if (($row['kind'] ?? null) === 'admin' && ($row['role'] ?? null) === 'viewer') { $minted = $row; break; } } self::assertIsArray($minted, 'minted admin row not present in list'); self::assertSame($issuer, (int) $minted['user_id']); self::assertSame('Carol Admin', $minted['user_label']); } public function testBoundAdminTokenAuthenticatesWhileIssuerActive(): void { $issuer = $this->createUser(Role::Admin); $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer); // Pick any admin-only endpoint that returns 200 for an Admin role. $resp = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $resp->getStatusCode()); } public function testBoundAdminTokenIsRejectedAfterIssuerDisabled(): void { $issuer = $this->createUser(Role::Admin); $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer); $this->db->update( 'users', ['disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s')], ['id' => $issuer], ); $resp = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(401, $resp->getStatusCode()); self::assertSame('unauthorized', $this->decode($resp)['error']); } public function testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole(): void { $issuer = $this->createUser(Role::Admin); // Token grants Admin to whoever holds it. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer); // Issuer demoted to Viewer — the admin-grant token must stop working. $this->db->update('users', ['role' => Role::Viewer->value], ['id' => $issuer]); $resp = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(401, $resp->getStatusCode()); } public function testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole(): void { $issuer = $this->createUser(Role::Admin); // Token grants only Viewer; issuer remains Admin (>= Viewer) → token works. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer, userId: $issuer); // Endpoint requires Viewer — admin/jobs/status is Viewer-tier. $resp = $this->request('GET', '/api/v1/admin/jobs/status', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $resp->getStatusCode()); } public function testBoundAdminTokenIsRejectedIfIssuerRowIsGone(): void { $issuer = $this->createUser(Role::Admin); $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: $issuer); // Simulate a hard delete: the issuer row is removed but the token // row points at the (now-orphaned) user_id. On MySQL the FK CASCADE // would kill the token row first; on SQLite the application-layer // user lookup returns null and the middleware refuses the token. $this->db->delete('users', ['id' => $issuer]); $resp = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(401, $resp->getStatusCode()); } public function testLegacyUnboundAdminTokenStillAuthenticates(): void { // Admin token created without any user_id — legacy / console-issued. // F16 grandfathers these so existing deployments keep working; // operators rotate them after they redeploy. $token = $this->createToken(TokenKind::Admin, role: Role::Admin, userId: null); $resp = $this->request('GET', '/api/v1/admin/tokens', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $resp->getStatusCode()); } }