test(authz): bearer-authed admin cannot edit/cover NULL-uploader mangas (0.87.19)
0.87.10 followup from the rereview. The fix lacked a test for the specific axis it changed (admin authority via cookie only). New test mints a bearer for a promoted admin and asserts PATCH/PUT-cover/DELETE- cover all 403 on NULL-uploader rows. Mutation-confirmed. Also: `MultipartBuilder::finalize` now `pub` for bearer-multipart use; `admin_csrf_guard` rustdoc updated to describe the post-0.87.10 cookie-precedence rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -757,3 +757,125 @@ async fn patch_null_uploader_allowed_for_admin(pool: PgPool) {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// 0.87.10 split `require_can_edit` so admin authority must come via a
|
||||
/// session cookie — bearer tokens (bot callers) DO NOT carry admin
|
||||
/// powers per the auth-module contract. A bearer-authed admin trying
|
||||
/// to PATCH/PUT-cover/DELETE-cover a NULL-`uploaded_by` row must be
|
||||
/// rejected. Mechanical revert of `admin_via_session(&session)` to
|
||||
/// `user.is_admin` would let bearer-authed admins through and pass
|
||||
/// the 0.87.10 cookie tests; this is the missing axis the rereview
|
||||
/// asked for.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn bearer_authed_admin_cannot_edit_null_uploader(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
|
||||
// Catalog row with NULL uploaded_by (the crawler shape).
|
||||
let (_, owner_cookie) = common::register_user(&h.app).await;
|
||||
let created = create_manga(&h.app, &owner_cookie, json!({ "title": "Catalog" })).await;
|
||||
let id = id_of(&created);
|
||||
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Register a user, promote to admin, mint a bearer token from
|
||||
// their session.
|
||||
let (admin_name, admin_cookie) = common::register_user(&h.app).await;
|
||||
let admin = mangalord::repo::user::find_by_username(&pool, &admin_name)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
mangalord::repo::user::set_is_admin_unchecked(&pool, admin.id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let mint_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
json!({ "name": "ci-bot" }),
|
||||
&admin_cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mint_resp.status(), StatusCode::CREATED);
|
||||
let bearer = common::body_json(mint_resp).await["bearer"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// PATCH via bearer — must 403 even though the user behind the
|
||||
// token IS an admin. CurrentUser admits the call, but
|
||||
// `admin_via_session` reads off `Option<CurrentSessionUser>`
|
||||
// which is None on a bearer request.
|
||||
let patch_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot({
|
||||
let body = json!({ "status": "completed" });
|
||||
axum::http::Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/api/v1/mangas/{id}"))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}"))
|
||||
.body(axum::body::Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
patch_resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"bearer-authed admin must NOT be able to PATCH a NULL-uploader manga"
|
||||
);
|
||||
|
||||
// PUT cover via bearer — same gate, same answer.
|
||||
let png = common::fake_png_bytes();
|
||||
let (boundary, body) = common::MultipartBuilder::new()
|
||||
.add_file("cover", "cover.png", "image/png", &png)
|
||||
.finalize();
|
||||
let put_resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot({
|
||||
axum::http::Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/v1/mangas/{id}/cover"))
|
||||
.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}"))
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
put_resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"bearer-authed admin must NOT be able to put_cover on a NULL-uploader manga"
|
||||
);
|
||||
|
||||
// DELETE cover via bearer.
|
||||
let delete_resp = h
|
||||
.app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/mangas/{id}/cover"))
|
||||
.header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}"))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
delete_resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"bearer-authed admin must NOT be able to delete_cover on a NULL-uploader manga"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -615,7 +615,11 @@ impl MultipartBuilder {
|
||||
self.body.extend(b"\r\n");
|
||||
}
|
||||
|
||||
fn finalize(self) -> (String, Vec<u8>) {
|
||||
/// Pub so a test can mint a multipart body for a bearer-auth Request
|
||||
/// without going through the cookie helpers. Returns
|
||||
/// `(boundary, body)` ready to attach as `Content-Type:
|
||||
/// multipart/form-data; boundary={boundary}` and the request body.
|
||||
pub fn finalize(self) -> (String, Vec<u8>) {
|
||||
let mut body = self.body;
|
||||
body.extend(format!("--{}--\r\n", self.boundary).as_bytes());
|
||||
(self.boundary, body)
|
||||
|
||||
Reference in New Issue
Block a user