//! Registration modes: open vs invite-only.
//!
//! These tests verify that the registration mode (controlled by
//! FROGUE_REGISTRATION_MODE) works correctly, and that invite-only mode
//! enforces invite requirements.
mod support;
use frogue::entities::{invites, users};
use miette::IntoDiagnostic;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, PaginatorTrait,
QueryFilter, Set,
};
use support::{Client, Server, free_port};
const PASSWORD: &str = "correct horse battery staple";
fn spawn() -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
(server, port)
}
fn spawn_with_env(env: &[(&str, &str)]) -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn_with_env(&format!("127.0.0.1:{port}"), env);
server.wait_for_connection(port);
(server, port)
}
async fn connect(server: &Server) -> DatabaseConnection {
Database::connect(server.database_url())
.await
.expect("the server's database should be reachable")
}
fn register(client: &mut Client, name: &str, email: &str, password: &str) -> support::Response {
client.post_form(
"/register",
&[("name", name), ("email", email), ("password", password)],
)
}
fn register_with_invite(
client: &mut Client,
name: &str,
email: &str,
password: &str,
invite: &str,
) -> support::Response {
client.post_form(
"/register",
&[
("name", name),
("email", email),
("password", password),
("invite", invite),
],
)
}
#[tokio::test]
async fn default_mode_is_open() -> miette::Result<()> {
let (server, port) = spawn();
let mut client = Client::new(port);
let response = register(&mut client, "alice", "alice@example.org", PASSWORD);
assert_eq!(
response.status, 303,
"registration should succeed in default (open) mode"
);
Ok(())
}
#[tokio::test]
async fn explicit_open_mode_behaves_like_default() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "open")]);
let mut client = Client::new(port);
let response = register(&mut client, "alice", "alice@example.org", PASSWORD);
assert_eq!(
response.status, 303,
"registration should succeed in explicit open mode"
);
Ok(())
}
#[tokio::test]
async fn invite_only_blocks_registration_without_invite() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let db = connect(&server).await;
// Bootstrap: create a user directly in the database
let user = users::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
name: Set("alice".to_string()),
email: Set("alice@example.org".to_string()),
password_hash: Set(
frogue::auth::hash_password(PASSWORD).expect("password should be hashable")
),
..Default::default()
};
user.insert(&db).await.into_diagnostic()?;
// Now try to register without an invite
let mut client = Client::new(port);
let response = register(&mut client, "bob", "bob@example.org", PASSWORD);
assert_eq!(
response.status, 403,
"registration without invite should be refused"
);
assert!(
response.body.contains("invite-only"),
"error message should mention invite-only"
);
assert!(
!response.body.contains("<form"),
"refused page should not show a registration form"
);
// Verify no user was created
let bob = users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?;
assert!(bob.is_none(), "bob should not be in the database");
Ok(())
}
#[tokio::test]
async fn first_user_can_register_even_in_invite_only_mode() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let _db = connect(&server).await;
// First registration (bootstrap) should work
let mut client = Client::new(port);
let response = register(&mut client, "alice", "alice@example.org", PASSWORD);
assert_eq!(
response.status, 303,
"first registration should succeed even in invite-only mode (bootstrap)"
);
// Second registration without invite should fail
let mut client2 = Client::new(port);
let response2 = register(&mut client2, "bob", "bob@example.org", PASSWORD);
assert_eq!(
response2.status, 403,
"second registration without invite should be refused"
);
Ok(())
}
#[tokio::test]
async fn registration_with_unknown_or_malformed_invite_is_refused() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let db = connect(&server).await;
// Bootstrap
let user = users::ActiveModel {
uuid: Set(uuid::Uuid::new_v4()),
name: Set("alice".to_string()),
email: Set("alice@example.org".to_string()),
password_hash: Set(
frogue::auth::hash_password(PASSWORD).expect("password should be hashable")
),
..Default::default()
};
user.insert(&db).await.into_diagnostic()?;
// Malformed UUID
let mut client1 = Client::new(port);
let response1 = register_with_invite(
&mut client1,
"bob",
"bob@example.org",
PASSWORD,
"not-a-uuid",
);
assert_eq!(response1.status, 403, "malformed invite should be refused");
// Valid UUID format but not in database
let mut client2 = Client::new(port);
let response2 = register_with_invite(
&mut client2,
"bob",
"bob@example.org",
PASSWORD,
"550e8400-e29b-41d4-a716-446655440000",
);
assert_eq!(response2.status, 403, "unknown invite should be refused");
// Verify no user was created
let bob = users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?;
assert!(bob.is_none(), "bob should not be in the database");
Ok(())
}
#[tokio::test]
async fn inviter_can_generate_and_redeem_an_invite() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let db = connect(&server).await;
// Bootstrap alice
let mut client_alice = Client::new(port);
let response = register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
assert_eq!(response.status, 303, "bootstrap should succeed");
// Alice generates an invite
let response = client_alice.post_form("/~alice/invites", &[]);
assert_eq!(response.status, 303, "generating an invite should redirect");
// Fetch the invites page to get the UUID
let response = client_alice.get("/~alice/invites");
assert_eq!(response.status, 200);
// Extract the invite UUID from the page
let body = &response.body;
let invite_start = body
.find("/register/?invite=")
.expect("invite link should be present");
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid = &body[uuid_start..uuid_end];
// Bob uses the invite to register
let mut client_bob = Client::new(port);
let response = client_bob.get(&format!("/register/?invite={}", invite_uuid));
assert_eq!(
response.status, 200,
"GET /register with valid invite should show the form"
);
assert!(
response.body.contains("<form"),
"valid invite should show the registration form"
);
// Bob posts the registration with the invite
let response = register_with_invite(
&mut client_bob,
"bob",
"bob@example.org",
PASSWORD,
invite_uuid,
);
assert_eq!(
response.status, 303,
"registration with valid invite should succeed"
);
// Verify the invite is marked as used
let invite = invites::Entity::find()
.filter(invites::Column::Uuid.eq(invite_uuid))
.one(&db)
.await
.into_diagnostic()?
.expect("invite should exist");
assert!(invite.used_by.is_some(), "invite should be marked as used");
Ok(())
}
#[tokio::test]
async fn a_used_invite_cannot_be_reused() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let db = connect(&server).await;
// Bootstrap alice
let mut client_alice = Client::new(port);
register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
// Alice generates an invite
client_alice.post_form("/~alice/invites", &[]);
// Extract the invite UUID
let response = client_alice.get("/~alice/invites");
let body = &response.body;
let invite_start = body.find("/register/?invite=").unwrap();
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid = &body[uuid_start..uuid_end];
// Bob uses the invite
let mut client_bob = Client::new(port);
let response = register_with_invite(
&mut client_bob,
"bob",
"bob@example.org",
PASSWORD,
invite_uuid,
);
assert_eq!(response.status, 303);
// Carol tries to use the same invite
let mut client_carol = Client::new(port);
let response = register_with_invite(
&mut client_carol,
"carol",
"carol@example.org",
PASSWORD,
invite_uuid,
);
assert_eq!(response.status, 403, "reusing an invite should be refused");
// Verify carol is not in the database
let carol = users::Entity::find()
.filter(users::Column::Name.eq("carol"))
.one(&db)
.await
.into_diagnostic()?;
assert!(carol.is_none(), "carol should not be in the database");
Ok(())
}
#[tokio::test]
async fn expired_invites_are_refused() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[
("FROGUE_REGISTRATION_MODE", "invite_only"),
("FROGUE_INVITES_VALIDITY_SECONDS", "1"),
]);
// Bootstrap alice
let mut client_alice = Client::new(port);
register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
// Alice generates an invite
client_alice.post_form("/~alice/invites", &[]);
// Extract the invite UUID
let response = client_alice.get("/~alice/invites");
let body = &response.body;
let invite_start = body.find("/register/?invite=").unwrap();
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid = &body[uuid_start..uuid_end];
// Wait for the invite to expire
std::thread::sleep(std::time::Duration::from_secs(2));
// Bob tries to use the expired invite
let mut client_bob = Client::new(port);
let response = client_bob.get(&format!("/register/?invite={}", invite_uuid));
assert_eq!(
response.status, 403,
"expired invite should be refused on GET"
);
let response = register_with_invite(
&mut client_bob,
"bob",
"bob@example.org",
PASSWORD,
invite_uuid,
);
assert_eq!(
response.status, 403,
"expired invite should be refused on POST"
);
Ok(())
}
#[tokio::test]
async fn daily_invite_quota_is_enforced() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[
("FROGUE_REGISTRATION_MODE", "invite_only"),
("FROGUE_INVITES_ALLOWED_PER_DAY", "2"),
]);
let db = connect(&server).await;
// Bootstrap alice
let mut client_alice = Client::new(port);
register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
// Generate 2 invites (should succeed)
let response1 = client_alice.post_form("/~alice/invites", &[]);
assert_eq!(response1.status, 303);
let response2 = client_alice.post_form("/~alice/invites", &[]);
assert_eq!(response2.status, 303);
// Third invite should be refused
let response3 = client_alice.post_form("/~alice/invites", &[]);
assert_eq!(
response3.status,
200, // Page re-rendered with error
"third invite should be refused"
);
assert!(
response3.body.contains("maximum number of invites"),
"error message should mention quota"
);
// Verify only 2 invites in database
let invite_count = invites::Entity::find()
.filter(invites::Column::CreatedBy.eq(1)) // alice's id
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(invite_count, 2, "only 2 invites should be in the database");
Ok(())
}
#[tokio::test]
async fn invites_page_is_owner_only() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
// Bootstrap alice
let mut client_alice = Client::new(port);
register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
// Anonymous access should be refused
let mut anon = Client::new(port);
let response = anon.get("/~alice/invites");
assert_eq!(response.status, 401, "anonymous access should be refused");
// Alice creates an invite for Bob
client_alice.post_form("/~alice/invites", &[]);
let response = client_alice.get("/~alice/invites");
let body = &response.body;
let invite_start = body.find("/register/?invite=").unwrap();
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid = &body[uuid_start..uuid_end];
// Bob uses the invite to register
let mut client_bob = Client::new(port);
let response = register_with_invite(
&mut client_bob,
"bob",
"bob@example.org",
PASSWORD,
invite_uuid,
);
assert_eq!(response.status, 303, "bob should register with invite");
// Bob tries to access alice's invites
let response = client_bob.get("/~alice/invites");
assert_eq!(
response.status, 403,
"other user's invites should be refused"
);
// Alice can access her own invites
let response = client_alice.get("/~alice/invites");
assert_eq!(
response.status, 200,
"owner should access their own invites"
);
Ok(())
}
#[tokio::test]
async fn open_mode_ignores_invites() -> miette::Result<()> {
let (server, port) = spawn();
// Register with a bogus invite value (should be ignored in open mode)
let mut client = Client::new(port);
let response = register_with_invite(
&mut client,
"alice",
"alice@example.org",
PASSWORD,
"not-a-real-uuid",
);
assert_eq!(
response.status, 303,
"open mode should ignore invite and register successfully"
);
Ok(())
}
#[tokio::test]
async fn the_invite_tree_records_who_invited_whom() -> miette::Result<()> {
let (server, port) = spawn_with_env(&[("FROGUE_REGISTRATION_MODE", "invite_only")]);
let db = connect(&server).await;
// Bootstrap alice
let mut client_alice = Client::new(port);
register(&mut client_alice, "alice", "alice@example.org", PASSWORD);
// Extract alice's user record
let alice = users::Entity::find()
.filter(users::Column::Name.eq("alice"))
.one(&db)
.await
.into_diagnostic()?
.expect("alice should exist");
assert_eq!(
alice.invited_by, None,
"alice (bootstrap) should have no inviter"
);
// Alice invites bob
client_alice.post_form("/~alice/invites", &[]);
let response = client_alice.get("/~alice/invites");
let body = &response.body;
let invite_start = body.find("/register/?invite=").unwrap();
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid_1 = body[uuid_start..uuid_end].to_string();
let mut client_bob = Client::new(port);
register_with_invite(
&mut client_bob,
"bob",
"bob@example.org",
PASSWORD,
&invite_uuid_1,
);
// Verify bob was invited by alice
let bob = users::Entity::find()
.filter(users::Column::Name.eq("bob"))
.one(&db)
.await
.into_diagnostic()?
.expect("bob should exist");
assert_eq!(
bob.invited_by,
Some(alice.id),
"bob should be invited by alice"
);
// Verify the invite's used_by is bob
let invite1 = invites::Entity::find()
.filter(invites::Column::Uuid.eq(&invite_uuid_1))
.one(&db)
.await
.into_diagnostic()?
.expect("invite should exist");
assert_eq!(
invite1.used_by,
Some(bob.id),
"invite should be used by bob"
);
// Bob invites carol
client_bob.post_form("/~bob/invites", &[]);
let response = client_bob.get("/~bob/invites");
let body = &response.body;
let invite_start = body.find("/register/?invite=").unwrap();
let uuid_start = invite_start + "/register/?invite=".len();
let uuid_end = body[uuid_start..]
.find('"')
.map(|i| uuid_start + i)
.unwrap_or(body.len());
let invite_uuid_2 = body[uuid_start..uuid_end].to_string();
let mut client_carol = Client::new(port);
register_with_invite(
&mut client_carol,
"carol",
"carol@example.org",
PASSWORD,
&invite_uuid_2,
);
// Verify carol was invited by bob
let carol = users::Entity::find()
.filter(users::Column::Name.eq("carol"))
.one(&db)
.await
.into_diagnostic()?
.expect("carol should exist");
assert_eq!(
carol.invited_by,
Some(bob.id),
"carol should be invited by bob"
);
// Verify the second invite's used_by is carol
let invite2 = invites::Entity::find()
.filter(invites::Column::Uuid.eq(&invite_uuid_2))
.one(&db)
.await
.into_diagnostic()?
.expect("second invite should exist");
assert_eq!(
invite2.used_by,
Some(carol.id),
"second invite should be used by carol"
);
Ok(())
}