#![allow(dead_code, private_interfaces)]
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::OnceCell;
use tower::ServiceExt;
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 = "write_err_item")]
pub struct WriteErrItem {
pub id: i64,
pub slug: String,
pub title: String,
}
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in a test env");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("write_error_per_field.sqlite");
std::mem::forget(tmp);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.filename(&path)
.create_if_missing(true),
)
.await
.expect("sqlite pool");
let app = umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(AdminPlugin::default())
.model::<WriteErrItem>()
.build()
.expect("App::build");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE auth_user (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
username TEXT NOT NULL UNIQUE,\
email TEXT NOT NULL,\
password_hash TEXT NOT NULL,\
is_active INTEGER NOT NULL,\
is_staff INTEGER NOT NULL,\
is_superuser INTEGER NOT NULL,\
date_joined TEXT NOT NULL,\
last_login TEXT,\
email_verified_at TEXT\
)",
)
.execute(&pool)
.await
.expect("create auth_user");
sqlx::query(
"CREATE TABLE session (\
id TEXT PRIMARY KEY,\
user_id TEXT,\
data TEXT NOT NULL DEFAULT '{}',\
created_at TEXT NOT NULL,\
expires_at TEXT NOT NULL\
)",
)
.execute(&pool)
.await
.expect("create session");
sqlx::query(
"CREATE TABLE write_err_item (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
slug TEXT NOT NULL UNIQUE,\
title TEXT NOT NULL\
)",
)
.execute(&pool)
.await
.expect("create write_err_item");
sqlx::query("INSERT INTO write_err_item (slug, title) VALUES ('hello', 'Hello')")
.execute(&pool)
.await
.expect("seed first item");
let staff = create_user("admin_wef", "admin_wef@example.com", "pw")
.await
.expect("create staff user");
sqlx::query("UPDATE auth_user SET is_staff = 1, is_superuser = 1 WHERE id = ?")
.bind(staff.id)
.execute(&pool)
.await
.expect("mark as staff");
app.into_router()
})
.await
}
async fn send(router: axum::Router, req: Request<Body>) -> (StatusCode, String) {
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
async fn send_full(
router: axum::Router,
req: Request<Body>,
) -> (StatusCode, axum::http::HeaderMap, String) {
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
(
status,
headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
fn extract_csrf_token(html: &str) -> Option<String> {
let name_marker = r#"name="csrf_token""#;
let pos = html.find(name_marker)?;
let window_end = pos.saturating_add(400).min(html.len());
let window = &html[pos..window_end];
let value_marker = "value=\"";
let vstart = window.find(value_marker)? + value_marker.len();
let vend = window[vstart..].find('"')?;
Some(window[vstart..vstart + vend].to_string())
}
async fn login_session(router: &axum::Router, username: &str, password: &str) -> String {
let (_, headers, body) = send_full(
router.clone(),
Request::builder()
.uri("/admin/login")
.body(Body::empty())
.unwrap(),
)
.await;
let anon_cookie = headers
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|pair| pair.split_once('='))
.map(|(_, v)| v.to_string())
})
.expect("GET /admin/login must set a session cookie");
let csrf_token =
extract_csrf_token(&body).expect("login page must contain a csrf_token hidden input");
let form_body = serde_urlencoded::to_string([
("username", username),
("password", password),
("csrf_token", &csrf_token),
("next", "/admin/"),
])
.unwrap();
let (_, headers2, _) = send_full(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/login")
.header(header::COOKIE, format!("umbral_csrf_token={anon_cookie}"))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form_body))
.unwrap(),
)
.await;
headers2
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';')
.next()
.and_then(|pair| pair.split_once('='))
.map(|(_, v)| v.to_string())
})
.expect("POST /admin/login must set a session cookie on success")
}
#[tokio::test]
async fn unique_violation_on_create_renders_error_under_slug_field() {
let router = boot().await.clone();
let cookie = login_session(&router, "admin_wef", "pw").await;
let auth_cookie = format!("umbral_session={cookie}");
let body_str =
serde_urlencoded::to_string([("slug", "hello"), ("title", "Duplicate")]).unwrap();
let (status, html) = send(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/write_err_item/new")
.header(header::COOKIE, &auth_cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_str))
.unwrap(),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"duplicate-slug create must return 400; body:\n{html}"
);
assert!(
html.contains("class=\"field-error\""),
"expected a field-error element in the form; body:\n{html}"
);
assert!(
html.contains("slug") && html.contains("already exists"),
"expected 'slug' and 'already exists' in per-field error; body:\n{html}"
);
let slug_input_pos = html
.find(r#"name="slug""#)
.expect("slug input must be in form");
let field_error_pos = html
.find("class=\"field-error\"")
.expect("field-error must be in form after the fix");
assert!(
field_error_pos > slug_input_pos,
"field-error must appear after the slug input (i.e. beneath it), \
not only at the top; slug_input at {slug_input_pos}, field-error at {field_error_pos}"
);
}
#[tokio::test]
async fn unique_violation_on_update_renders_error_under_slug_field() {
let router = boot().await.clone();
let cookie = login_session(&router, "admin_wef", "pw").await;
let auth_cookie = format!("umbral_session={cookie}");
let create_body = serde_urlencoded::to_string([("slug", "world"), ("title", "World")]).unwrap();
let resp = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/write_err_item/new")
.header(header::COOKIE, &auth_cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(create_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"creating 'world' row must succeed"
);
let edit_body =
serde_urlencoded::to_string([("slug", "hello"), ("title", "World renamed")]).unwrap();
let (status, html) = send(
router.clone(),
Request::builder()
.method("POST")
.uri("/admin/write_err_item/2/edit")
.header(header::COOKIE, &auth_cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(edit_body))
.unwrap(),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"duplicate-slug edit must return 400; body:\n{html}"
);
assert!(
html.contains("class=\"field-error\""),
"expected a field-error element in the edit form; body:\n{html}"
);
assert!(
html.contains("slug") && html.contains("already exists"),
"expected 'slug' and 'already exists' in per-field error on edit; body:\n{html}"
);
let slug_input_pos = html
.find(r#"name="slug""#)
.expect("slug input must be in edit form");
let field_error_pos = html
.find("class=\"field-error\"")
.expect("field-error must be in edit form after the fix");
assert!(
field_error_pos > slug_input_pos,
"field-error must appear after the slug input on edit form; \
slug_input at {slug_input_pos}, field-error at {field_error_pos}"
);
}