brewlog/tests/cli/helpers.rs
Jon Seager 000f20eab7
feat(server): auto-enable insecure cookies for localhost defaults
Default BREWLOG_RP_ID to "localhost" and BREWLOG_RP_ORIGIN to
"http://localhost:3000". When these localhost defaults are active,
insecure cookies are enabled automatically so local dev works without
setting BREWLOG_INSECURE_COOKIES. A warning is logged at startup when
insecure cookies are active.

The insecure_cookies flag is now threaded through AppState instead of
reading the env var at cookie-set time.
2026-02-09 17:23:09 +00:00

318 lines
10 KiB
Rust

use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use once_cell::sync::Lazy;
use tempfile::TempDir;
use tokio::net::TcpListener;
use webauthn_rs::prelude::*;
use brewlog::application::routes::app_router;
use brewlog::application::state::{AppState, AppStateConfig};
use brewlog::infrastructure::database::Database;
/// Shared test server state.
/// `_temp_dir` is kept alive to prevent the temporary database file from being deleted.
/// The server itself runs on a background thread and dies when the test binary exits.
struct SharedServer {
address: String,
db_url: String,
_temp_dir: TempDir,
}
/// Single shared test server for all CLI tests
static TEST_SERVER: Lazy<Mutex<Option<SharedServer>>> = Lazy::new(|| Mutex::new(None));
#[allow(clippy::expect_used)]
fn test_webauthn() -> Arc<Webauthn> {
let rp_origin = url::Url::parse("http://localhost:0").expect("valid URL");
Arc::new(
WebauthnBuilder::new("localhost", &rp_origin)
.expect("valid RP config")
.rp_name("Brewlog Test")
.build()
.expect("valid WebAuthn"),
)
}
/// Get path to the brewlog binary
pub fn brewlog_bin() -> String {
// Cargo sets this for integration tests - works for both debug and release
env!("CARGO_BIN_EXE_brewlog").to_string()
}
/// Get or start the shared test server - handles mutex poisoning gracefully.
///
/// Spawns the server in-process on a background thread (not as a child process),
/// so the server dies automatically when the test binary exits.
#[allow(clippy::too_many_lines)]
fn ensure_server_started() -> Result<(String, String), String> {
let mut server = match TEST_SERVER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if server.is_none() {
eprintln!("Starting test server...");
// Create temporary database (file-based so create_token() can connect independently)
let temp_dir = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?;
let db_path = temp_dir.path().join("test.db");
let db_url = format!("sqlite:{}", db_path.display());
// Pick a random port
let port = portpicker::pick_unused_port().ok_or("No ports available")?;
let address = format!("http://127.0.0.1:{port}");
let bind_address = format!("127.0.0.1:{port}");
eprintln!("Starting server on {address}");
let db_url_for_thread = db_url.clone();
let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
// Start server on a background thread with its own tokio runtime.
// The thread (and server) live until the test binary exits — no cleanup needed.
std::thread::spawn(move || {
#[allow(clippy::expect_used)]
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
rt.block_on(async move {
#[allow(clippy::expect_used)]
let database = Database::connect(&db_url_for_thread)
.await
.expect("Failed to connect to test database");
let (stats_tx, _stats_rx) = tokio::sync::mpsc::channel(1);
let state = AppState::from_database(
&database,
AppStateConfig {
webauthn: test_webauthn(),
insecure_cookies: true,
foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL
.to_string(),
foursquare_api_key: String::new(),
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
openrouter_api_key: String::new(),
openrouter_model: "openrouter/free".to_string(),
stats_invalidator: brewlog::application::services::StatsInvalidator::new(
stats_tx,
),
},
);
let app = app_router(state);
#[allow(clippy::expect_used)]
let listener = TcpListener::bind(&bind_address)
.await
.expect("Failed to bind to port");
// Signal readiness to the main thread
let _ = tx.send(());
// Run server until the process exits
#[allow(clippy::expect_used)]
axum::serve(listener, app).await.expect("Server failed");
});
});
// Wait for server to be ready
rx.recv_timeout(Duration::from_secs(30))
.map_err(|e| format!("Server failed to start within timeout: {e}"))?;
eprintln!("Server ready on {address}");
*server = Some(SharedServer {
address: address.clone(),
db_url: db_url.clone(),
_temp_dir: temp_dir,
});
}
#[allow(clippy::unwrap_used)]
let srv = server.as_ref().unwrap();
Ok((srv.address.clone(), srv.db_url.clone()))
}
/// Get the shared server address and database URL
pub fn server_info() -> (String, String) {
ensure_server_started().expect("Failed to start test server")
}
/// Create a token for testing by directly inserting into the database
pub fn create_token(name: &str) -> String {
let (_, db_url) = ensure_server_started().expect("Failed to start test server");
#[allow(clippy::expect_used)]
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
rt.block_on(async {
#[allow(clippy::expect_used)]
let pool = sqlx::SqlitePool::connect(&db_url)
.await
.expect("Failed to connect to test database");
// Ensure a user exists (INSERT OR IGNORE to handle concurrent test threads)
let uuid = uuid::Uuid::new_v4().to_string();
#[allow(clippy::expect_used)]
sqlx::query(
"INSERT OR IGNORE INTO users (username, uuid, created_at) VALUES (?, ?, datetime('now'))",
)
.bind("admin")
.bind(&uuid)
.execute(&pool)
.await
.expect("Failed to ensure test user");
#[allow(clippy::expect_used)]
let user_id: i64 =
sqlx::query_scalar::<_, i64>("SELECT id FROM users WHERE username = ?")
.bind("admin")
.fetch_one(&pool)
.await
.expect("Failed to query test user");
// Generate and insert a bearer token
#[allow(clippy::expect_used)]
let token_value =
brewlog::infrastructure::auth::generate_token().expect("Failed to generate token");
let token_hash = brewlog::infrastructure::auth::hash_token(&token_value);
#[allow(clippy::expect_used)]
sqlx::query(
"INSERT INTO tokens (user_id, token_hash, name, created_at) VALUES (?, ?, ?, datetime('now'))",
)
.bind(user_id)
.bind(&token_hash)
.bind(name)
.execute(&pool)
.await
.expect("Failed to insert token");
token_value
})
}
/// Run a brewlog CLI command and return the output
pub fn run_brewlog(args: &[&str], env: &[(&str, &str)]) -> std::process::Output {
let (address, _) = ensure_server_started().expect("Failed to start test server");
let mut cmd = Command::new(brewlog_bin());
cmd.args(args);
cmd.env("BREWLOG_URL", &address);
for (key, value) in env {
cmd.env(key, value);
}
cmd.output().expect("Failed to run brewlog command")
}
/// Generic helper: run a CLI command with auth, assert success, parse JSON, extract ID.
fn create_entity_cli(args: &[&str], token: &str, label: &str) -> String {
let output = run_brewlog(args, &[("BREWLOG_TOKEN", token)]);
if !output.status.success() {
panic!(
"Failed to create {label}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON");
value["id"]
.as_i64()
.unwrap_or_else(|| panic!("{label} id should be numeric"))
.to_string()
}
pub fn create_roaster(name: &str, token: &str) -> String {
create_entity_cli(
&["roaster", "add", "--name", name, "--country", "UK"],
token,
"roaster",
)
}
pub fn create_roast(roaster_id: &str, name: &str, token: &str) -> String {
create_entity_cli(
&[
"roast",
"add",
"--roaster-id",
roaster_id,
"--name",
name,
"--origin",
"Kenya",
"--region",
"Nyeri",
"--producer",
"Coop",
"--process",
"Washed",
"--tasting-notes",
"Blackcurrant",
],
token,
"roast",
)
}
pub fn create_cafe(
name: &str,
city: &str,
country: &str,
latitude: &str,
longitude: &str,
token: &str,
) -> String {
create_entity_cli(
&[
"cafe",
"add",
"--name",
name,
"--city",
city,
"--country",
country,
"--latitude",
latitude,
"--longitude",
longitude,
],
token,
"cafe",
)
}
pub fn create_bag(roast_id: &str, token: &str) -> String {
create_entity_cli(
&["bag", "add", "--roast-id", roast_id, "--amount", "250"],
token,
"bag",
)
}
pub fn create_gear(category: &str, make: &str, model: &str, token: &str) -> String {
create_entity_cli(
&[
"gear",
"add",
"--category",
category,
"--make",
make,
"--model",
model,
],
token,
"gear",
)
}