use axum::body::Body;
use axum::http::{Request, StatusCode};
use laterite_admin::{router, AdminConfig, Contributions};
use laterite_auth::{AuthConfig, AuthService, NewOperator, RequestContext};
use laterite_core::{CatalogStore, Db};
use std::sync::Arc;
use tower::ServiceExt;
const SESSION_COOKIE: &str = "laterite_session";
const ORIGIN: &str = "https://acme.test";
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&laterite_admin::builtin_migrations()).await
}
fn post(path: &str, token: &str, headers: &[(&str, &str)], body: &str) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(path)
.header("cookie", format!("{SESSION_COOKIE}={token}"))
.header("content-type", "application/x-www-form-urlencoded");
for (key, value) in headers {
builder = builder.header(*key, *value);
}
builder.body(Body::from(body.to_string())).unwrap()
}
fn get(path: &str, token: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(path)
.header("cookie", format!("{SESSION_COOKIE}={token}"))
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn admin_mutations_require_origin_and_token() {
let (pool, _guard) = test_db().await;
let svc = AuthService::new(pool.clone(), AuthConfig::default());
svc.create_superuser(NewOperator {
username: "root",
email: "root@acme.test",
first_name: "Root",
last_name: None,
password: "rootpw12345",
timezone: None,
})
.await
.unwrap();
let token = svc
.authenticate("root", "rootpw12345", &RequestContext::default())
.await
.unwrap()
.token;
svc.set_session_data(&token, r#"{"v":1,"csrf":"tok-123"}"#)
.await
.unwrap();
let app = || {
let mut config = AdminConfig::default();
config.origin = ORIGIN.to_string();
router(
AuthService::new(pool.clone(), AuthConfig::default()),
pool.clone(),
Contributions::default(),
config,
Arc::new(CatalogStore::default()),
)
};
let same_origin = ("sec-fetch-site", "same-origin");
let pref = "/admin/preferences";
let status = app()
.oneshot(post(
pref,
&token,
&[same_origin],
"timezone=UTC&_csrf=tok-123",
))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::SEE_OTHER,
"field token should be accepted"
);
let status = app()
.oneshot(post(
pref,
&token,
&[same_origin, ("x-csrf-token", "tok-123")],
"timezone=UTC",
))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::SEE_OTHER,
"header token should be accepted"
);
let status = app()
.oneshot(post(pref, &token, &[same_origin], "timezone=UTC"))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"missing token must be rejected"
);
let status = app()
.oneshot(post(
pref,
&token,
&[same_origin],
"timezone=UTC&_csrf=wrong",
))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"wrong token must be rejected"
);
let status = app()
.oneshot(post(
pref,
&token,
&[("origin", "https://evil.test")],
"timezone=UTC&_csrf=tok-123",
))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"cross-site origin must be rejected"
);
let status = app()
.oneshot(post(pref, &token, &[], "timezone=UTC&_csrf=tok-123"))
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a mutation with no origin signal must be rejected"
);
let status = app().oneshot(get(pref, &token)).await.unwrap().status();
assert_eq!(status, StatusCode::OK, "GET is exempt");
}