feat(auth): support optional expiry for bot API tokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user