diff --git a/README.md b/README.md index d518734..a6928bb 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,8 @@ brewlog brew Manage brews (add, list, get, delete — no update) brewlog cafe Manage cafes brewlog cup Manage cups (cafe visits with ratings) brewlog token Manage API tokens (create, list, revoke) -brewlog backup Export all data to JSON on stdout -brewlog restore --file F Restore data from a JSON backup into an empty database +brewlog backup Export all data to JSON on stdout (requires BREWLOG_TOKEN) +brewlog restore --file F Restore data from a JSON backup into an empty database (requires BREWLOG_TOKEN) ``` Use `brewlog --help` for detailed options on any command. @@ -197,6 +197,8 @@ Migrations run automatically on server startup. ### Backup & Restore +Backup and restore go through the API and require authentication (`BREWLOG_TOKEN`). + ```bash # Export all data to JSON brewlog backup > backup.json @@ -205,7 +207,10 @@ brewlog backup > backup.json brewlog restore --file backup.json ``` -Both commands accept `--database-url` (or `BREWLOG_DATABASE_URL`) to target a specific database. +The API endpoints are also available directly: + +- `GET /api/v1/backup` — export all data as JSON (requires auth) +- `POST /api/v1/backup/restore` — restore from a JSON backup (requires auth, database must be empty) ## Installation diff --git a/src/application/routes/backup.rs b/src/application/routes/backup.rs new file mode 100644 index 0000000..c15bba2 --- /dev/null +++ b/src/application/routes/backup.rs @@ -0,0 +1,39 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{ApiError, AppError}; +use crate::application::server::AppState; +use crate::infrastructure::backup::BackupData; + +/// GET /api/v1/backup — export all data as JSON (requires authentication) +pub(crate) async fn export_backup( + State(state): State, + _auth_user: AuthenticatedUser, +) -> Result, ApiError> { + let data = state + .backup_service + .export() + .await + .map_err(|e| AppError::unexpected(e.to_string()))?; + Ok(Json(data)) +} + +/// POST /api/v1/backup/restore — restore from JSON backup (requires authentication) +pub(crate) async fn restore_backup( + State(state): State, + _auth_user: AuthenticatedUser, + Json(payload): Json, +) -> Result { + state.backup_service.restore(payload).await.map_err(|e| { + let msg = e.to_string(); + if msg.contains("not empty") { + ApiError::from(AppError::Conflict(msg)) + } else { + ApiError::from(AppError::unexpected(msg)) + } + })?; + Ok(StatusCode::NO_CONTENT.into_response()) +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 5ec646b..6b10709 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod backup; pub mod bags; pub mod brews; pub mod cafes; @@ -17,6 +18,7 @@ pub mod tokens; pub(crate) use auth::is_authenticated; use askama::Template; +use axum::extract::DefaultBodyLimit; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{get, post}; @@ -94,7 +96,12 @@ pub fn app_router(state: AppState) -> axum::Router { "/tokens", post(tokens::create_token).get(tokens::list_tokens), ) - .route("/tokens/:id/revoke", post(tokens::revoke_token)); + .route("/tokens/:id/revoke", post(tokens::revoke_token)) + .route("/backup", get(backup::export_backup)) + .route( + "/backup/restore", + post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), + ); axum::Router::new() .route("/", get(home::home_page)) diff --git a/src/application/server.rs b/src/application/server.rs index 53d06b5..873b591 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -14,6 +14,7 @@ use crate::domain::repositories::{ }; use crate::domain::users::NewUser; use crate::infrastructure::auth::hash_password; +use crate::infrastructure::backup::BackupService; use crate::infrastructure::database::Database; use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::brews::SqlBrewRepository; @@ -55,6 +56,7 @@ pub struct AppState { pub foursquare_api_key: String, pub openrouter_api_key: String, pub openrouter_model: String, + pub backup_service: Arc, } impl AppState { @@ -76,6 +78,7 @@ impl AppState { foursquare_api_key: String, openrouter_api_key: String, openrouter_model: String, + backup_service: Arc, ) -> Self { Self { roaster_repo, @@ -94,6 +97,7 @@ impl AppState { foursquare_api_key, openrouter_api_key, openrouter_model, + backup_service, } } } @@ -119,6 +123,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let session_repo: Arc = Arc::new(SqlSessionRepository::new(database.clone_pool())); + let backup_service = Arc::new(BackupService::new(database.clone_pool())); + // Bootstrap admin user if no users exist bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?; @@ -139,6 +145,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { config.foursquare_api_key, config.openrouter_api_key, config.openrouter_model, + backup_service, ); let listener = TcpListener::bind(config.bind_address) diff --git a/src/infrastructure/client/backup.rs b/src/infrastructure/client/backup.rs new file mode 100644 index 0000000..63431fd --- /dev/null +++ b/src/infrastructure/client/backup.rs @@ -0,0 +1,44 @@ +use anyhow::{Context, Result}; +use reqwest::StatusCode; + +use crate::infrastructure::backup::BackupData; + +use super::BrewlogClient; + +pub struct BackupClient<'a> { + inner: &'a BrewlogClient, +} + +impl<'a> BackupClient<'a> { + pub(crate) fn new(inner: &'a BrewlogClient) -> Self { + Self { inner } + } + + pub async fn export(&self) -> Result { + let url = self.inner.endpoint("api/v1/backup")?; + let response = self + .inner + .request(reqwest::Method::GET, url) + .send() + .await + .context("failed to issue backup export request")?; + + self.inner.handle_response(response).await + } + + pub async fn restore(&self, data: &BackupData) -> Result<()> { + let url = self.inner.endpoint("api/v1/backup/restore")?; + let response = self + .inner + .request(reqwest::Method::POST, url) + .json(data) + .send() + .await + .context("failed to issue backup restore request")?; + + match response.status() { + StatusCode::NO_CONTENT => Ok(()), + _ => Err(self.inner.response_error(response).await), + } + } +} diff --git a/src/infrastructure/client/mod.rs b/src/infrastructure/client/mod.rs index ef3b8be..9b9eda2 100644 --- a/src/infrastructure/client/mod.rs +++ b/src/infrastructure/client/mod.rs @@ -1,3 +1,4 @@ +pub mod backup; pub mod bags; pub mod brews; pub mod cafes; @@ -44,6 +45,10 @@ impl BrewlogClient { Self::new(url) } + pub fn backup(&self) -> backup::BackupClient<'_> { + backup::BackupClient::new(self) + } + pub fn roasters(&self) -> roasters::RoastersClient<'_> { roasters::RoastersClient::new(self) } diff --git a/src/main.rs b/src/main.rs index 1f0bf97..95cdd58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ use anyhow::Result; use brewlog::application::{ServerConfig, serve}; -use brewlog::infrastructure::backup::{BackupData, BackupService}; +use brewlog::infrastructure::backup::BackupData; use brewlog::infrastructure::client::BrewlogClient; -use brewlog::infrastructure::database::Database; use brewlog::presentation::cli::{ Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens, }; @@ -58,10 +57,9 @@ async fn main() -> Result<()> { let client = BrewlogClient::from_base_url(&cli.api_url)?; tokens::run(&client, command).await } - Commands::Backup(cmd) => { - let database = Database::connect(&cmd.database_url).await?; - let service = BackupService::new(database.clone_pool()); - let data = service.export().await?; + Commands::Backup(_cmd) => { + let client = BrewlogClient::from_base_url(&cli.api_url)?; + let data = client.backup().export().await?; let json = serde_json::to_string_pretty(&data)?; println!("{json}"); Ok(()) @@ -69,9 +67,8 @@ async fn main() -> Result<()> { Commands::Restore(cmd) => { let contents = std::fs::read_to_string(&cmd.file)?; let data: BackupData = serde_json::from_str(&contents)?; - let database = Database::connect(&cmd.database_url).await?; - let service = BackupService::new(database.clone_pool()); - service.restore(data).await?; + let client = BrewlogClient::from_base_url(&cli.api_url)?; + client.backup().restore(&data).await?; eprintln!("Restore complete."); Ok(()) } diff --git a/src/presentation/cli/backup.rs b/src/presentation/cli/backup.rs index c28dc3f..cf53dc9 100644 --- a/src/presentation/cli/backup.rs +++ b/src/presentation/cli/backup.rs @@ -1,26 +1,10 @@ use clap::Args; #[derive(Debug, Args)] -pub struct BackupCommand { - /// Database URL to back up from - #[arg( - long, - env = "BREWLOG_DATABASE_URL", - default_value = "sqlite://brewlog.db" - )] - pub database_url: String, -} +pub struct BackupCommand; #[derive(Debug, Args)] pub struct RestoreCommand { - /// Database URL to restore into (must be an empty database) - #[arg( - long, - env = "BREWLOG_DATABASE_URL", - default_value = "sqlite://brewlog.db" - )] - pub database_url: String, - /// Path to the backup JSON file #[arg(long)] pub file: String, diff --git a/tests/cli/backup_cli.rs b/tests/cli/backup_cli.rs new file mode 100644 index 0000000..2194a90 --- /dev/null +++ b/tests/cli/backup_cli.rs @@ -0,0 +1,37 @@ +use super::helpers::{create_token, run_brewlog}; + +#[test] +fn backup_produces_valid_json() { + let token = create_token("backup-test"); + + let output = run_brewlog(&["backup"], &[("BREWLOG_TOKEN", &token)]); + + assert!( + output.status.success(), + "backup command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let data: serde_json::Value = + serde_json::from_str(&stdout).expect("backup output is not valid JSON"); + + assert_eq!(data["version"], 1); + assert!(data["roasters"].is_array()); + assert!(data["roasts"].is_array()); + assert!(data["bags"].is_array()); + assert!(data["gear"].is_array()); + assert!(data["brews"].is_array()); + assert!(data["cafes"].is_array()); + assert!(data["timeline_events"].is_array()); +} + +#[test] +fn backup_requires_auth() { + let output = run_brewlog(&["backup"], &[]); + + assert!( + !output.status.success(), + "backup command should fail without auth" + ); +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 9b1a95a..ab01bbc 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -1,3 +1,4 @@ +pub mod backup_cli; pub mod bags_cli; pub mod brews_cli; pub mod cafes_cli; diff --git a/tests/server/backup.rs b/tests/server/backup.rs index 43ff922..ef7e575 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -22,6 +22,8 @@ use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; +use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth}; + struct TestDb { roaster_repo: Arc, roast_repo: Arc, @@ -454,3 +456,145 @@ async fn backup_empty_database() { let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize"); assert_eq!(parsed.version, 1); } + +// --- API-level tests --- + +#[tokio::test] +async fn backup_export_requires_auth() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + + let response = client + .get(app.api_url("/backup")) + .send() + .await + .expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn backup_export_returns_data() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + // Create some data first + create_default_roaster(&app).await; + + let response = client + .get(app.api_url("/backup")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let data: BackupData = response.json().await.expect("failed to parse backup data"); + assert_eq!(data.version, 1); + assert_eq!(data.roasters.len(), 1); + assert_eq!(data.roasters[0].name, "Test Roasters"); +} + +#[tokio::test] +async fn backup_restore_requires_auth() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + + let backup_data = BackupData { + version: 1, + created_at: chrono::Utc::now(), + roasters: vec![], + gear: vec![], + roasts: vec![], + bags: vec![], + brews: vec![], + cafes: vec![], + timeline_events: vec![], + }; + + let response = client + .post(app.api_url("/backup/restore")) + .json(&backup_data) + .send() + .await + .expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn backup_restore_non_empty_db_returns_conflict() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + // Create data to make the database non-empty + create_default_roaster(&app).await; + + let backup_data = BackupData { + version: 1, + created_at: chrono::Utc::now(), + roasters: vec![], + gear: vec![], + roasts: vec![], + bags: vec![], + brews: vec![], + cafes: vec![], + timeline_events: vec![], + }; + + let response = client + .post(app.api_url("/backup/restore")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&backup_data) + .send() + .await + .expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); +} + +#[tokio::test] +async fn backup_round_trip_via_api() { + // 1. Create source app with data + let source = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + create_default_roaster(&source).await; + + // 2. Export via API + let response = client + .get(source.api_url("/backup")) + .bearer_auth(source.auth_token.as_ref().unwrap()) + .send() + .await + .expect("failed to export backup"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let backup_data: BackupData = response.json().await.expect("failed to parse backup"); + assert_eq!(backup_data.roasters.len(), 1); + + // 3. Restore into a fresh app + let target = spawn_app_with_auth().await; + + let response = client + .post(target.api_url("/backup/restore")) + .bearer_auth(target.auth_token.as_ref().unwrap()) + .json(&backup_data) + .send() + .await + .expect("failed to restore backup"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + + // 4. Verify data was restored by listing roasters + let response = client + .get(target.api_url("/roasters")) + .send() + .await + .expect("failed to list roasters"); + + let roasters: Vec = response.json().await.expect("failed to parse roasters"); + assert_eq!(roasters.len(), 1); + assert_eq!(roasters[0].name, "Test Roasters"); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 762aefd..2889b46 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -10,6 +10,7 @@ use brewlog::domain::repositories::{ use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::users::NewUser; use brewlog::infrastructure::auth::hash_password; +use brewlog::infrastructure::backup::BackupService; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository; @@ -122,6 +123,8 @@ async fn spawn_app_inner( foursquare_api_key: String, mock_server: Option, ) -> TestApp { + let backup_service = Arc::new(BackupService::new(_database.clone_pool())); + // Create application state let state = AppState::new( roaster_repo.clone(), @@ -140,6 +143,7 @@ async fn spawn_app_inner( foursquare_api_key, String::new(), "openrouter/free".to_string(), + backup_service, ); // Create router