mod common;
use common::{delete, get, post, put, signup, As};
use serde_json::json;
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn signup_login_me_logout() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "alice", "hunter2").await;
assert!(
token.starts_with("r:") && token.len() == 34,
"a session token is `r:` plus 32 hex characters, and a client can observe it: {token}"
);
assert!(
token[2..]
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"lowercase hex only, not the objectId alphabet: {token}"
);
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(me.status, 200, "{}", me.raw);
assert_eq!(me.body["objectId"], json!(object_id));
assert_eq!(me.body["username"], json!("alice"));
assert_eq!(
me.body["sessionToken"],
json!(token),
"SDKs expect the token echoed back"
);
assert!(
me.body.get("password").is_none() && me.body.get("_hashed_password").is_none(),
"no form of the password may reach a response: {}",
me.raw
);
let login = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "alice", "password": "hunter2" }),
)
.await;
assert_eq!(login.status, 200, "{}", login.raw);
assert_eq!(login.body["objectId"], json!(object_id));
let login_token = login.body["sessionToken"].as_str().expect("token");
assert_ne!(login_token, token, "login mints a new session");
assert!(login.body.get("_hashed_password").is_none());
let out = post(host, "/logout", &As::user(&token), &json!({})).await;
assert_eq!(out.status, 200);
let after = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(after.code(), Some(209), "{}", after.raw);
let other = get(host, "/users/me", &As::user(login_token)).await;
assert_eq!(other.status, 200, "{}", other.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_session_survives_a_restart() {
let server = common::boot().await;
let (object_id, token) = signup(&server.host, "bob", "hunter2").await;
let restarted = common::reboot(&server.database).await;
let me = get(&restarted, "/users/me", &As::user(&token)).await;
assert_eq!(
me.status, 200,
"a token minted by one process must be accepted by the next: {}",
me.raw
);
assert_eq!(me.body["objectId"], json!(object_id));
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn login_does_not_distinguish_a_missing_user_from_a_wrong_password() {
let server = common::boot().await;
let host = &server.host;
signup(host, "carol", "hunter2").await;
for body in [
json!({ "username": "carol", "password": "wrong" }),
json!({ "username": "nobody", "password": "hunter2" }),
] {
let r = post(host, "/login", &As::anonymous(), &body).await;
assert_eq!(r.code(), Some(101), "{}", r.raw);
assert_eq!(r.error(), "Invalid username/password.");
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_duplicate_username_or_email_is_reported_without_disclosing_the_row() {
let server = common::boot().await;
let host = &server.host;
let database = &server.database;
signup(host, "dave", "hunter2").await;
let with_email = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "erin", "password": "pw", "email": "erin@example.com" }),
)
.await;
assert_eq!(with_email.status, 201, "{}", with_email.raw);
let same_username = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "dave", "password": "other" }),
)
.await;
assert_eq!(same_username.code(), Some(202), "{}", same_username.raw);
assert_eq!(
same_username.error(),
"Account already exists for this username."
);
let same_email = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "frank", "password": "pw", "email": "erin@example.com" }),
)
.await;
assert_eq!(same_email.code(), Some(203), "{}", same_email.raw);
assert_eq!(
same_email.error(),
"Account already exists for this email address."
);
for response in [&same_username, &same_email] {
assert_no_storage_detail(
&response.raw,
database,
&["dave", "erin@example.com", "E11000"],
);
}
}
fn assert_no_storage_detail(raw: &str, database: &str, values: &[&str]) {
assert!(
!raw.contains(database),
"the database name is on the wire: {raw}"
);
for value in values {
assert!(!raw.contains(value), "`{value}` is on the wire: {raw}");
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_unknown_token_is_an_error_rather_than_anonymity() {
let server = common::boot().await;
let host = &server.host;
let r = get(
host,
"/classes/Post",
&As::user("r:0123456789abcdef0123456789abcdef"),
)
.await;
assert_eq!(r.code(), Some(209), "{}", r.raw);
assert_eq!(r.error(), "Invalid session token");
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn the_two_me_routes_report_a_missing_token_differently() {
let server = common::boot().await;
let host = &server.host;
let users = get(host, "/users/me", &As::anonymous()).await;
assert_eq!(users.code(), Some(209));
assert_eq!(users.error(), "Permission denied");
let sessions = get(host, "/sessions/me", &As::anonymous()).await;
assert_eq!(sessions.code(), Some(209));
assert_eq!(sessions.error(), "Session token required.");
let disclosing = {
let mut config = parse_rust_server::ServerConfig::new(common::APP_ID, common::MASTER_KEY);
config.enable_sanitized_error_response = false;
common::boot_with(&server.database, config).await
};
let users = get(&disclosing, "/users/me", &As::anonymous()).await;
assert_eq!(users.code(), Some(209));
assert_eq!(users.error(), "Invalid session token");
let sessions = get(&disclosing, "/sessions/me", &As::anonymous()).await;
assert_eq!(sessions.code(), Some(209));
assert_eq!(sessions.error(), "Session token required.");
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn sessions_me_returns_the_callers_own_session() {
let server = common::boot().await;
let host = &server.host;
let (user_id, token) = signup(host, "erin", "hunter2").await;
let me = get(host, "/sessions/me", &As::user(&token)).await;
assert_eq!(me.status, 200, "{}", me.raw);
assert_eq!(me.body["sessionToken"], json!(token));
assert_eq!(me.body["user"]["objectId"], json!(user_id));
assert_eq!(me.body["user"]["__type"], json!("Pointer"));
assert_eq!(me.body["expiresAt"]["__type"], json!("Date"));
assert!(me.body["createdAt"].is_string());
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_session_read_is_narrowed_to_the_caller() {
let server = common::boot().await;
let host = &server.host;
let (a_id, a_token) = signup(host, "frank", "hunter2").await;
let (_b_id, b_token) = signup(host, "grace", "hunter2").await;
let mine = get(host, "/sessions", &As::user(&a_token)).await;
assert_eq!(mine.status, 200, "{}", mine.raw);
assert_eq!(mine.results().len(), 1, "{}", mine.raw);
assert_eq!(mine.results()[0]["user"]["objectId"], json!(a_id));
let b_session = get(host, "/sessions/me", &As::user(&b_token)).await;
let b_object_id = b_session.body["objectId"].as_str().expect("objectId");
let stolen = get(
host,
&format!("/sessions/{b_object_id}"),
&As::user(&a_token),
)
.await;
assert_eq!(
stolen.code(),
Some(101),
"one user must not read another's session row: {}",
stolen.raw
);
let all = get(host, "/sessions", &As::master()).await;
assert_eq!(all.results().len(), 2, "{}", all.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_session_can_only_be_deleted_by_its_owner() {
let server = common::boot().await;
let host = &server.host;
let (_a_id, a_token) = signup(host, "heidi", "hunter2").await;
let (_b_id, b_token) = signup(host, "ivan", "hunter2").await;
let b_session = get(host, "/sessions/me", &As::user(&b_token)).await;
let b_object_id = b_session.body["objectId"]
.as_str()
.expect("objectId")
.to_string();
let stolen = delete(
host,
&format!("/sessions/{b_object_id}"),
&As::user(&a_token),
)
.await;
assert_eq!(stolen.code(), Some(101), "{}", stolen.raw);
assert_eq!(stolen.error(), "Object not found for delete.");
assert_eq!(
get(host, "/users/me", &As::user(&b_token)).await.status,
200
);
let own = delete(
host,
&format!("/sessions/{b_object_id}"),
&As::user(&b_token),
)
.await;
assert_eq!(own.status, 200, "{}", own.raw);
assert_eq!(
get(host, "/users/me", &As::user(&b_token)).await.code(),
Some(209)
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_included_master_only_class_is_refused() {
let server = common::boot().await;
let host = &server.host;
let (_id, token) = signup(host, "peggy", "hunter2").await;
let created = post(
host,
"/classes/Probe",
&As::user(&token),
&json!({
"tag": "probe",
"cfg": { "__type": "Pointer", "className": "_GlobalConfig", "objectId": "1" }
}),
)
.await;
assert_eq!(created.status, 201, "{}", created.raw);
let plain = get(
host,
&format!(
"/classes/Probe{}",
common::where_query(json!({ "tag": "probe" }))
),
&As::user(&token),
)
.await;
assert_eq!(plain.status, 200, "{}", plain.raw);
let expanded = get(
host,
&format!(
"/classes/Probe{}&include=cfg",
common::where_query(json!({ "tag": "probe" }))
),
&As::user(&token),
)
.await;
assert_eq!(expanded.status, 400, "{}", expanded.raw);
assert_eq!(expanded.body["code"], json!(119), "{}", expanded.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_multi_object_include_of_installations_is_refused_and_a_single_one_is_not() {
let server = common::boot().await;
let host = &server.host;
let (_id, token) = signup(host, "quinn", "hunter2").await;
let probe = |tag: &str, installation: &str| {
json!({
"tag": tag,
"inst": {
"__type": "Pointer",
"className": "_Installation",
"objectId": installation
}
})
};
for (tag, installation) in [("one", "iAAAAAAAAA"), ("two", "iBBBBBBBBB")] {
let created = post(
host,
"/classes/Probe",
&As::user(&token),
&probe(tag, installation),
)
.await;
assert_eq!(created.status, 201, "{}", created.raw);
}
let single = get(
host,
&format!(
"/classes/Probe{}&include=inst",
common::where_query(json!({ "tag": "one" }))
),
&As::user(&token),
)
.await;
assert_eq!(single.status, 200, "{}", single.raw);
let many = get(
host,
&format!(
"/classes/Probe{}&include=inst",
common::where_query(json!({ "tag": { "$in": ["one", "two"] } }))
),
&As::user(&token),
)
.await;
assert_eq!(many.status, 400, "{}", many.raw);
assert_eq!(many.body["code"], json!(119), "{}", many.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_pinned_where_does_not_turn_a_find_on_installations_into_a_get() {
let server = common::boot().await;
let host = &server.host;
let (_id, token) = signup(host, "rhea", "hunter2").await;
let pinned = get(
host,
&format!(
"/classes/_Installation{}",
common::where_query(json!({ "objectId": "iAAAAAAAAA" }))
),
&As::user(&token),
)
.await;
assert_eq!(pinned.status, 400, "{}", pinned.raw);
assert_eq!(pinned.body["code"], json!(119), "{}", pinned.raw);
let by_id = get(host, "/classes/_Installation/iAAAAAAAAA", &As::user(&token)).await;
assert_eq!(by_id.body["code"], json!(101), "{}", by_id.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_included_session_is_narrowed_to_the_caller() {
let server = common::boot().await;
let host = &server.host;
let (_a_id, a_token) = signup(host, "mallory", "hunter2").await;
let (_b_id, b_token) = signup(host, "trent", "hunter2").await;
let a_session = get(host, "/sessions/me", &As::user(&a_token)).await;
let a_object_id = a_session.body["objectId"].as_str().expect("objectId");
let b_session = get(host, "/sessions/me", &As::user(&b_token)).await;
let b_object_id = b_session.body["objectId"].as_str().expect("objectId");
let b_session_token = b_session.body["sessionToken"]
.as_str()
.expect("sessionToken")
.to_string();
let pointer = |object_id: &str| json!({ "__type": "Pointer", "className": "_Session", "objectId": object_id });
let own = post(
host,
"/classes/Leak",
&As::user(&a_token),
&json!({ "tag": "own", "s": pointer(a_object_id) }),
)
.await;
assert_eq!(own.status, 201, "{}", own.raw);
let mine = get(
host,
&format!(
"/classes/Leak{}&include=s",
common::where_query(json!({ "tag": "own" }))
),
&As::user(&a_token),
)
.await;
assert_eq!(mine.status, 200, "{}", mine.raw);
assert_eq!(
mine.results()[0]["s"]["objectId"],
json!(a_object_id),
"the caller's own session must resolve, or the negative case below proves nothing: {}",
mine.raw
);
let stolen = post(
host,
"/classes/Leak",
&As::user(&a_token),
&json!({ "tag": "stolen", "s": pointer(b_object_id) }),
)
.await;
assert_eq!(stolen.status, 201, "{}", stolen.raw);
let leaked = get(
host,
&format!(
"/classes/Leak{}&include=s",
common::where_query(json!({ "tag": "stolen" }))
),
&As::user(&a_token),
)
.await;
assert_eq!(leaked.status, 200, "{}", leaked.raw);
assert!(
!leaked.raw.contains(&b_session_token),
"an included `_Session` handed over another user's session token: {}",
leaked.raw
);
assert!(
leaked.results()[0].get("s").is_none(),
"an unresolved include leaves the key absent: {}",
leaked.raw
);
assert_eq!(
get(host, "/users/me", &As::user(&b_session_token))
.await
.status,
200
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn the_excluded_session_routes_are_404() {
let server = common::boot().await;
let host = &server.host;
let (_id, token) = signup(host, "judy", "hunter2").await;
for (method, path) in [
("POST", "/sessions"),
("PUT", "/sessions/abc123"),
("POST", "/upgradeToRevocableSession"),
] {
let r = match method {
"POST" => post(host, path, &As::user(&token), &json!({})).await,
_ => put(host, path, &As::user(&token), &json!({})).await,
};
assert!(
r.status == 404 || r.status == 405,
"{method} {path} must not be served, got {}: {}",
r.status,
r.raw
);
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_dead_session_token_does_not_block_a_login() {
let server = common::boot().await;
let host = &server.host;
let created = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "relogin", "password": "pw" }),
)
.await;
assert_eq!(created.status, 201, "{}", created.raw);
let token = created.body["sessionToken"]
.as_str()
.expect("token")
.to_string();
let out = post(host, "/logout", &As::user(&token), &json!({})).await;
assert_eq!(out.status, 200, "{}", out.raw);
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(me.code(), Some(209), "the token must be dead: {}", me.raw);
let again = post(
host,
"/login",
&As::user(&token),
&json!({ "username": "relogin", "password": "pw" }),
)
.await;
assert_eq!(
again.status, 200,
"a dead token attached to /login must be discarded, not validated: {}",
again.raw
);
assert!(again.body["sessionToken"].is_string(), "{}", again.raw);
assert_ne!(
again.body["sessionToken"].as_str().expect("token"),
token,
"a fresh session, not the dead one"
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_unserved_method_is_405_unless_post_carries_it_to_dispatch() {
let server = common::boot().await;
let host = &server.host;
for path in ["/sessions", "/users/me"] {
let r = post(host, path, &As::master(), &json!({})).await;
assert_eq!(
r.status, 404,
"POST {path} reaches dispatch and finds no arm: {}",
r.raw
);
}
for path in ["/sessions", "/login"] {
let r = put(host, path, &As::master(), &json!({})).await;
assert_eq!(
r.status, 405,
"PUT {path} is refused by the router: {}",
r.raw
);
}
let r = get(host, "/nonesuch", &As::master()).await;
assert_eq!(r.status, 404, "{}", r.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_user_can_save_their_own_row() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "saver", "pw").await;
let saved = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "nickname": "Sav" }),
)
.await;
assert_eq!(
saved.status, 200,
"a user may save their own row: {}",
saved.raw
);
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(me.body["nickname"], json!("Sav"), "{}", me.raw);
let (other_id, _) = signup(host, "other", "pw").await;
let intruder = put(
host,
&format!("/classes/_User/{other_id}"),
&As::user(&token),
&json!({ "nickname": "hacked" }),
)
.await;
assert_eq!(
intruder.code(),
Some(101),
"one user may not save another's row: {}",
intruder.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn changing_a_password_revokes_sessions_and_issues_a_new_token() {
let server = common::boot().await;
let host = &server.host;
let (object_id, first) = signup(host, "changer", "old-pw").await;
let second = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "changer", "password": "old-pw" }),
)
.await;
let second_token = second.body["sessionToken"]
.as_str()
.expect("token")
.to_string();
let changed = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&first),
&json!({ "password": "new-pw" }),
)
.await;
assert_eq!(changed.status, 200, "{}", changed.raw);
let replacement = changed.body["sessionToken"]
.as_str()
.expect("a replacement token is issued")
.to_string();
assert_ne!(replacement, first, "a new session, not the old one");
for dead in [&first, &second_token] {
let r = get(host, "/users/me", &As::user(dead)).await;
assert_eq!(
r.code(),
Some(209),
"an old token must be revoked: {}",
r.raw
);
}
let alive = get(host, "/users/me", &As::user(&replacement)).await;
assert_eq!(alive.status, 200, "{}", alive.raw);
let old = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "changer", "password": "old-pw" }),
)
.await;
assert_eq!(old.code(), Some(101), "{}", old.raw);
let new = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "changer", "password": "new-pw" }),
)
.await;
assert_eq!(new.status, 200, "{}", new.raw);
assert!(changed.raw.find("_hashed_password").is_none());
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn saving_a_taken_username_reports_the_parse_code() {
let server = common::boot().await;
let host = &server.host;
signup(host, "taken", "pw").await;
let (object_id, token) = signup(host, "mover", "pw").await;
let clash = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "username": "taken" }),
)
.await;
assert_eq!(clash.code(), Some(202), "{}", clash.raw);
assert_eq!(clash.error(), "Account already exists for this username.");
assert!(
!clash.raw.contains("E11000") && !clash.raw.contains(&server.database),
"no driver detail on the wire: {}",
clash.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn creating_or_deleting_a_user_through_classes_is_still_refused() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "fixed", "pw").await;
let created = post(
host,
"/classes/_User",
&As::user(&token),
&json!({ "username": "sneaky", "password": "pw" }),
)
.await;
assert_eq!(created.code(), Some(119), "{}", created.raw);
let deleted = delete(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
)
.await;
assert_eq!(deleted.code(), Some(119), "{}", deleted.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_unauthenticated_user_update_is_refused_even_with_a_public_write_acl() {
let server = common::boot().await;
let host = &server.host;
let (object_id, _) = signup(host, "victim", "pw").await;
let opened = put(
host,
&format!("/classes/_User/{object_id}"),
&As::master(),
&json!({ "ACL": { "*": { "read": true, "write": true } } }),
)
.await;
assert_eq!(opened.status, 200, "{}", opened.raw);
let taken = put(
host,
&format!("/classes/_User/{object_id}"),
&As::anonymous(),
&json!({ "password": "hacked" }),
)
.await;
assert_eq!(taken.code(), Some(206), "{}", taken.raw);
assert!(
taken.body.get("sessionToken").is_none(),
"no session may be issued to an anonymous caller: {}",
taken.raw
);
let still = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "victim", "password": "pw" }),
)
.await;
assert_eq!(still.status, 200, "{}", still.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn server_controlled_user_fields_are_refused() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "controlled", "pw").await;
for body in [
json!({ "emailVerified": true }),
json!({ "authData": { "custom": { "id": "1" } } }),
] {
let r = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&body,
)
.await;
assert_eq!(r.code(), Some(119), "{body} must be refused: {}", r.raw);
}
let acl = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "ACL": { "*": { "read": true } } }),
)
.await;
assert_eq!(acl.status, 200, "{}", acl.raw);
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(
me.body["ACL"][&object_id],
json!({ "read": true, "write": true }),
"the owner entry is forced back in: {}",
me.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn user_identity_is_validated_case_insensitively() {
let server = common::boot().await;
let host = &server.host;
signup(host, "CaseOnly", "pw").await;
let (object_id, token) = signup(host, "mover2", "pw").await;
let case = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "username": "caseonly" }),
)
.await;
assert_eq!(case.code(), Some(202), "{}", case.raw);
let bad_email = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "email": "not-an-email" }),
)
.await;
assert_eq!(bad_email.code(), Some(125), "{}", bad_email.raw);
assert_eq!(bad_email.error(), "Email address format is invalid.");
let self_save = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "username": "mover2", "email": "m@example.com" }),
)
.await;
assert_eq!(self_save.status, 200, "{}", self_save.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_non_string_password_is_refused_and_changes_nothing() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "nuller", "pw").await;
for bad in [json!(null), json!(7), json!(true), json!({"a": 1})] {
let r = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "password": bad }),
)
.await;
assert_eq!(r.code(), Some(111), "{bad}: {}", r.raw);
assert!(r.body.get("sessionToken").is_none(), "{}", r.raw);
}
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(me.status, 200, "no session was revoked: {}", me.raw);
let login = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "nuller", "password": "pw" }),
)
.await;
assert_eq!(login.status, 200, "{}", login.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_replacement_session_has_no_created_with() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "meta", "pw").await;
let changed = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "password": "new-pw" }),
)
.await;
assert_eq!(changed.status, 200, "{}", changed.raw);
let replacement = changed.body["sessionToken"].as_str().expect("token");
let me = get(host, "/sessions/me", &As::user(replacement)).await;
assert_eq!(me.status, 200, "{}", me.raw);
assert!(
me.body.get("createdWith").is_none(),
"a password-update session describes no action: {}",
me.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_non_user_duplicate_is_not_relabelled_as_a_username_error() {
let server = common::boot().await;
let host = &server.host;
let first = post(
host,
"/classes/Member",
&As::master(),
&json!({ "username": "one" }),
)
.await;
assert_eq!(first.status, 201, "{}", first.raw);
let second = post(
host,
"/classes/Member",
&As::master(),
&json!({ "username": "two" }),
)
.await;
assert_eq!(second.status, 201, "{}", second.raw);
let second_id = second.body["objectId"].as_str().expect("objectId");
common::create_unique_index(&server.database, "Member", "username").await;
let clash = put(
host,
&format!("/classes/Member/{second_id}"),
&As::master(),
&json!({ "username": "one" }),
)
.await;
assert_eq!(
clash.code(),
Some(137),
"an ordinary class's collision stays DUPLICATE_VALUE: {}",
clash.raw
);
assert_ne!(
clash.error(),
"Account already exists for this username.",
"and is never reported as a user error: {}",
clash.raw
);
signup(host, "taken_u", "pw").await;
let (mover, token) = signup(host, "mover_u", "pw").await;
let user_clash = put(
host,
&format!("/classes/_User/{mover}"),
&As::user(&token),
&json!({ "username": "taken_u" }),
)
.await;
assert_eq!(user_clash.code(), Some(202), "{}", user_clash.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn signup_validates_identity_the_same_way_an_update_does() {
let server = common::boot().await;
let host = &server.host;
signup(host, "SignupCase", "pw").await;
let case_dupe = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "signupcase", "password": "pw" }),
)
.await;
assert_eq!(
case_dupe.code(),
Some(202),
"a case-only duplicate must not sign up: {}",
case_dupe.raw
);
let bad_email = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "fresh", "password": "pw", "email": "not-an-email" }),
)
.await;
assert_eq!(bad_email.code(), Some(125), "{}", bad_email.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn master_can_replace_a_user_acl_without_the_owner_being_re_added() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "revokable", "pw").await;
let replaced = put(
host,
&format!("/classes/_User/{object_id}"),
&As::master(),
&json!({ "ACL": { "*": { "read": true } } }),
)
.await;
assert_eq!(replaced.status, 200, "{}", replaced.raw);
let seen = get(host, &format!("/classes/_User/{object_id}"), &As::master()).await;
assert!(
seen.body["ACL"].get(&object_id).is_none(),
"a master ACL is stored exactly as written: {}",
seen.raw
);
let (other_id, other_token) = signup(host, "keeper", "pw").await;
let by_owner = put(
host,
&format!("/classes/_User/{other_id}"),
&As::user(&other_token),
&json!({ "ACL": { "*": { "read": true } } }),
)
.await;
assert_eq!(by_owner.status, 200, "{}", by_owner.raw);
let owned = get(host, &format!("/classes/_User/{other_id}"), &As::master()).await;
assert_eq!(
owned.body["ACL"][&other_id],
json!({ "read": true, "write": true }),
"a client's ACL keeps the owner entry: {}",
owned.raw
);
let _ = token;
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn email_validation_matches_upstreams_regex_and_ordering() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "emails", "pw").await;
for accepted in [json!(""), json!("@a@b")] {
let r = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "email": accepted }),
)
.await;
assert_eq!(r.status, 200, "{accepted} is accepted upstream: {}", r.raw);
}
for rejected in [json!("not-an-email"), json!("a@"), json!("@a")] {
let r = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "email": rejected }),
)
.await;
assert_eq!(r.code(), Some(125), "{rejected}: {}", r.raw);
}
signup(host, "OrderCase", "pw").await;
let both = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "username": "ordercase", "email": "not-an-email" }),
)
.await;
assert_eq!(
both.code(),
Some(202),
"username is checked first: {}",
both.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn user_update_refusals_carry_upstreams_detailed_messages() {
let server = common::boot_fresh_with(|mut c| {
c.enable_sanitized_error_response = false;
c
})
.await;
let host = &server.host;
let (object_id, token) = signup(host, "messages", "pw").await;
put(
host,
&format!("/classes/_User/{object_id}"),
&As::master(),
&json!({ "ACL": { "*": { "read": true, "write": true } } }),
)
.await;
let anon = put(
host,
&format!("/classes/_User/{object_id}"),
&As::anonymous(),
&json!({ "nickname": "x" }),
)
.await;
assert_eq!(anon.code(), Some(206), "{}", anon.raw);
assert_eq!(anon.error(), format!("Cannot modify user {object_id}."));
let verified = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "emailVerified": true }),
)
.await;
assert_eq!(verified.code(), Some(119), "{}", verified.raw);
assert_eq!(
verified.error(),
"Clients aren't allowed to manually update email verification.",
"the noun is upstream's, not the column name"
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn uniqueness_folds_unicode_normalization_not_just_case() {
let server = common::boot().await;
let host = &server.host;
let precomposed = "Caf\u{e9}";
let decomposed = "Cafe\u{301}";
assert_ne!(precomposed, decomposed, "the two spellings differ by bytes");
signup(host, precomposed, "pw").await;
let clash = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": decomposed, "password": "pw" }),
)
.await;
assert_eq!(
clash.code(),
Some(202),
"a decomposed spelling is the same identity: {}",
clash.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn signup_cannot_set_email_verification_or_auth_data() {
let server = common::boot().await;
let host = &server.host;
for (field, value) in [
("emailVerified", json!(true)),
("authData", json!({"custom": {"id": "someone-elses"}})),
] {
let response = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": format!("u_{field}"), "password": "pw", field: value }),
)
.await;
assert_eq!(
response.code(),
Some(119),
"signup must refuse a client-set {field}: {}",
response.raw
);
}
let ok = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "u_control", "password": "pw" }),
)
.await;
assert_eq!(ok.status, 201, "{}", ok.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn login_accepts_an_email_address_as_the_identifier() {
let server = common::boot().await;
let host = &server.host;
let created = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "ada", "password": "pw", "email": "ada@example.com" }),
)
.await;
assert_eq!(created.status, 201, "{}", created.raw);
let by_email = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "ada@example.com", "password": "pw" }),
)
.await;
assert_eq!(by_email.status, 200, "{}", by_email.raw);
assert_eq!(by_email.body["username"], "ada");
let email_key = post(
host,
"/login",
&As::anonymous(),
&json!({ "email": "ada@example.com", "password": "pw" }),
)
.await;
assert_eq!(email_key.status, 200, "{}", email_key.raw);
let by_username = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "ada", "password": "pw" }),
)
.await;
assert_eq!(by_username.status, 200, "{}", by_username.raw);
let wrong = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "ada@example.com", "password": "nope" }),
)
.await;
assert_eq!(wrong.code(), Some(101), "{}", wrong.raw);
let mismatched = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "ada", "email": "someone@else.example", "password": "pw" }),
)
.await;
assert_eq!(mismatched.code(), Some(101), "{}", mismatched.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn login_reports_which_credential_was_wrong() {
let server = common::boot().await;
let host = &server.host;
signup(host, "grace", "pw").await;
for (body, code, label) in [
(json!({ "password": "pw" }), 200, "no identifier"),
(
json!({ "username": "", "password": "pw" }),
200,
"empty identifier",
),
(json!({ "username": "grace" }), 201, "no password"),
(
json!({ "username": "grace", "password": "" }),
201,
"empty password",
),
(
json!({ "username": "grace", "password": 7 }),
101,
"non-string password",
),
(
json!({ "username": 7, "password": "pw" }),
101,
"non-string identifier",
),
] {
let response = post(host, "/login", &As::anonymous(), &body).await;
assert_eq!(response.code(), Some(code), "{label}: {}", response.raw);
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_empty_acl_disables_login() {
let server = common::boot().await;
let host = &server.host;
let (object_id, _) = signup(host, "disabled", "pw").await;
let before = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "disabled", "password": "pw" }),
)
.await;
assert_eq!(before.status, 200, "{}", before.raw);
let locked = put(
host,
&format!("/classes/_User/{object_id}"),
&As::master(),
&json!({ "ACL": {} }),
)
.await;
assert_eq!(locked.status, 200, "{}", locked.raw);
let after = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "disabled", "password": "pw" }),
)
.await;
assert_eq!(
after.code(),
Some(101),
"a disabled account must not log in: {}",
after.raw
);
assert!(
after.body.get("sessionToken").is_none(),
"and must not receive a session: {}",
after.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_username_wins_over_another_users_matching_email() {
let server = common::boot().await;
let host = &server.host;
let other = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "other_user", "password": "wrong-pw", "email": "collide@example.com" }),
)
.await;
assert_eq!(other.status, 201, "{}", other.raw);
let other_id = other.body["objectId"]
.as_str()
.expect("objectId")
.to_string();
let (target_id, _) = signup(host, "collide@example.com", "right-pw").await;
assert_ne!(target_id, other_id);
let logged_in = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "collide@example.com", "password": "right-pw" }),
)
.await;
assert_eq!(logged_in.status, 200, "{}", logged_in.raw);
assert_eq!(
logged_in.body["objectId"],
json!(target_id),
"the exact username match wins, not whichever row the database returned first: {}",
logged_in.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_read_of_an_absent_class_is_refused_when_client_class_creation_is_off() {
let server = common::boot_fresh_with(|mut c| {
c.allow_client_class_creation = false;
c
})
.await;
let host = &server.host;
let (_id, token) = signup(host, "reader", "pw").await;
let read = get(host, "/classes/NoSuchClass", &As::user(&token)).await;
assert_eq!(read.code(), Some(119), "{}", read.raw);
let as_master = get(host, "/classes/NoSuchClass", &As::master()).await;
assert_eq!(as_master.status, 200, "{}", as_master.raw);
assert_eq!(as_master.body["results"], json!([]));
let as_maintenance = get(host, "/classes/NoSuchClass", &As::maintenance()).await;
assert_eq!(
as_maintenance.code(),
Some(119),
"maintenance is subject to the read-path check: {}",
as_maintenance.raw
);
let written = post(
host,
"/classes/NoSuchClass",
&As::maintenance(),
&json!({ "x": 1 }),
)
.await;
assert_eq!(
written.status, 201,
"maintenance may still create a class: {}",
written.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_login_for_a_missing_user_costs_what_a_real_one_does() {
use std::time::Instant;
let server = common::boot().await;
let host = &server.host;
signup(host, "real_user", "correct-horse").await;
for _ in 0..2 {
post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "real_user", "password": "wrong" }),
)
.await;
}
let time = |body: serde_json::Value| async move {
let started = Instant::now();
let r = post(host, "/login", &As::anonymous(), &body).await;
assert_eq!(r.code(), Some(101), "both must fail: {}", r.raw);
started.elapsed()
};
let existing = time(json!({ "username": "real_user", "password": "wrong" })).await;
let missing = time(json!({ "username": "no_such_user", "password": "wrong" })).await;
assert!(
missing * 5 >= existing,
"a missing account answered in {missing:?} against {existing:?} for a real one, which \
leaks account existence through timing regardless of the shared error message"
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_refused_signup_does_not_disclose_whether_the_account_exists() {
use std::time::Instant;
let server = common::boot().await;
let host = &server.host;
signup(host, "already_here", "pw").await;
let clp = put(
host,
"/schemas/_User",
&As::master(),
&json!({
"className": "_User",
"classLevelPermissions": { "find": {"*": true}, "get": {"*": true}, "create": {} }
}),
)
.await;
assert_eq!(clp.status, 200, "{}", clp.raw);
let attempt = |name: &'static str| async move {
let started = Instant::now();
let r = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": name, "password": "pw" }),
)
.await;
(r, started.elapsed())
};
let (existing, t_existing) = attempt("already_here").await;
let (fresh, t_fresh) = attempt("brand_new_name").await;
assert_eq!(existing.code(), Some(119), "{}", existing.raw);
assert_eq!(fresh.code(), Some(119), "{}", fresh.raw);
assert!(
t_existing * 5 >= t_fresh && t_fresh * 5 >= t_existing,
"a refused signup must not disclose account existence through timing: \
existing {t_existing:?} against fresh {t_fresh:?}"
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_master_user_create_validates_identity() {
let server = common::boot().await;
let host = &server.host;
signup(host, "MasterCase", "pw").await;
let case_dupe = post(
host,
"/classes/_User",
&As::master(),
&json!({ "username": "mastercase", "password": "pw" }),
)
.await;
assert_eq!(case_dupe.code(), Some(202), "{}", case_dupe.raw);
let bad_email = post(
host,
"/classes/_User",
&As::master(),
&json!({ "username": "fresh_master", "password": "pw", "email": "not-an-email" }),
)
.await;
assert_eq!(bad_email.code(), Some(125), "{}", bad_email.raw);
let exact = post(
host,
"/classes/_User",
&As::master(),
&json!({ "username": "MasterCase", "password": "pw" }),
)
.await;
assert_eq!(exact.code(), Some(202), "{}", exact.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn creating_a_user_requires_a_username_and_a_password() {
let server = common::boot().await;
let host = &server.host;
for route in ["/users", "/classes/_User"] {
let who = if route == "/users" {
As::anonymous()
} else {
As::master()
};
let no_username = post(host, route, &who, &json!({ "password": "pw" })).await;
assert_eq!(
no_username.code(),
Some(200),
"{route} without a username: {}",
no_username.raw
);
assert_eq!(no_username.error(), "bad or missing username");
let no_password = post(host, route, &who, &json!({ "username": "nopw_x" })).await;
assert_eq!(
no_password.code(),
Some(201),
"{route} without a password: {}",
no_password.raw
);
assert_eq!(no_password.error(), "password is required");
let empty = post(
host,
route,
&who,
&json!({ "username": "", "password": "pw" }),
)
.await;
assert_eq!(
empty.code(),
Some(200),
"{route} empty username: {}",
empty.raw
);
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_regex_operand_in_an_array_in_is_a_query_not_a_write() {
let server = common::boot().await;
let host = &server.host;
let made = post(
host,
"/classes/Tagger",
&As::master(),
&json!({ "tags": ["xylophone", "banjo"] }),
)
.await;
assert_eq!(made.status, 201, "{}", made.raw);
let w = common::urlencode(&json!({ "tags": { "$in": [{ "$regex": "^xy" }] } }).to_string());
let found = get(host, &format!("/classes/Tagger?where={w}"), &As::master()).await;
assert_eq!(
found.status, 200,
"a regex operand must not be refused as a nested write key: {}",
found.raw
);
assert_eq!(found.results().len(), 1, "{}", found.raw);
let written = post(
host,
"/classes/Tagger",
&As::master(),
&json!({ "tags": [{ "$regex": "^xy" }] }),
)
.await;
assert_eq!(written.code(), Some(121), "{}", written.raw);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn deleting_an_acl_keeps_the_owner_and_leaves_the_account_usable() {
let server = common::boot().await;
let host = &server.host;
let (object_id, token) = signup(host, "acl_unset", "pw").await;
let unset = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "ACL": { "__op": "Delete" } }),
)
.await;
assert_eq!(unset.status, 200, "{}", unset.raw);
let stored = get(host, &format!("/classes/_User/{object_id}"), &As::master()).await;
assert_eq!(
stored.body["ACL"],
json!({ &object_id: { "read": true, "write": true } }),
"the owner entry survives an unset: {}",
stored.raw
);
let after = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "nickname": "still here" }),
)
.await;
assert_eq!(
after.status, 200,
"an unset ACL must not lock the owner out: {}",
after.raw
);
let login = post(
host,
"/login",
&As::anonymous(),
&json!({ "username": "acl_unset", "password": "pw" }),
)
.await;
assert_eq!(
login.status, 200,
"and must not read as a disabled account: {}",
login.raw
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn deleting_a_username_is_refused_and_deleting_an_email_is_not() {
let server = common::boot().await;
let host = &server.host;
let created = post(
host,
"/users",
&As::anonymous(),
&json!({ "username": "unset_me", "password": "pw", "email": "unset@example.com" }),
)
.await;
assert_eq!(created.status, 201, "{}", created.raw);
let object_id = created.body["objectId"]
.as_str()
.expect("objectId")
.to_string();
let token = created.body["sessionToken"]
.as_str()
.expect("token")
.to_string();
let refused = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "username": { "__op": "Delete" } }),
)
.await;
assert_eq!(refused.code(), Some(107), "{}", refused.raw);
assert_eq!(
refused.error(),
"You cannot use [object Object] as a query parameter."
);
let me = get(host, "/users/me", &As::user(&token)).await;
assert_eq!(me.body["username"], json!("unset_me"), "{}", me.raw);
let allowed = put(
host,
&format!("/classes/_User/{object_id}"),
&As::user(&token),
&json!({ "email": { "__op": "Delete" } }),
)
.await;
assert_eq!(
allowed.status, 200,
"an email unset is explicitly permitted upstream: {}",
allowed.raw
);
}