use autumn_web::config::AutumnConfig;
use autumn_web::prelude::*;
use autumn_web::reexports::axum::middleware::from_fn;
use autumn_web::test::{TestApp, TestClient, TestDb};
use {{crate_name}}::routes;
fn app_routes() -> Vec<autumn_web::Route> {
routes![
{{crate_name}}::index,
routes::auth::signup_form,
routes::auth::signup,
routes::auth::login_form,
routes::auth::login,
routes::auth::logout,
routes::dashboard::dashboard,
routes::dashboard::create_project,
]
}
fn enable_tenancy(config: &mut AutumnConfig) {
config.tenancy.enabled = true;
config.tenancy.source = "session".to_string();
config.tenancy.session_key = "tenant_id".to_string();
config.tenancy.public_paths = vec![
"/".to_string(),
"/login".to_string(),
"/signup".to_string(),
"/logout".to_string(),
"/static".to_string(),
];
config.tenancy.login_redirect = Some("/login".to_string());
}
#[tokio::test]
async fn login_page_renders() {
let client = TestApp::new().routes(app_routes()).build();
client
.get("/login")
.send()
.await
.assert_ok()
.assert_body_contains("Log in");
}
#[tokio::test]
async fn login_page_is_public_with_tenancy_enabled() {
let mut config = AutumnConfig::default();
enable_tenancy(&mut config);
let client = TestApp::new().routes(app_routes()).config(config).build();
client
.get("/login")
.send()
.await
.assert_ok()
.assert_body_contains("Log in");
}
#[tokio::test]
async fn protected_route_redirects_to_login_when_unauthenticated() {
let mut config = AutumnConfig::default();
enable_tenancy(&mut config);
let client = TestApp::new().routes(app_routes()).config(config).build();
let resp = client
.get("/dashboard")
.header("accept", "text/html")
.send()
.await;
resp.assert_status(303);
assert_eq!(
resp.header("location"),
Some("/login"),
"missing-tenant protected route should redirect a browser to the login page"
);
}
#[tokio::test]
async fn protected_route_returns_401_for_api_clients() {
let mut config = AutumnConfig::default();
enable_tenancy(&mut config);
let client = TestApp::new().routes(app_routes()).config(config).build();
let resp = client
.get("/dashboard")
.header("accept", "application/json")
.send()
.await;
resp.assert_status(401);
}
async fn db_client() -> TestClient {
let db = TestDb::shared().await;
db.execute_sql(
"CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)",
)
.await;
db.execute_sql(
"CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)",
)
.await;
db.execute_sql(
"CREATE TABLE IF NOT EXISTS remember_tokens (
series TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
previous_token_hash TEXT NULL,
rotated_at TIMESTAMPTZ NULL,
expires_at TIMESTAMPTZ NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
ua_family TEXT NOT NULL DEFAULT '',
ua_os TEXT NOT NULL DEFAULT '',
ua_device TEXT NOT NULL DEFAULT '',
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
)",
)
.await;
db.execute_sql("TRUNCATE users, projects, remember_tokens RESTART IDENTITY")
.await;
let mut config = AutumnConfig::default();
config.security.csrf.enabled = false;
enable_tenancy(&mut config);
{{crate_name}}::remember::init_remember_pool(db.pool(), autumn_web::auth::RememberConfig::default());
TestApp::new()
.routes(app_routes())
.config(config)
.with_db(db.pool())
.layer(from_fn({{crate_name}}::remember::remember_me_middleware))
.build()
}
fn session_cookie(resp: &autumn_web::test::TestResponse) -> String {
let set_cookie = resp
.header("set-cookie")
.expect("auth response should set a session cookie");
set_cookie
.split(';')
.next()
.expect("cookie has a name=value pair")
.to_owned()
}
fn named_cookie(resp: &autumn_web::test::TestResponse, name: &str) -> Option<String> {
for (key, value) in &resp.headers {
if key.eq_ignore_ascii_case("set-cookie") {
let pair = value.split(';').next().unwrap_or("");
if let Some((cookie_name, cookie_value)) = pair.split_once('=')
&& cookie_name.trim() == name
{
return Some(cookie_value.trim().to_owned());
}
}
}
None
}
async fn signup(client: &TestClient, email: &str) -> String {
let resp = client
.post("/signup")
.form(&format!(
"email={email}&password=Tr0ubad0ur-Xy7-correct-horse"
))
.send()
.await;
resp.assert_status(303);
session_cookie(&resp)
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn signup_login_dashboard_returns_200() {
let client = db_client().await;
let cookie = signup(&client, "founder@acme.test").await;
client
.get("/dashboard")
.header("cookie", &cookie)
.send()
.await
.assert_ok()
.assert_body_contains("Projects");
let login = client
.post("/login")
.form("email=founder@acme.test&password=Tr0ubad0ur-Xy7-correct-horse")
.send()
.await;
login.assert_status(303);
let cookie = session_cookie(&login);
client
.get("/dashboard")
.header("cookie", &cookie)
.send()
.await
.assert_ok();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn signup_rejects_weak_password() {
let client = db_client().await;
let resp = client
.post("/signup")
.form("email=weak@acme.test&password=password")
.send()
.await;
resp.assert_ok();
assert!(
resp.text().contains("too common"),
"expected the weak-password policy error in the re-rendered form, got: {}",
resp.text()
);
let login = client
.post("/login")
.form("email=weak@acme.test&password=password")
.send()
.await;
login.assert_ok();
assert!(
login.text().contains("Invalid email or password"),
"expected the login form to redisplay inline with the auth error, got: {}",
login.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn signup_and_login_failures_redisplay_the_form_with_email_preserved() {
let client = db_client().await;
let resp = client
.post("/signup")
.form("email=not-an-email&password=Tr0ubad0ur-Xy7-correct-horse")
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("Enter a valid email address"));
assert!(
resp.text().contains(r#"value="not-an-email""#),
"expected the entered email to be preserved in the re-rendered form, got: {}",
resp.text()
);
let long_password = "x".repeat(129);
let resp = client
.post("/signup")
.form(&format!("email=long@acme.test&password={long_password}"))
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("at most 128 characters"));
assert!(resp.text().contains(r#"value="long@acme.test""#));
let resp = client
.post("/signup")
.form("email=weak2@acme.test&password=password")
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("too common"));
assert!(resp.text().contains(r#"value="weak2@acme.test""#));
let _ = signup(&client, "dupe@acme.test").await;
let resp = client
.post("/signup")
.form("email=dupe@acme.test&password=Tr0ubad0ur-Xy7-correct-horse-2")
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("Could not create account"));
assert!(resp.text().contains(r#"value="dupe@acme.test""#));
let resp = client
.post("/login")
.form("email=nobody@acme.test&password=wrong-password-entirely")
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("Invalid email or password"));
assert!(resp.text().contains(r#"value="nobody@acme.test""#));
let resp = client
.post("/login")
.form(&format!("email=toolong@acme.test&password={long_password}"))
.send()
.await;
resp.assert_ok();
assert!(resp.text().contains("Invalid email or password"));
assert!(resp.text().contains(r#"value="toolong@acme.test""#));
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn login_failure_preserves_the_remember_me_choice() {
let client = db_client().await;
let checked = client
.post("/login")
.form("email=nobody@acme.test&password=wrong-password&remember=on")
.send()
.await;
checked.assert_ok();
assert!(
checked.text().contains("checked"),
"expected the Remember-me checkbox to stay checked after a rejected login, got: {}",
checked.text()
);
let unchecked = client
.post("/login")
.form("email=nobody@acme.test&password=wrong-password")
.send()
.await;
unchecked.assert_ok();
assert!(
!unchecked.text().contains("checked"),
"an unticked box must not come back checked, got: {}",
unchecked.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn tenants_are_isolated() {
let client = db_client().await;
let acme = signup(&client, "a@acme.test").await;
let globex = signup(&client, "b@globex.test").await;
client
.post("/dashboard/projects")
.header("cookie", &acme)
.form("name=Alpha")
.send()
.await
.assert_status(303);
client
.get("/dashboard")
.header("cookie", &acme)
.send()
.await
.assert_ok()
.assert_body_contains("Alpha");
let globex_view = client
.get("/dashboard")
.header("cookie", &globex)
.send()
.await;
globex_view.assert_ok();
assert!(
!globex_view.text().contains("Alpha"),
"tenant isolation breached: Globex can see Acme's project"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_project_failure_redisplays_the_dashboard_with_name_preserved() {
let client = db_client().await;
let cookie = signup(&client, "founder@acme.test").await;
client
.post("/dashboard/projects")
.header("cookie", &cookie)
.form("name=Existing")
.send()
.await
.assert_status(303);
let resp = client
.post("/dashboard/projects")
.header("cookie", &cookie)
.form("name=%20%20%20")
.send()
.await;
resp.assert_ok();
assert!(
resp.text()
.contains("Project name must be between 1 and 200 characters"),
"expected the length-validation error in the re-rendered dashboard, got: {}",
resp.text()
);
assert!(
resp.text().contains("Existing"),
"expected the existing project list to survive the rejected submission, got: {}",
resp.text()
);
assert!(
resp.text().contains(r#"value=" ""#),
"expected the raw (untrimmed) whitespace-only submission to be preserved verbatim \
in the re-rendered field rather than silently blanked by the length check's own \
trimming, got: {}",
resp.text()
);
let long_name = "x".repeat(201);
let resp = client
.post("/dashboard/projects")
.header("cookie", &cookie)
.form(&format!("name={long_name}"))
.send()
.await;
resp.assert_ok();
assert!(
resp.text()
.contains("Project name must be between 1 and 200 characters")
);
assert!(
resp.text().contains(&format!(r#"value="{long_name}""#)),
"expected the rejected name to be preserved in the re-rendered field, got: {}",
resp.text()
);
let dashboard = client
.get("/dashboard")
.header("cookie", &cookie)
.send()
.await;
dashboard.assert_ok();
assert!(
dashboard.text().contains("1 total"),
"a rejected submission must not create a project, got: {}",
dashboard.text()
);
}
fn extract_submit_token(html: &str) -> String {
let marker = r#"name="_submit_token" value=""#;
let start = html
.find(marker)
.expect("expected a `_submit_token` hidden field in the rendered form (issue #2921)");
let rest = &html[start + marker.len()..];
let end = rest
.find('"')
.expect("unterminated `_submit_token` value in the rendered form");
rest[..end].to_owned()
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn create_project_double_submit_creates_exactly_one_project() {
let client = db_client().await;
let cookie = signup(&client, "founder@acme.test").await;
let dashboard = client
.get("/dashboard")
.header("cookie", &cookie)
.send()
.await;
dashboard.assert_ok();
let token = extract_submit_token(&dashboard.text());
let body = |token: &str| format!("name=DoubleClickTest&_submit_token={token}");
let first = client
.post("/dashboard/projects")
.header("cookie", &cookie)
.form(&body(&token))
.send()
.await;
first.assert_status(303);
let second = client
.post("/dashboard/projects")
.header("cookie", &cookie)
.form(&body(&token))
.send()
.await;
second.assert_status(303);
assert_eq!(
second.header("x-submit-token-replayed"),
Some("true"),
"the double-clicked submission must be short-circuited as a replay, \
not re-run the handler"
);
let dashboard = client
.get("/dashboard")
.header("cookie", &cookie)
.send()
.await;
dashboard.assert_ok();
assert!(
dashboard.text().contains("1 total"),
"a double-clicked create-project submission must create exactly one \
project, got: {}",
dashboard.text()
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn remember_me_rotates_across_restart_and_detects_theft() {
let client = db_client().await;
signup(&client, "remember@acme.test").await;
client.log_out();
let login = client
.post("/login")
.form("email=remember@acme.test&password=Tr0ubad0ur-Xy7-correct-horse&remember=on")
.send()
.await;
login.assert_status(303);
let original_remember = named_cookie(&login, "autumn.remember")
.expect("login with remember=on must set a remember cookie");
assert!(
named_cookie(&login, "autumn.sid").is_some(),
"login must also set a session cookie"
);
client
.get("/dashboard")
.send()
.await
.assert_ok()
.assert_body_contains("Projects");
client.log_out(); let restart = client.get("/dashboard").send().await;
restart.assert_ok();
let rotated_once = named_cookie(&restart, "autumn.remember")
.expect("a rotated request must set a fresh remember cookie");
assert_ne!(
rotated_once, original_remember,
"the remember cookie must rotate on use"
);
client.log_out();
let restart2 = client.get("/dashboard").send().await;
restart2.assert_ok();
let rotated_twice = named_cookie(&restart2, "autumn.remember")
.expect("a second rotated request must set another fresh remember cookie");
assert_ne!(
rotated_twice, rotated_once,
"the remember cookie must rotate again on the second use"
);
client.log_out();
let theft = client
.get("/dashboard")
.header("accept", "application/json")
.header("cookie", &format!("autumn.remember={original_remember}"))
.send()
.await;
theft.assert_status(401);
let after_theft = client
.get("/dashboard")
.header("accept", "application/json")
.header("cookie", &format!("autumn.remember={rotated_twice}"))
.send()
.await;
after_theft.assert_status(401);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn remember_me_accepts_previous_token_within_grace() {
let client = db_client().await;
signup(&client, "grace@acme.test").await;
client.log_out();
let login = client
.post("/login")
.form("email=grace@acme.test&password=Tr0ubad0ur-Xy7-correct-horse&remember=on")
.send()
.await;
login.assert_status(303);
let original_remember = named_cookie(&login, "autumn.remember")
.expect("login with remember=on must set a remember cookie");
client.log_out();
let restart = client.get("/dashboard").send().await;
restart.assert_ok();
let rotated_once = named_cookie(&restart, "autumn.remember")
.expect("a rotated request must set a fresh remember cookie");
assert_ne!(rotated_once, original_remember);
client.log_out();
let within_grace = client
.get("/dashboard")
.header("cookie", &format!("autumn.remember={original_remember}"))
.send()
.await;
within_grace.assert_ok().assert_body_contains("Projects");
client.log_out();
client
.get("/dashboard")
.header("cookie", &format!("autumn.remember={rotated_once}"))
.send()
.await
.assert_ok();
}