use super::*;
use apiplant_auth::Authenticator;
fn main_toml(db_url: &str) -> String {
format!(
r#"
[server]
base_path = "/api"
public_url = "https://example.test"
[database]
url = "{db_url}"
[email]
provider = "smtp"
from = "no-reply@example.test"
[email.smtp]
host = "127.0.0.1"
port = 1
encryption = "none"
"#
)
}
fn sql(state: &AppState, query: &str) -> String {
let mut out = query.to_string();
for (placeholder, resource) in [
("{users}", "user"),
("{organizations}", "organization"),
("{memberships}", "membership"),
("{invitations}", "invitation"),
("{auth_tokens}", "auth_token"),
] {
out = out.replace(placeholder, &state.table(resource).expect(resource));
}
out
}
async fn seed(state: &AppState, resource: &str, fields: Value) -> Value {
let resource = state.app.resources.get(resource).expect(resource);
let Value::Object(data) = fields else {
panic!("seed takes an object");
};
state.db.create(resource, &data).await.unwrap()
}
fn at(seconds: i64) -> Value {
json!((chrono::Utc::now() + chrono::Duration::seconds(seconds)).to_rfc3339())
}
async fn verified_user(state: &AppState, email: &str, password: &str) -> String {
let hash = state.auth.hash_password(password).unwrap();
let row = seed(
state,
"user",
json!({ "email": email, "password_hash": hash, "email_verified_at": at(0) }),
)
.await;
row["id"].as_str().unwrap().to_string()
}
async fn organization(state: &AppState, name: &str, owner: &str) -> String {
let row = seed(state, "organization", json!({ "name": name })).await;
let org = row["id"].as_str().unwrap().to_string();
seed(
state,
"membership",
json!({ "user_id": owner, "organization_id": org, "role": "admin" }),
)
.await;
org
}
async fn invitation(
state: &AppState,
org: &str,
email: &str,
role: &str,
token: &str,
expires_in: i64,
) {
seed(
state,
"invitation",
json!({
"email": email,
"role": role,
"token_hash": Authenticator::hash_link_token(token),
"organization_id": org,
"expires_at": at(expires_in),
}),
)
.await;
}
async fn auth_token(state: &AppState, user: &str, kind: &str, token: &str) {
seed(
state,
"auth_token",
json!({
"user_id": user,
"kind": kind,
"token_hash": Authenticator::hash_link_token(token),
"expires_at": at(60 * 60 * 24),
}),
)
.await;
}
async fn count(state: &AppState, query: &str, params: &[Value]) -> usize {
let rows = state.db.raw_json(&sql(state, query), params).await.unwrap();
rows.as_array().map(Vec::len).unwrap_or_default()
}
#[ntex::test]
async fn without_a_mailer_there_are_no_mailbox_routes() {
let db = TempDatabase::create("nomail").await;
let root = temp_dir("nomail");
write_files(
&root,
&[(
"main.toml",
&format!(
"[server]\nbase_path = \"/api\"\n\n[database]\nurl = \"{}\"\n",
db.url
),
)],
);
let state = load_state(&root).await;
assert!(!state.email_enabled());
assert!(!state.invitations_enabled());
assert!(!state.password_reset_enabled());
assert!(!state.requires_email_verification());
let app = init_http_app!(state);
for (method, path) in [
("POST", "/api/auth/invitations"),
("POST", "/api/auth/password/forgot"),
("POST", "/api/auth/password/reset"),
("POST", "/api/auth/verify-email"),
] {
let resp = test::call_service(&app, req_json(method, path, json!({}))).await;
assert!(
matches!(resp.status().as_u16(), 404 | 405),
"{path} was handled by a server with no mailer"
);
}
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/register",
json!({"email":"ann@example.test","password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 201);
assert!(read_json(resp).await["token"].is_string());
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn a_provider_switches_the_flows_on_and_a_flag_can_switch_one_back_off() {
let db = TempDatabase::create("emailflags").await;
let root = temp_dir("emailflags");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
assert!(state.email_enabled());
assert!(state.invitations_enabled());
assert!(state.password_reset_enabled());
assert!(state.requires_email_verification());
write_files(
&root,
&[(
"main.toml",
&format!(
"{}\n[auth]\nrequire_email_verification = false\n",
main_toml(&db.url)
),
)],
);
let state = load_state(&root).await;
assert!(!state.requires_email_verification());
assert!(state.invitations_enabled());
assert!(state.password_reset_enabled());
let app = init_http_app!(state);
let resp =
test::call_service(&app, req_json("POST", "/api/auth/verify-email", json!({}))).await;
assert!(matches!(resp.status().as_u16(), 404 | 405));
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn an_unconfirmed_account_cannot_sign_in_until_it_confirms() {
let db = TempDatabase::create("verify").await;
let root = temp_dir("verify");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/register",
json!({"email":"ann@example.test","password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 502);
let rows = state
.db
.raw_json(
&sql(
&state,
"SELECT id::text AS id FROM {users} WHERE email = $1",
),
&[json!("ann@example.test")],
)
.await
.unwrap();
let user_id = rows[0]["id"].as_str().unwrap().to_string();
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"ann@example.test","password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 403);
let refused = read_json(resp).await;
assert_eq!(refused["reason"], "email_unverified");
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"ann@example.test","password":"wrong"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 401);
auth_token(&state, &user_id, "email_verification", "verify_known").await;
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/verify-email",
json!({"token":"verify_known"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
assert!(read_json(resp).await["token"].is_string());
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/verify-email",
json!({"token":"verify_known"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 404);
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"ann@example.test","password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn a_reset_link_changes_the_password_once() {
let db = TempDatabase::create("reset").await;
let root = temp_dir("reset");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let user_id = verified_user(&state, "ann@example.test", "old-password").await;
for address in ["ann@example.test", "nobody@example.test"] {
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/password/forgot",
json!({"email": address}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 202);
}
auth_token(&state, &user_id, "password_reset", "reset_first").await;
auth_token(&state, &user_id, "password_reset", "reset_second").await;
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/password/reset",
json!({"token":"reset_first","password":"new-password"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
assert!(read_json(resp).await["token"].is_string());
for token in ["reset_first", "reset_second"] {
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/password/reset",
json!({"token": token, "password":"third-password"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 404, "{token} still worked");
}
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"ann@example.test","password":"new-password"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"ann@example.test","password":"old-password"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 401);
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn an_invitation_creates_the_account_and_the_membership() {
let db = TempDatabase::create("invite").await;
let root = temp_dir("invite");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let admin = verified_user(&state, "admin@example.test", "hunter2").await;
let org = organization(&state, "Acme Ltd", &admin).await;
invitation(
&state,
&org,
"new@example.test",
"member",
"inv_known",
60 * 60 * 24 * 7,
)
.await;
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri("/api/auth/invitations/inv_known")
.to_request(),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let preview = read_json(resp).await;
assert_eq!(preview["organization"], "Acme Ltd");
assert_eq!(preview["email"], "new@example.test");
assert_eq!(preview["has_account"], false);
assert!(preview.get("organization_id").is_none());
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/invitations/inv_known/accept",
json!({"password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let accepted = read_json(resp).await;
assert!(accepted["token"].is_string());
assert_eq!(accepted["organization_id"], org);
assert_eq!(
count(
&state,
"SELECT 1 AS hit FROM {memberships} m JOIN {users} u ON u.id = m.user_id \
WHERE u.email = $1 AND m.organization_id = $2::uuid AND m.role = 'member'",
&[json!("new@example.test"), json!(org)],
)
.await,
1
);
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/login",
json!({"email":"new@example.test","password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/invitations/inv_known/accept",
json!({"password":"hunter2"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 404);
assert_eq!(
count(
&state,
"SELECT 1 AS hit FROM {invitations} WHERE accepted_at IS NOT NULL",
&[],
)
.await,
1
);
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn an_invitation_to_an_existing_account_just_joins_it() {
let db = TempDatabase::create("invitejoin").await;
let root = temp_dir("invitejoin");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let admin = verified_user(&state, "admin@example.test", "hunter2").await;
let org = organization(&state, "Acme Ltd", &admin).await;
verified_user(&state, "ann@example.test", "her-own-password").await;
invitation(
&state,
&org,
"ann@example.test",
"billing",
"inv_known",
60 * 60 * 24 * 7,
)
.await;
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri("/api/auth/invitations/inv_known")
.to_request(),
)
.await;
assert_eq!(read_json(resp).await["has_account"], true);
let resp = test::call_service(
&app,
req_json("POST", "/api/auth/invitations/inv_known/accept", json!({})),
)
.await;
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(
count(
&state,
"SELECT 1 AS hit FROM {users} WHERE email = $1",
&[json!("ann@example.test")],
)
.await,
1
);
assert_eq!(
count(
&state,
"SELECT 1 AS hit FROM {memberships} m JOIN {users} u ON u.id = m.user_id \
WHERE u.email = $1 AND m.role = 'billing'",
&[json!("ann@example.test")],
)
.await,
1
);
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn an_expired_invitation_is_indistinguishable_from_one_that_never_existed() {
let db = TempDatabase::create("inviteold").await;
let root = temp_dir("inviteold");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let admin = verified_user(&state, "admin@example.test", "hunter2").await;
let org = organization(&state, "Acme Ltd", &admin).await;
invitation(
&state,
&org,
"late@example.test",
"member",
"inv_stale",
-60 * 60 * 24,
)
.await;
for token in ["inv_stale", "inv_never_existed"] {
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri(&format!("/api/auth/invitations/{token}"))
.to_request(),
)
.await;
assert_eq!(resp.status().as_u16(), 404);
}
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}
#[ntex::test]
async fn an_invitation_that_could_not_be_sent_is_not_left_pending() {
let db = TempDatabase::create("invitesend").await;
let root = temp_dir("invitesend");
write_files(&root, &[("main.toml", &main_toml(&db.url))]);
let state = load_state(&root).await;
let app = init_http_app!(state.clone());
let admin = verified_user(&state, "admin@example.test", "hunter2").await;
let org = organization(&state, "Acme Ltd", &admin).await;
let admin_token = state
.auth
.issue_token(uuid::Uuid::parse_str(&admin).unwrap())
.unwrap();
let member = verified_user(&state, "member@example.test", "hunter2").await;
seed(
&state,
"membership",
json!({ "user_id": member, "organization_id": org, "role": "member" }),
)
.await;
let member_token = state
.auth
.issue_token(uuid::Uuid::parse_str(&member).unwrap())
.unwrap();
let invite = |token: &str, body: Value| {
bearer(
test::TestRequest::post()
.uri("/api/auth/invitations")
.header(CONTENT_TYPE, "application/json")
.set_payload(body.to_string()),
token,
)
.header("x-organization", org.clone())
.to_request()
};
let resp = test::call_service(
&app,
invite(&member_token, json!({"email":"new@example.test"})),
)
.await;
assert_eq!(resp.status().as_u16(), 403);
let resp = test::call_service(
&app,
invite(&admin_token, json!({"email":"new@example.test"})),
)
.await;
assert_eq!(resp.status().as_u16(), 502);
assert_eq!(
count(&state, "SELECT 1 AS hit FROM {invitations}", &[]).await,
0,
"a row was left behind for an email that was never sent"
);
let resp = test::call_service(
&app,
invite(&admin_token, json!({"email":"member@example.test"})),
)
.await;
assert_eq!(resp.status().as_u16(), 409);
let resp = test::call_service(
&app,
req_json(
"POST",
"/api/auth/invitations",
json!({"email":"new@example.test"}),
),
)
.await;
assert_eq!(resp.status().as_u16(), 401);
db.cleanup().await;
fs::remove_dir_all(root).unwrap();
}