This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 14:57:37 +02:00
1 parent 180a0e4877
commit d23fa5e154
39 files changed
+1521 -376

No files matched your search

+150
View File
@@ -1284,6 +1284,156 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn certificate_api_settings_read_edit_and_regenerate_without_exposing_existing_token() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let existing_token = "C".repeat(64);
let admin = |method: Method, path: &str, body: serde_json::Value| {
request(path)
.method(method)
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let external = |path: &str, token: &str| {
request(path)
.method("POST")
.header("authorization", format!("Bearer {token}"))
.header("x-rustproxy-certificate-key", "certbot-prod")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap()
};
let created = router.clone().oneshot(admin(
Method::PUT,
"/api/certificate-api",
json!({
"key_id": "certbot-prod", "token": existing_token,
"comment": "first comment", "certificate_ids": ["first-cert"], "allow_deploy": false
}),
)).await.unwrap();
assert_eq!(created.status(), StatusCode::OK);
let created = json_body(created).await;
assert_eq!(created["token"], existing_token);
let before_hash: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(before_hash, auth::secret_hash(&existing_token));
let detail = router.clone().oneshot(admin(
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
)).await.unwrap();
assert_eq!(detail.status(), StatusCode::OK);
let detail = json_body(detail).await;
assert_eq!(detail["credential"]["comment"], "first comment");
assert_eq!(detail["credential"]["certificate_ids"], json!(["first-cert"]));
assert!(detail.get("token").is_none());
assert!(detail["credential"].get("token").is_none());
assert!(detail["credential"].get("token_hash").is_none());
let created_at = detail["credential"]["created_at"].clone();
// A bearer-only automation client cannot read or modify management records.
let denied = router.clone().oneshot(
request("/api/certificate-api/certbot-prod")
.header("authorization", format!("Bearer {existing_token}"))
.header("x-rustproxy-certificate-key", "certbot-prod")
.body(Body::empty()).unwrap(),
).await.unwrap();
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
let no_csrf = router.clone().oneshot(
request("/api/certificate-api/certbot-prod/regenerate")
.method("POST").header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.body(Body::empty()).unwrap(),
).await.unwrap();
assert_eq!(no_csrf.status(), StatusCode::FORBIDDEN);
let bad_edit = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"changed", "certificate_ids":["other-cert"], "allow_deploy":true, "token":"should-not-be-accepted"}),
)).await.unwrap();
assert!(bad_edit.status().is_client_error());
let wildcard = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"changed", "certificate_ids":["*"], "allow_deploy":true}),
)).await.unwrap();
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
// An edit changes only metadata and grants, not the current bearer secret.
let edited = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
)).await.unwrap();
assert_eq!(edited.status(), StatusCode::OK);
let edited = json_body(edited).await;
assert_eq!(edited["credential"]["comment"], "updated owner");
assert_eq!(edited["credential"]["certificate_ids"], json!(["new-cert"]));
assert_eq!(edited["credential"]["allow_deploy"], true);
assert_eq!(edited["credential"]["created_at"], created_at);
assert!(edited.get("token").is_none());
let hash_after_edit: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(hash_after_edit, before_hash);
let allowed = router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &existing_token,
)).await.unwrap();
assert_eq!(allowed.status(), StatusCode::NOT_FOUND);
let old_id = router.clone().oneshot(external(
"/api/certificates/first-cert/reload", &existing_token,
)).await.unwrap();
assert_eq!(old_id.status(), StatusCode::FORBIDDEN);
let regenerated = router.clone().oneshot(admin(
Method::POST, "/api/certificate-api/certbot-prod/regenerate", json!({}),
)).await.unwrap();
assert_eq!(regenerated.status(), StatusCode::OK);
let regenerated = json_body(regenerated).await;
let new_token = regenerated["token"].as_str().unwrap().to_owned();
assert!(new_token.len() >= 32);
assert_ne!(new_token, existing_token);
assert_eq!(regenerated["credential"]["comment"], "updated owner");
assert_eq!(regenerated["credential"]["allow_deploy"], true);
assert_eq!(regenerated["credential"]["certificate_ids"], json!(["new-cert"]));
assert!(regenerated["credential"]["last_used_at"].is_null());
let raw: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(raw, auth::secret_hash(&new_token));
assert_ne!(raw, new_token);
assert_ne!(raw, before_hash);
assert_eq!(router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &existing_token,
)).await.unwrap().status(), StatusCode::UNAUTHORIZED);
assert_eq!(router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &new_token,
)).await.unwrap().status(), StatusCode::NOT_FOUND);
let after = router.clone().oneshot(admin(
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
)).await.unwrap();
let after = json_body(after).await;
assert!(after.get("token").is_none());
assert!(after["credential"].get("token").is_none());
assert!(after["credential"].get("token_hash").is_none());
let missing = router.clone().oneshot(admin(
Method::POST, "/api/certificate-api/missing-key/regenerate", json!({}),
)).await.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let audit: Vec<String> = sqlx::query_scalar(
"SELECT action FROM admin_audit WHERE action LIKE 'certificate_api.%' ORDER BY id",
).fetch_all(&store.pool).await.unwrap();
assert!(audit.iter().any(|entry| entry == "certificate_api.edit"));
assert!(audit.iter().any(|entry| entry == "certificate_api.regenerate"));
store.close().await;
}
#[tokio::test]
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
let (router, store, _dir, secret, csrf) = fixture().await;