feat(auth): support optional expiry for bot API tokens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 07:22:57 +02:00
parent ed18d95bb0
commit 3cba9ecf95
8 changed files with 139 additions and 8 deletions

View File

@@ -541,6 +541,100 @@ async fn create_and_use_bot_token(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::OK);
}
#[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
// expiry, and the response echoes a non-null expires_at.
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "ci-bot", "expires_in_days": 30 }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
assert!(
body["expires_at"].is_string(),
"expires_at should be set, got {}",
body["expires_at"]
);
let bearer = body["bearer"].as_str().unwrap().to_string();
let resp = h
.app
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn expired_bot_token_is_rejected(pool: PgPool) {
use chrono::{Duration, Utc};
use mangalord::auth::token::generate_token;
let h = common::harness(pool.clone());
common::register_user(&h.app).await;
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
// Hand-craft a token that expired an hour ago.
let (raw, hash) = generate_token();
let expires_at = Utc::now() - Duration::hours(1);
sqlx::query(
"INSERT INTO api_tokens (user_id, name, token_hash, expires_at) \
VALUES ($1, 'stale', $2, $3)",
)
.bind(user_id)
.bind(&hash[..])
.bind(expires_at)
.execute(&pool)
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_bearer("/api/v1/auth/me", &raw))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "unauthenticated");
}
#[sqlx::test(migrations = "./migrations")]
async fn create_token_rejects_out_of_range_expiry(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
for days in [0, -5, 100_000] {
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "bad", "expires_in_days": days }),
&cookie,
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"expires_in_days={days} should be rejected"
);
}
}
#[sqlx::test(migrations = "./migrations")]
async fn user_a_cannot_delete_user_b_token(pool: PgPool) {
let h = common::harness(pool);