mod support;
use axum::http::StatusCode;
use cratefield_testing::MailerMode;
use support::{Spec, fixture};
#[pollster::test]
async fn an_invitation_is_single_use_and_the_response_never_carries_the_token() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
let invited = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"Bob@Example.test","role":"staff"}"#,
)
.await;
assert_eq!(invited.status, StatusCode::ACCEPTED, "{:?}", invited.text());
let mail = kit.harness.mailer.last_message().expect("a mail was sent");
assert_eq!(mail.to, "bob@example.test", "the address is normalized");
assert!(mail.text.contains("/v1/orgs/invitations/accept?token="));
let token = kit.invitation_token();
assert!(
!invited.text().contains(&token),
"the response body carried the raw token: {}",
invited.text()
);
let accepted = kit
.post(
"/v1/orgs/invitations/accept",
"bob|bob@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(accepted.status, StatusCode::OK, "{:?}", accepted.text());
assert_eq!(accepted.json()["id"], id.as_str());
let me = kit.get(&format!("/v1/orgs/{id}/members/me"), "bob").await;
assert_eq!(me.json()["role"], "staff");
assert_eq!(kit.rows_for("org_members", "user_sub", "bob").await, 1);
let replay = kit
.post(
"/v1/orgs/invitations/accept",
"bob|bob@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(replay.status, StatusCode::GONE);
assert_eq!(replay.problem_slug(), "orgs-invitation-gone");
assert_eq!(kit.rows_for("org_members", "user_sub", "bob").await, 1);
}
}
#[pollster::test]
async fn the_invitation_row_holds_only_hashes() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
let invited = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"Bob@Example.test","role":"staff"}"#,
)
.await;
assert_eq!(invited.status, StatusCode::ACCEPTED, "{:?}", invited.text());
let token = kit.invitation_token();
let rows = kit
.harness
.db
.query(&cratefield_core::Statement::new(
"SELECT * FROM org_invitations",
))
.await
.expect("reads the invitation row");
let row = rows.first().expect("one invitation");
for column in [
"id",
"org_id",
"token_hash",
"email_hash",
"role",
"invited_by",
"expires_at",
"created_at",
] {
let value: String = row.get(column).unwrap_or_default();
assert!(
!value.contains(&token),
"`{column}` carries the raw token: {value}"
);
assert!(
!value.to_lowercase().contains("bob@example.test"),
"`{column}` carries the raw address: {value}"
);
}
assert!(
row.get::<Option<String>>("accepted_at")
.unwrap_or(None)
.is_none(),
"a fresh invitation is not yet spent"
);
}
}
#[pollster::test]
async fn an_invitation_is_refused_for_another_address_without_spending_it() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
let _ = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"carol@example.test","role":"staff"}"#,
)
.await;
let token = kit.invitation_token();
let wrong = kit
.post(
"/v1/orgs/invitations/accept",
"bob|bob@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(wrong.status, StatusCode::FORBIDDEN);
assert_eq!(wrong.problem_slug(), "orgs-invitation-for-someone-else");
let right = kit
.post(
"/v1/orgs/invitations/accept",
"carol|carol@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(right.status, StatusCode::OK, "{:?}", right.text());
let me = kit.get(&format!("/v1/orgs/{id}/members/me"), "carol").await;
assert_eq!(me.json()["role"], "staff");
}
}
#[pollster::test]
async fn an_unverified_address_cannot_accept() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
let _ = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"bob@example.test","role":"staff"}"#,
)
.await;
let token = kit.invitation_token();
let unverified = kit
.post(
"/v1/orgs/invitations/accept",
"bob",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(unverified.status, StatusCode::FORBIDDEN);
assert_eq!(unverified.problem_slug(), "orgs-email-unverified");
let verified = kit
.post(
"/v1/orgs/invitations/accept",
"bob|bob@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(verified.status, StatusCode::OK, "{:?}", verified.text());
}
}
#[pollster::test]
async fn an_invitation_that_lapses_is_gone() {
let spec = Spec {
invitation_ttl_secs: 3_600,
..Spec::default()
};
for kit in fixture(&spec).kits {
let id = kit.create_org("alice", "Acme").await;
let _ = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"bob@example.test","role":"staff"}"#,
)
.await;
let token = kit.invitation_token();
kit.clock.advance_secs(3_601);
let response = kit
.post(
"/v1/orgs/invitations/accept",
"bob|bob@example.test",
&format!(r#"{{"token":"{token}"}}"#),
)
.await;
assert_eq!(response.status, StatusCode::GONE);
assert_eq!(response.problem_slug(), "orgs-invitation-gone");
assert_eq!(kit.rows_for("org_members", "user_sub", "bob").await, 0);
}
}
#[pollster::test]
async fn an_invitation_the_mailer_cannot_send_leaves_nothing_behind() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
kit.harness.mailer.set_mode(MailerMode::NotConfigured);
let no_mail = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"bob@example.test","role":"staff"}"#,
)
.await;
assert_eq!(no_mail.status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(no_mail.problem_slug(), "mail-not-configured");
assert_eq!(kit.rows("org_invitations").await, 0);
kit.harness.mailer.set_mode(MailerMode::Fail);
let refused = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"bob@example.test","role":"staff"}"#,
)
.await;
assert_eq!(refused.status, StatusCode::BAD_GATEWAY);
assert_eq!(refused.problem_slug(), "orgs-mail-failed");
assert_eq!(kit.rows("org_invitations").await, 0);
}
}
#[pollster::test]
async fn inviting_is_managing_and_the_role_is_checked() {
for kit in fixture(&Spec::default()).kits {
let id = kit.create_org("alice", "Acme").await;
let _ = kit.add_member(&id, "alice", "bob", "manager").await;
let _ = kit.add_member(&id, "alice", "carol", "staff").await;
let not_a_manager = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"carol",
r#"{"email":"dave@example.test","role":"staff"}"#,
)
.await;
assert_eq!(not_a_manager.status, StatusCode::FORBIDDEN);
assert_eq!(not_a_manager.problem_slug(), "orgs-forbidden");
let owner_role = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"bob",
r#"{"email":"dave@example.test","role":"owner"}"#,
)
.await;
assert_eq!(owner_role.status, StatusCode::FORBIDDEN);
let unknown_role = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"bob",
r#"{"email":"dave@example.test","role":"wizard"}"#,
)
.await;
assert_eq!(unknown_role.status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(unknown_role.problem_slug(), "orgs-unknown-role");
let bad_address = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"bob",
r#"{"email":"not-an-address","role":"staff"}"#,
)
.await;
assert_eq!(bad_address.status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(bad_address.problem_slug(), "orgs-invalid-email");
assert_eq!(kit.rows("org_invitations").await, 0, "nothing was written");
let owner_invite = kit
.post(
&format!("/v1/orgs/{id}/invitations"),
"alice",
r#"{"email":"erin@example.test","role":"owner"}"#,
)
.await;
assert_eq!(owner_invite.status, StatusCode::ACCEPTED);
assert_eq!(kit.rows("org_invitations").await, 1);
}
}