#![allow(dead_code)]
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::{Mutex, OnceCell};
use tower::ServiceExt;
use umbral::orm::M2M;
use umbral_admin::AdminPlugin;
use umbral_auth::{AuthPlugin, AuthUser, create_user};
use umbral_sessions::SessionsPlugin;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "m2mb_item")]
pub struct Item {
pub id: i64,
#[umbral(string)]
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "m2mb_group")]
pub struct Group {
pub id: i64,
#[umbral(string)]
pub title: String,
#[umbral(m2m = "m2mb_item")]
pub items: M2M<Item>,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
static LOCK: Mutex<()> = Mutex::const_new(());
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("m2m_bounded.sqlite");
std::mem::forget(tmp);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.busy_timeout(std::time::Duration::from_secs(5))
.filename(&path)
.create_if_missing(true),
)
.await
.expect("pool");
let app = umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(AdminPlugin::default())
.model::<Item>()
.model::<Group>()
.build()
.expect("App::build");
umbral::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
let pool = umbral::db::pool();
for i in 1..=210i64 {
sqlx::query("INSERT INTO m2mb_item (name) VALUES (?)")
.bind(format!("item-{i:04}"))
.execute(&pool)
.await
.expect("seed item");
}
sqlx::query("INSERT INTO m2mb_group (title) VALUES ('test-group')")
.execute(&pool)
.await
.expect("seed group");
sqlx::query("INSERT INTO m2mb_group_items (parent_id, child_id) VALUES (1, 210)")
.execute(&pool)
.await
.expect("seed junction");
let staff = create_user("m2m_admin", "m2m@example.com", "pass123")
.await
.expect("create user");
sqlx::query("UPDATE auth_user SET is_staff = 1 WHERE id = ?")
.bind(staff.id)
.execute(&pool)
.await
.expect("set staff");
app.into_router()
})
.await
}
async fn body_of(resp: axum::response::Response) -> String {
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
String::from_utf8_lossy(&bytes).into_owned()
}
fn extract_csrf(html: &str) -> String {
let marker = r#"name="csrf_token""#;
let pos = html.find(marker).unwrap_or(0);
let window = &html[pos..(pos + 200).min(html.len())];
let val = r#"value=""#;
let vpos = window.find(val).unwrap_or(0);
let after = &window[vpos + val.len()..];
after[..after.find('"').unwrap_or(0)].to_string()
}
async fn login_session(router: axum::Router) -> String {
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await
.expect("GET /admin/login");
let csrf_cookie = resp
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|s| {
let first = s.split(';').next()?;
let (k, v) = first.split_once('=')?;
if k.trim() == "umbral_csrf_token" {
Some(v.to_string())
} else {
None
}
})
.expect("csrf cookie from GET /admin/login");
let html = body_of(resp).await;
let csrf = extract_csrf(&html);
let form = serde_urlencoded::to_string([
("username", "m2m_admin"),
("password", "pass123"),
("csrf_token", csrf.as_str()),
("next", "/admin/"),
])
.unwrap();
let resp2 = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, format!("umbral_csrf_token={csrf_cookie}"))
.body(Body::from(form))
.unwrap(),
)
.await
.expect("POST /admin/login");
resp2
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find_map(|s| {
let first = s.split(';').next()?;
let (k, v) = first.split_once('=')?;
if k.trim() == "umbral_session" {
Some(v.to_string())
} else {
None
}
})
.unwrap_or_default()
}
#[tokio::test]
async fn test_m2m_option_fetch_is_bounded() {
let _g = LOCK.lock().await;
let router = boot().await.clone();
let session = login_session(router.clone()).await;
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/m2mb_group/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("GET edit");
let status = resp.status();
let body = body_of(resp).await;
assert_eq!(status, StatusCode::OK, "edit form status:\n{body}");
let checkbox_count = body.matches(r#"name="m2m_items""#).count();
assert!(
checkbox_count < 210,
"expected <210 checkboxes (cap={cap}) but got {checkbox_count};\
the full-table fetch was not bounded",
cap = 200,
);
assert!(
body.contains("item-0210"),
"item-0210 (selected, beyond cap) is missing from the edit form;\
selected-beyond-cap backfill did not fire"
);
}
#[tokio::test]
async fn test_m2m_selected_beyond_cap_is_pre_checked() {
let _g = LOCK.lock().await;
let router = boot().await.clone();
let session = login_session(router.clone()).await;
let resp = router
.clone()
.oneshot(
Request::builder()
.uri("/admin/m2mb_group/1/edit")
.header(header::COOKIE, format!("umbral_session={session}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("GET edit");
let body = body_of(resp).await;
let needle = r#"value="210""#;
let Some(pos) = body.find(needle) else {
panic!(
"checkbox with value=\"210\" not found in form body;\
(item-0210 present: {})",
body.contains("item-0210")
);
};
let window_end = (pos + 200).min(body.len());
let window = &body[pos..window_end];
assert!(
window.contains("checked"),
"item-0210 (selected beyond cap) does not appear as checked in the form;\
window from value=\"210\": {window:?}"
);
}