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:
MechaCat02
2026-06-23 20:36:50 +02:00
parent d6a4fd668c
commit 0f9254b054
6 changed files with 146 additions and 8 deletions

View File

@@ -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"
);
}

View File

@@ -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)