feat: bot API token management page
The account page pointed users at a "bot-token list" that didn't exist: there was no GET endpoint, no UI route, and the client type lacked expiry. Add GET /v1/auth/tokens (caller-scoped, token_hash never serialised), extend the auth client with listTokens/expires_at and createToken expiry, and add a /profile/tokens page to list, create (revealing the raw bearer once), and revoke tokens. Link the account-page copy to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -541,6 +541,62 @@ async fn create_and_use_bot_token(pool: PgPool) {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_tokens_returns_callers_tokens_scoped_and_without_hash(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
// Mint two tokens for this user, one with an expiry.
|
||||
for body in [
|
||||
json!({ "name": "no-expiry" }),
|
||||
json!({ "name": "expiring", "expires_in_days": 30 }),
|
||||
] {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
body,
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// A second user's token must NOT appear in the first user's list.
|
||||
let (_, other) = common::register_user(&h.app).await;
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
json!({ "name": "someone-elses" }),
|
||||
&other,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/auth/tokens", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2, "only the caller's two tokens");
|
||||
|
||||
let names: Vec<&str> = items.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"no-expiry") && names.contains(&"expiring"));
|
||||
// Raw secret / hash must never appear, but expiry metadata must.
|
||||
for t in items {
|
||||
assert!(t.get("token_hash").is_none(), "token_hash must be absent");
|
||||
assert!(t.get("bearer").is_none(), "raw bearer only shown at creation");
|
||||
assert!(t.get("expires_at").is_some(), "expiry metadata present");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
|
||||
// A token minted with expires_in_days is still active before its
|
||||
|
||||
Reference in New Issue
Block a user