feat: add local client API key import export

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-12 17:18:33 +08:00
co-authored by Codex
parent a408195c41
commit 5393a67f6b
7 changed files with 610 additions and 11 deletions
+86
View File
@@ -238,6 +238,92 @@ Success response:
}
```
### `GET /admin/api-keys/export`
Exports Rust-local client API key metadata for backup or migration. The export never includes plaintext keys, key hashes, or the raw auth pepper. Because `cpr_` keys are stored only as peppered HMAC hashes, imports rotate keys instead of preserving the original plaintext.
Optional query:
```http
GET /admin/api-keys/export?ids=key_01,key_02
```
Success response:
```json
{
"code": 200,
"message": "OK",
"data": {
"sourceFormat": "rustLocalClientApiKeys",
"rotationRequired": true,
"apiKeys": [
{
"id": "key_01",
"name": "cursor",
"label": "automation",
"prefix": "cpr_xxxxxxxx",
"enabled": true,
"createdAt": "2026-06-11T12:00:00Z",
"lastUsedAt": null
}
]
},
"requestId": "req_01"
}
```
### `POST /admin/api-keys/import`
Imports Rust-local client API key metadata exported by this service. Import creates new local `cpr_` keys using the target instance's pepper and returns the new plaintext values only in this response. Clients must replace the old keys with these rotated plaintext values.
Request:
```json
{
"sourceFormat": "rustLocalClientApiKeys",
"rotationRequired": true,
"apiKeys": [
{
"id": "key_01",
"name": "cursor",
"label": "automation",
"prefix": "cpr_xxxxxxxx",
"enabled": true
}
]
}
```
Success response:
```json
{
"code": 200,
"message": "OK",
"data": {
"imported": 1,
"skipped": 0,
"rotated": true,
"keys": [
{
"sourceId": "key_01",
"sourcePrefix": "cpr_xxxxxxxx",
"id": "key_02",
"name": "cursor",
"label": "automation",
"prefix": "cpr_yyyyyyyy",
"enabled": true,
"createdAt": "2026-06-11T12:05:00Z",
"lastUsedAt": null,
"plaintext": "cpr_new_full_key_returned_once"
}
]
},
"requestId": "req_01"
}
```
### `GET /admin/accounts`
Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned.
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ This audit is the correction point against the TypeScript reference at `/home/zy
| Manual account creation / `16938e6` + current correction | `POST /auth/accounts` accepts only `token` and/or `refreshToken`, runs `AccountImportService.importOne`, exchanges refresh-token-only imports, derives account identity/profile from JWT claims, updates an existing `chatgpt_account_id` + `chatgpt_user_id` entry, and never returns tokens. | `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects missing/invalid/expired JWTs and tokens without `https://api.openai.com/auth.chatgpt_account_id`, derives email/accountId/userId/planType/expiresAt from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only imports with `AppState`'s OpenAI refresher, preserves or rotates refresh tokens, encrypts secrets, and synchronizes the runtime account pool. It does not yet cache quota from a verification probe. | Corrected scoped parity. | Keep. Finish quota fetch/health/login variants separately without adding proxy pools or non-OpenAI provider support. |
| Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, Anthropic, Gemini, OpenRouter/custom, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. |
| Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. |
| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. | Intentional scope cut. | Do not port TS provider key import/export. Document that local client keys cannot be safely exported after creation. |
| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. Local `/admin/api-keys/export` exports only metadata, and `/admin/api-keys/import` rotates metadata into new target-local `cpr_` keys with plaintext returned once. | Rust local extension, intentional provider scope cut. | Keep local metadata export/import as client auth utility only. Do not port TS provider key import/export, provider/model binding, proxy pools, or non-OpenAI provider behavior. |
| Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. |
| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, admin quota fetch, health check, per-account refresh, and reset-usage for imported accounts. Rust still lacks OAuth PKCE login-start/code-relay/callback, device login/poll, and logout/auth status equivalents. | Partial OpenAI/Codex parity. | Continue with login/status variants without adding proxy pools or non-OpenAI providers. |
| Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, Anthropic/Gemini/Ollama/custom provider support, or Electron/frontend flows. |
@@ -157,6 +157,9 @@ Immediate priority after this audit:
- [x] Write failing tests for local client API key label and batch-delete mutations.
- [x] Implement local client API key label and batch-delete mutations without third-party provider key pools.
- [x] Run: `cargo test --test admin_api_keys_route_test`
- [x] Write failing tests for local client API key metadata export and rotation import.
- [x] Implement local client API key metadata export/import without plaintext export, hash export, pepper export, or third-party provider key pools.
- [x] Run: `cargo test --test admin_api_keys_route_test admin_api_keys_export_should_return_metadata_without_secret_material`; `cargo test --test admin_api_keys_route_test admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once`
- [x] Write failing tests for account Cookie get/set/delete routes.
- [x] Implement encrypted account Cookie get/set/delete routes.
- [x] Run: `cargo test --test admin_accounts_route_test`
+7 -5
View File
@@ -10,11 +10,11 @@ use crate::{
admin::{
account_quota, accounts, api_keys, batch_delete_accounts, batch_delete_api_keys,
batch_update_account_status, create_account, create_api_key, delete_account,
delete_account_cookies, delete_api_key, export_accounts, get_account_cookies,
health_check_accounts, import_accounts, import_cli_auth, login, logs, refresh_account,
refresh_models, reset_account_usage, set_account_cookies, settings,
update_account_label, update_account_status, update_api_key_label,
update_api_key_status, usage_stats, usage_stats_summary,
delete_account_cookies, delete_api_key, export_accounts, export_api_keys,
get_account_cookies, health_check_accounts, import_accounts, import_api_keys,
import_cli_auth, login, logs, refresh_account, refresh_models, reset_account_usage,
set_account_cookies, settings, update_account_label, update_account_status,
update_api_key_label, update_api_key_status, usage_stats, usage_stats_summary,
},
health::health,
middleware::attach_request_id,
@@ -77,6 +77,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/admin/accounts/import", post(import_accounts))
.route("/admin/accounts/import-cli", post(import_cli_auth))
.route("/admin/api-keys", get(api_keys).post(create_api_key))
.route("/admin/api-keys/export", get(export_api_keys))
.route("/admin/api-keys/import", post(import_api_keys))
.route("/admin/api-keys/batch-delete", post(batch_delete_api_keys))
.route("/admin/api-keys/{key_id}", delete(delete_api_key))
.route(
+25 -3
View File
@@ -45,25 +45,38 @@ impl ClientApiKeyRepository {
&self,
name: &str,
generated: &GeneratedClientApiKey,
) -> ClientApiKeyRepositoryResult<StoredClientApiKey> {
self.insert_generated_with_metadata(name, None, true, generated)
.await
}
pub async fn insert_generated_with_metadata(
&self,
name: &str,
label: Option<&str>,
enabled: bool,
generated: &GeneratedClientApiKey,
) -> ClientApiKeyRepositoryResult<StoredClientApiKey> {
let id = format!("key_{}", Uuid::new_v4().simple());
let now = Utc::now().to_rfc3339();
sqlx::query(
"insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, 1, ?, null)",
"insert into client_api_keys (id, name, label, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, ?, ?, ?, null)",
)
.bind(&id)
.bind(name)
.bind(label)
.bind(&generated.prefix)
.bind(&generated.key_hash)
.bind(if enabled { 1_i64 } else { 0_i64 })
.bind(&now)
.execute(&self.pool)
.await?;
Ok(StoredClientApiKey {
id,
name: name.to_string(),
label: None,
label: label.map(ToString::to_string),
prefix: generated.prefix.clone(),
enabled: true,
enabled,
created_at: now,
last_used_at: None,
})
@@ -123,6 +136,15 @@ impl ClientApiKeyRepository {
Ok(row.map(|row| key_from_row(&row)))
}
pub async fn list_all(&self) -> ClientApiKeyRepositoryResult<Vec<StoredClientApiKey>> {
let rows = sqlx::query(
"select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| key_from_row(&row)).collect())
}
pub async fn verify_and_touch(
&self,
plaintext: &str,
+334
View File
@@ -166,6 +166,12 @@ pub struct ApiKeysQuery {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiKeyExportQuery {
pub ids: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageStatsQuery {
@@ -476,6 +482,59 @@ pub struct DeleteClientApiKeyData {
pub deleted: bool,
}
#[derive(Debug, Clone)]
struct ClientApiKeyImportEntry {
pub source_id: Option<String>,
pub source_prefix: Option<String>,
pub name: String,
pub label: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyExportData {
pub source_format: &'static str,
pub rotation_required: bool,
pub api_keys: Vec<ClientApiKeyExportEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyExportEntry {
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientApiKeyImportData {
pub imported: u32,
pub skipped: u32,
pub rotated: bool,
pub keys: Vec<ImportedClientApiKeyData>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedClientApiKeyData {
pub source_id: Option<String>,
pub source_prefix: Option<String>,
pub id: String,
pub name: String,
pub label: Option<String>,
pub prefix: String,
pub enabled: bool,
pub created_at: String,
pub last_used_at: Option<String>,
pub plaintext: String,
}
impl From<StoredClientApiKey> for ClientApiKeyData {
fn from(key: StoredClientApiKey) -> Self {
Self {
@@ -490,6 +549,20 @@ impl From<StoredClientApiKey> for ClientApiKeyData {
}
}
impl From<StoredClientApiKey> for ClientApiKeyExportEntry {
fn from(key: StoredClientApiKey) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
}
}
}
impl CreatedClientApiKeyData {
fn new(key: StoredClientApiKey, plaintext: String) -> Self {
Self {
@@ -505,6 +578,28 @@ impl CreatedClientApiKeyData {
}
}
impl ImportedClientApiKeyData {
fn new(
key: StoredClientApiKey,
plaintext: String,
source_id: Option<String>,
source_prefix: Option<String>,
) -> Self {
Self {
source_id,
source_prefix,
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
plaintext,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAccountData {
@@ -2468,6 +2563,192 @@ pub async fn api_keys(
}
}
pub async fn export_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<ApiKeyExportQuery>,
) -> Response {
let request_id = request_id.as_str().to_string();
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.client_api_key_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"API key repository is not initialized",
(),
request_id,
),
)
.into_response();
};
let ids = account_export_ids(query.ids.as_deref());
let keys = if ids.is_empty() {
match repo.list_all().await {
Ok(keys) => keys,
Err(_) => {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to export API keys", (), request_id),
)
.into_response();
}
}
} else {
let mut keys = Vec::with_capacity(ids.len());
for id in ids {
match repo.get(&id).await {
Ok(Some(key)) => keys.push(key),
Ok(None) => {}
Err(_) => {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to export API keys", (), request_id),
)
.into_response();
}
}
}
keys
};
// 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。
let data = ClientApiKeyExportData {
source_format: "rustLocalClientApiKeys",
rotation_required: true,
api_keys: keys
.into_iter()
.map(ClientApiKeyExportEntry::from)
.collect(),
};
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data, request_id)).into_response()
}
pub async fn import_api_keys(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Response {
let request_id = request_id.as_str().to_string();
let Some(pool) = state.db() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
)
.into_response();
};
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
return response;
}
let Some(repo) = state.client_api_key_repository() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(
50001,
"API key repository is not initialized",
(),
request_id,
),
)
.into_response();
};
let Some(hasher) = state.api_key_hasher() else {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "API key hasher is not initialized", (), request_id),
)
.into_response();
};
let entries = parse_client_api_key_import_payload(&payload);
if entries.is_empty() {
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(40001, "No importable API keys found", (), request_id),
)
.into_response();
}
let mut imported = 0u32;
let mut skipped = 0u32;
let mut keys = Vec::with_capacity(entries.len());
for entry in entries {
let name = entry.name.trim();
if name.is_empty() {
skipped += 1;
continue;
}
if entry
.label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return AdminResponse::new(
StatusCode::BAD_REQUEST,
AdminEnvelope::new(
40001,
"API key label must be 64 characters or fewer",
(),
request_id,
),
)
.into_response();
}
let generated = hasher.generate_client_api_key(name);
let plaintext = generated.plaintext.clone();
let source_id = entry.source_id;
let source_prefix = entry.source_prefix;
match repo
.insert_generated_with_metadata(name, entry.label.as_deref(), entry.enabled, &generated)
.await
{
Ok(key) => {
imported += 1;
keys.push(ImportedClientApiKeyData::new(
key,
plaintext,
source_id,
source_prefix,
));
}
Err(_) => {
return AdminResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminEnvelope::new(50001, "Failed to import API key", (), request_id),
)
.into_response();
}
}
}
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
ClientApiKeyImportData {
imported,
skipped,
rotated: true,
keys,
},
request_id,
),
)
.into_response()
}
pub async fn create_api_key(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -3728,6 +4009,59 @@ fn account_export_ids(value: Option<&str>) -> Vec<String> {
.collect()
}
fn parse_client_api_key_import_payload(payload: &Value) -> Vec<ClientApiKeyImportEntry> {
let payload = payload
.get("data")
.filter(|data| data.get("apiKeys").is_some() || data.get("keys").is_some())
.unwrap_or(payload);
if let Some(keys) = payload.get("apiKeys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.get("keys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.as_array() {
return keys
.iter()
.filter_map(client_api_key_import_entry_from_value)
.collect();
}
client_api_key_import_entry_from_value(payload)
.into_iter()
.collect()
}
fn client_api_key_import_entry_from_value(value: &Value) -> Option<ClientApiKeyImportEntry> {
value.as_object()?;
let name = first_string(value, &[&["name"]])?;
// 安全边界:导入即使收到 plaintext/keyHash,也只按元数据轮换生成新的本地 cpr_ key。
Some(ClientApiKeyImportEntry {
source_id: first_string(value, &[&["id"], &["sourceId"]]),
source_prefix: first_string(value, &[&["prefix"], &["sourcePrefix"]]),
name,
label: first_string(value, &[&["label"]]),
enabled: client_api_key_import_enabled(value),
})
}
fn client_api_key_import_enabled(value: &Value) -> bool {
if let Some(enabled) = value.get("enabled").and_then(Value::as_bool) {
return enabled;
}
!first_string(value, &[&["status"]])
.unwrap_or_else(|| "active".to_string())
.trim()
.eq_ignore_ascii_case("disabled")
}
fn parse_account_export_format(value: Option<&str>) -> Result<AccountExportFormat, &'static str> {
match value.unwrap_or("native") {
"" | "native" | "full" => Ok(AccountExportFormat::Native),
+152
View File
@@ -477,6 +477,158 @@ async fn create_admin_api_key(
)
}
#[tokio::test]
async fn admin_api_keys_export_should_return_metadata_without_secret_material() {
let (app, _dir) = admin_api_keys_test_app("admin-api-key-export.sqlite").await;
let (key_id, plaintext) = create_admin_api_key(&app, "session_1", "export-key").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/admin/api-keys/export?ids={key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["requestId"], "req_api_key_export");
assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys");
assert_eq!(body["data"]["rotationRequired"], true);
assert_eq!(body["data"]["apiKeys"][0]["id"], key_id);
assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key");
assert!(body["data"]["apiKeys"][0]["prefix"]
.as_str()
.unwrap()
.starts_with("cpr_"));
assert!(body["data"]["apiKeys"][0].get("plaintext").is_none());
assert!(body["data"]["apiKeys"][0].get("keyHash").is_none());
assert!(body["data"].get("pepper").is_none());
assert!(!body.to_string().contains(&plaintext));
}
#[tokio::test]
async fn admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once() {
let source_dir = tempfile::tempdir().unwrap();
let source_db = source_dir.path().join("admin-api-key-export-source.sqlite");
let source_url = format!("sqlite://{}", source_db.display());
let source_pool = connect_sqlite(&source_url).await.unwrap();
seed_admin_session(&source_pool, "session_1").await;
let source_app = build_router(AppState::with_pool_secret_and_api_key_hasher(
test_config(source_url),
source_pool,
SecretBox::new([51u8; 32]),
ApiKeyHasher::new([52u8; 32]),
));
let (source_key_id, source_plaintext) =
create_admin_api_key(&source_app, "session_1", "rotated-key").await;
let export_response = source_app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api-keys/export")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(export_response.status(), StatusCode::OK);
let export_body = response_json(export_response).await;
let target_dir = tempfile::tempdir().unwrap();
let target_db = target_dir.path().join("admin-api-key-import-target.sqlite");
let target_url = format!("sqlite://{}", target_db.display());
let target_pool = connect_sqlite(&target_url).await.unwrap();
seed_admin_session(&target_pool, "session_1").await;
let target_app = build_router(AppState::with_pool_secret_and_api_key_hasher(
test_config(target_url),
target_pool,
SecretBox::new([61u8; 32]),
ApiKeyHasher::new([62u8; 32]),
));
let import_response = target_app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api-keys/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_import")
.body(Body::from(export_body["data"].to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(import_response.status(), StatusCode::OK);
let import_body = response_json(import_response).await;
assert_eq!(import_body["requestId"], "req_api_key_import");
assert_eq!(import_body["data"]["imported"], 1);
assert_eq!(import_body["data"]["skipped"], 0);
assert_eq!(import_body["data"]["rotated"], true);
assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id);
assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key");
assert!(import_body["data"]["keys"][0].get("keyHash").is_none());
let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"]
.as_str()
.unwrap()
.to_string();
assert!(rotated_plaintext.starts_with("cpr_"));
assert_ne!(rotated_plaintext, source_plaintext);
let list_response = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let list_body = response_json(list_response).await;
assert!(list_body["data"][0].get("plaintext").is_none());
let old_rejected = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {source_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED);
let new_accepted = target_app
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {rotated_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(new_accepted.status(), StatusCode::OK);
}
async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) {
let now = Utc::now().to_rfc3339();
sqlx::query(