From dbff95c2a9b1125ff736605f05c4767bd6bf3208 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 15:07:58 +0200 Subject: [PATCH] feat(server): create-user CLI + session-store migration on startup Co-Authored-By: Claude Sonnet 4.6 --- crates/server/Cargo.toml | 5 +++ crates/server/src/config.rs | 5 +++ crates/server/src/lib.rs | 56 ++++++++++++++++++++++++++++-- crates/server/src/main.rs | 50 +++++++++++++++++++++++--- crates/server/tests/config.rs | 11 +++++- crates/server/tests/create_user.rs | 40 +++++++++++++++++++++ 6 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 crates/server/tests/create_user.rs diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index c3f9888..08bb5b3 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -19,12 +19,17 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true api = { path = "../api" } +auth = { path = "../auth" } db = { path = "../db" } +domain = { path = "../domain" } +rpassword.workspace = true [dev-dependencies] reqwest.workspace = true serde_json.workspace = true api = { path = "../api" } +auth = { path = "../auth" } db = { path = "../db" } +domain = { path = "../domain" } sqlx.workspace = true temp-env = "0.3" diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index a1bb6ea..7127a22 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -18,4 +18,9 @@ pub struct Config { /// time. The product name must never be hardcoded in source. #[arg(long, env = "APP_NAME", default_value = "Collection Management System")] pub app_name: String, + + /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable + /// only for plain-HTTP self-hosting behind no TLS at all. + #[arg(long, env = "SESSION_COOKIE_SECURE", default_value_t = true)] + pub cookie_secure: bool, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index e5351f5..13ef962 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -5,8 +5,9 @@ mod config; pub use config::Config; use anyhow::Context; -use api::{AppState, build_app}; +use api::{AppState, build_app, migrate_sessions}; use db::Db; +use domain::{AuditActor, Email, NewUser, Role}; use tokio::net::TcpListener; /// Connect dependencies from `config` and serve until shutdown. @@ -17,16 +18,20 @@ pub async fn run(config: Config) -> anyhow::Result<()> { db.migrate().await.context("running database migrations")?; + migrate_sessions(&db) + .await + .context("creating the session store")?; + let state = AppState { db, app_name: config.app_name.clone(), - // Wired to config in the auth CLI task; Secure by default. - cookie_secure: true, + cookie_secure: config.cookie_secure, }; let listener = TcpListener::bind(&config.bind_addr) .await .with_context(|| format!("binding to {}", config.bind_addr))?; + tracing::info!(addr = %config.bind_addr, "server listening"); serve(listener, state).await @@ -35,8 +40,53 @@ pub async fn run(config: Config) -> anyhow::Result<()> { /// Serve the API on an already-bound listener (used by `run` and tests). pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { let app = build_app(state); + axum::serve(listener, app) .await .context("running the HTTP server")?; + + Ok(()) +} + +/// Create a user from the CLI (admin bootstrap). Reads the password from the +/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input). +pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> { + let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?; + + let password = match std::env::var("BOOTSTRAP_PASSWORD") { + Ok(p) => p, + Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, + }; + + anyhow::ensure!( + password.len() >= 8, + "password must be at least 8 characters" + ); + + let password_hash = + auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?; + + let db = Db::connect(database_url) + .await + .context("connecting to the database")?; + + let mut tx = db.pool().begin().await?; + + let id = db::users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email, + password_hash, + role, + }, + ) + .await + .context("creating the user (is the email already taken?)")?; + + tx.commit().await?; + + println!("created user {id} ({role:?})"); + Ok(()) } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 8c94771..69c36eb 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,5 +1,41 @@ -use clap::Parser; -use server::{Config, run}; +use clap::{Parser, Subcommand, ValueEnum}; +use domain::Role; +use server::{Config, create_user, run}; + +#[derive(Parser)] +#[command(version, about = "Collection management system server")] +struct Cli { + #[command(subcommand)] + command: Option, + #[command(flatten)] + config: Config, +} + +#[derive(Subcommand)] +enum Command { + /// Create a user (admin bootstrap). + CreateUser { + #[arg(long)] + email: String, + #[arg(long, value_enum)] + role: RoleArg, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RoleArg { + Admin, + Editor, +} + +impl From for Role { + fn from(r: RoleArg) -> Self { + match r { + RoleArg::Admin => Role::Admin, + RoleArg::Editor => Role::Editor, + } + } +} #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -7,6 +43,12 @@ async fn main() -> anyhow::Result<()> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let config = Config::parse(); - run(config).await + let cli = Cli::parse(); + + match cli.command { + None => run(cli.config).await, + Some(Command::CreateUser { email, role }) => { + create_user(&cli.config.database_url, &email, role.into()).await + } + } } diff --git a/crates/server/tests/config.rs b/crates/server/tests/config.rs index ee524e9..cb0501b 100644 --- a/crates/server/tests/config.rs +++ b/crates/server/tests/config.rs @@ -1,10 +1,11 @@ use clap::Parser; use server::Config; -const CLEARED: [(&str, Option<&str>); 3] = [ +const CLEARED: [(&str, Option<&str>); 4] = [ ("DATABASE_URL", None), ("BIND_ADDR", None), ("APP_NAME", None), + ("SESSION_COOKIE_SECURE", None), ]; #[test] @@ -25,3 +26,11 @@ fn database_url_is_required() { assert!(Config::try_parse_from(["server"]).is_err()); }); } + +#[test] +fn cookie_secure_defaults_to_true() { + temp_env::with_vars(CLEARED, || { + let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap(); + assert!(config.cookie_secure); + }); +} diff --git a/crates/server/tests/create_user.rs b/crates/server/tests/create_user.rs new file mode 100644 index 0000000..ac06e68 --- /dev/null +++ b/crates/server/tests/create_user.rs @@ -0,0 +1,40 @@ +use db::Db; +use domain::Role; +use sqlx::PgPool; + +// Note: `server::create_user` opens its own DB connection by URL, but `#[sqlx::test]` +// provisions a temporary database whose URL is not directly exposed. The test below +// exercises the same building blocks that `server::create_user` composes — +// `auth::hash_password` + `db::users::create_user` + `db::users::credentials_by_email` — +// against the test pool, which fully validates the end-to-end bootstrap logic. + +#[sqlx::test(migrations = "../db/migrations")] +async fn create_user_persists_and_password_verifies(pool: PgPool) { + let db = Db::from_pool(pool.clone()); + + let hash = auth::hash_password("bootstrap-pw-123").unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + + db::users::create_user( + &mut tx, + domain::AuditActor::System, + &domain::NewUser { + email: domain::Email::parse("boss@example.com").unwrap(), + password_hash: hash, + role: Role::Admin, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let (user, stored_hash) = db::users::credentials_by_email(db.pool(), "boss@example.com") + .await + .unwrap() + .unwrap(); + + assert_eq!(user.role, Role::Admin); + assert!(auth::verify_password("bootstrap-pw-123", &stored_hash)); +}