diff --git a/Cargo.lock b/Cargo.lock index 73ae835..b452362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3344,6 +3344,7 @@ dependencies = [ "serde", "similar-asserts", "sqlx", + "tempfile", "tokio", "tokio-util", "toml 0.9.11+spec-1.1.0", diff --git a/Cargo.toml b/Cargo.toml index cfb4155..5c296f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,6 +156,7 @@ rstest.workspace = true rustical_store_sqlite = { workspace = true, features = ["test"] } insta.workspace = true similar-asserts.workspace = true +tempfile = "3.24" [dependencies] rustical_store.workspace = true diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e02dadb..02f3120 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,7 +8,7 @@ use rustical_frontend::FrontendConfig; mod health; pub mod membership; -mod principals; +pub mod principals; pub use health::{HealthArgs, cmd_health}; pub use principals::{PrincipalsArgs, cmd_principals}; diff --git a/src/commands/principals.rs b/src/commands/principals.rs index 4c7353c..6fe53b3 100644 --- a/src/commands/principals.rs +++ b/src/commands/principals.rs @@ -1,56 +1,49 @@ use super::membership::MembershipArgs; use crate::{config::Config, get_data_stores, membership::cmd_membership}; use clap::{Parser, Subcommand}; -use figment::{ - Figment, - providers::{Env, Format, Toml}, -}; use password_hash::{PasswordHasher, SaltString, rand_core::OsRng}; use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType}; #[derive(Parser, Debug)] pub struct PrincipalsArgs { - #[arg(short, long, env, default_value = "/etc/rustical/config.toml")] - config_file: String, - #[command(subcommand)] - command: Command, + pub command: PrincipalsCommand, } #[derive(Parser, Debug)] -struct CreateArgs { - id: String, +pub struct CreateArgs { + pub id: String, #[arg(value_enum, short, long)] - principal_type: Option, + pub principal_type: Option, #[arg(short, long)] - name: Option, + pub name: Option, #[arg(long, help = "Ask for password input")] - password: bool, + pub password: bool, } #[derive(Parser, Debug)] -struct RemoveArgs { - id: String, +pub struct RemoveArgs { + pub id: String, } #[derive(Parser, Debug)] -struct EditArgs { - id: String, +pub struct EditArgs { + pub id: String, #[arg(long, help = "Ask for password input")] - password: bool, + pub password: bool, #[arg( long, help = "Remove password (If you only want to use OIDC for example)" )] - remove_password: bool, + pub remove_password: bool, #[arg(short, long, help = "Change principal displayname")] - name: Option, + pub name: Option, #[arg(value_enum, short, long, help = "Change the principal type")] - principal_type: Option, + pub principal_type: Option, } #[derive(Debug, Subcommand)] -enum Command { +pub enum PrincipalsCommand { List, Create(CreateArgs), Remove(RemoveArgs), @@ -59,16 +52,11 @@ enum Command { } #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] -pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { - let config: Config = Figment::new() - .merge(Toml::file(&args.config_file)) - .merge(Env::prefixed("RUSTICAL_").split("__")) - .extract()?; - +pub async fn cmd_principals(args: PrincipalsArgs, config: Config) -> anyhow::Result<()> { let (_, _, _, principal_store, _) = get_data_stores(true, &config.data_store).await?; match args.command { - Command::List => { + PrincipalsCommand::List => { for principal in principal_store.get_principals().await? { println!( "{} (displayname={}) [{}]", @@ -78,7 +66,7 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { ); } } - Command::Create(CreateArgs { + PrincipalsCommand::Create(CreateArgs { id, principal_type, name, @@ -112,11 +100,11 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { .await?; println!("Principal created"); } - Command::Remove(RemoveArgs { id }) => { + PrincipalsCommand::Remove(RemoveArgs { id }) => { principal_store.remove_principal(&id).await?; println!("Principal {id} removed"); } - Command::Edit(EditArgs { + PrincipalsCommand::Edit(EditArgs { id, remove_password, password, @@ -152,7 +140,7 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { principal_store.insert_principal(principal, true).await?; println!("Principal {id} updated"); } - Command::Membership(args) => { + PrincipalsCommand::Membership(args) => { cmd_membership(principal_store.as_ref(), args).await?; } } diff --git a/src/lib.rs b/src/lib.rs index 39ad25a..d46c679 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,8 +110,11 @@ pub async fn cmd_default( args: Args, config: Config, start_notifier: Option>, + tracing: bool, ) -> Result<()> { - setup_tracing(&config.tracing); + if tracing { + setup_tracing(&config.tracing); + } let (addr_store, cal_store, subscription_store, principal_store, update_recv) = get_data_stores(!args.no_migrations, &config.data_store).await?; diff --git a/src/main.rs b/src/main.rs index 1cc5d41..8ff1a4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,14 +21,16 @@ async fn main() -> Result<()> { match args.command { Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args), - Some(Command::Principals(principals_args)) => cmd_principals(principals_args).await, + Some(Command::Principals(principals_args)) => { + cmd_principals(principals_args, parse_config()?).await + } Some(Command::Health(health_args)) => { let config: Config = parse_config()?; cmd_health(config.http, health_args).await } None => { let config: Config = parse_config()?; - cmd_default(args, config, None).await + cmd_default(args, config, None, true).await } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 598b3a8..0f7c530 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,27 +3,38 @@ use rustical::{ config::{Config, DataStoreConfig, HttpConfig, SqliteDataStoreConfig}, }; use std::{ + collections::HashSet, net::{Ipv4Addr, SocketAddrV4, TcpListener}, - sync::Arc, + sync::{Arc, Mutex, OnceLock}, thread::{self, JoinHandle}, }; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; +// When running multiple integration tests we need to make sure that they don't get the same port +static BOUND_PORTS: OnceLock>> = OnceLock::new(); + pub fn find_free_port() -> Option { + let bound_ports = BOUND_PORTS.get_or_init(Mutex::default); + let mut bound_ports_write = bound_ports.lock().unwrap(); let mut port = 15000; // Frees the socket on drop such that this function returns a free port - while TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_err() { + while TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_err() + || bound_ports_write.contains(&port) + { port += 1; if port >= 16000 { return None; } } + bound_ports_write.insert(port); Some(port) } -pub fn rustical_process() -> (CancellationToken, u16, JoinHandle<()>, Arc) { +pub fn rustical_process( + db_url: Option, +) -> (CancellationToken, u16, JoinHandle<()>, Arc) { let port = find_free_port().unwrap(); let token = CancellationToken::new(); let cloned_token = token.clone(); @@ -41,7 +52,7 @@ pub fn rustical_process() -> (CancellationToken, u16, JoinHandle<()>, Arc (CancellationToken, u16, JoinHandle<()>, Arc(inner: F) +pub async fn test_runner(db_path: Option, inner: F) where O: IntoFuture, // ::IntoFuture: UnwindSafe, F: FnOnce(String) -> O, { // Start RustiCal process - let (token, port, main_process, start_notify) = rustical_process(); + let (token, port, main_process, start_notify) = rustical_process(db_path); let origin = format!("http://localhost:{port}"); // Wait for RustiCal server to listen @@ -32,7 +39,7 @@ where #[tokio::test] async fn test_ping() { - test_runner(async |origin| { + test_runner(None, async |origin| { let resp = reqwest::get(origin.clone() + "/ping").await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -42,3 +49,77 @@ async fn test_ping() { }) .await } + +// When setting a use password from the CLI we effectively have two processes accessing the same +// database: The server and the CLI. +// This test ensures that the server correctly picks up the changes made by the CLI. +#[tokio::test] +async fn test_initial_setup() { + let db_tempfile = tempfile::NamedTempFile::with_suffix(".rustical-test.sqlite3").unwrap(); + let db_path = db_tempfile.path().to_string_lossy().into_owned(); + + test_runner(Some(db_path.clone()), async |origin| { + // Create principal + cmd_principals( + PrincipalsArgs { + command: PrincipalsCommand::Create(CreateArgs { + id: "user".to_owned(), + name: Some("Test User".to_owned()), + password: false, + principal_type: Some(PrincipalType::Individual), + }), + }, + Config { + data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig { + db_url: db_path.clone(), + run_repairs: true, + skip_broken: false, + }), + http: Default::default(), + frontend: Default::default(), + oidc: None, + tracing: Default::default(), + dav_push: Default::default(), + nextcloud_login: Default::default(), + caldav: Default::default(), + }, + ) + .await + .unwrap(); + + // Bodge to set password without using command (since that reads stdin) + let db = create_db_pool(&db_path, false).await.unwrap(); + let principal_store = SqlitePrincipalStore::new(db); + let app_token = "token"; + principal_store + .add_app_token("user", "Test Token".to_owned(), app_token.to_owned()) + .await + .unwrap(); + + let url = origin.clone() + "/caldav/principal/user"; + let resp = reqwest::Client::new() + .request(Method::from_bytes(b"PROPFIND").unwrap(), &url) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let resp = reqwest::Client::new() + .request(Method::from_bytes(b"PROPFIND").unwrap(), &url) + .basic_auth("user", Some("token")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::MULTI_STATUS); + + principal_store.remove_principal("user").await.unwrap(); + + let resp = reqwest::Client::new() + .request(Method::from_bytes(b"PROPFIND").unwrap(), &url) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + }) + .await; +}