feat(backup): expose backup/restore via authenticated API endpoints

- Add GET /api/v1/backup and POST /api/v1/backup/restore endpoints
  behind AuthenticatedUser
- Add BackupService to AppState and BackupClient for HTTP access
- Update CLI backup/restore to use API instead of direct DB access
- Remove --database-url flag from backup and restore commands
- Increase body limit to 50MB for restore endpoint
- Add API and CLI tests for auth, export, restore, and round-trip
- Update README to document auth requirement and API endpoints
This commit is contained in:
Jon Seager 2026-02-04 19:38:49 +00:00
parent e31f1c741e
commit 50a372015c
No known key found for this signature in database
12 changed files with 304 additions and 30 deletions

View file

@ -130,8 +130,8 @@ brewlog brew <cmd> Manage brews (add, list, get, delete — no update)
brewlog cafe <cmd> Manage cafes brewlog cafe <cmd> Manage cafes
brewlog cup <cmd> Manage cups (cafe visits with ratings) brewlog cup <cmd> Manage cups (cafe visits with ratings)
brewlog token <cmd> Manage API tokens (create, list, revoke) brewlog token <cmd> Manage API tokens (create, list, revoke)
brewlog backup Export all data to JSON on stdout 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 brewlog restore --file F Restore data from a JSON backup into an empty database (requires BREWLOG_TOKEN)
``` ```
Use `brewlog <command> --help` for detailed options on any command. Use `brewlog <command> --help` for detailed options on any command.
@ -197,6 +197,8 @@ Migrations run automatically on server startup.
### Backup & Restore ### Backup & Restore
Backup and restore go through the API and require authentication (`BREWLOG_TOKEN`).
```bash ```bash
# Export all data to JSON # Export all data to JSON
brewlog backup > backup.json brewlog backup > backup.json
@ -205,7 +207,10 @@ brewlog backup > backup.json
brewlog restore --file 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 ## Installation

View file

@ -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<AppState>,
_auth_user: AuthenticatedUser,
) -> Result<Json<BackupData>, 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<AppState>,
_auth_user: AuthenticatedUser,
Json(payload): Json<BackupData>,
) -> Result<Response, ApiError> {
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())
}

View file

@ -1,4 +1,5 @@
pub mod auth; pub mod auth;
pub mod backup;
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes; pub mod cafes;
@ -17,6 +18,7 @@ pub mod tokens;
pub(crate) use auth::is_authenticated; pub(crate) use auth::is_authenticated;
use askama::Template; use askama::Template;
use axum::extract::DefaultBodyLimit;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect}; use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post}; use axum::routing::{get, post};
@ -94,7 +96,12 @@ pub fn app_router(state: AppState) -> axum::Router {
"/tokens", "/tokens",
post(tokens::create_token).get(tokens::list_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() axum::Router::new()
.route("/", get(home::home_page)) .route("/", get(home::home_page))

View file

@ -14,6 +14,7 @@ use crate::domain::repositories::{
}; };
use crate::domain::users::NewUser; use crate::domain::users::NewUser;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::backup::BackupService;
use crate::infrastructure::database::Database; use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::bags::SqlBagRepository;
use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::brews::SqlBrewRepository;
@ -55,6 +56,7 @@ pub struct AppState {
pub foursquare_api_key: String, pub foursquare_api_key: String,
pub openrouter_api_key: String, pub openrouter_api_key: String,
pub openrouter_model: String, pub openrouter_model: String,
pub backup_service: Arc<BackupService>,
} }
impl AppState { impl AppState {
@ -76,6 +78,7 @@ impl AppState {
foursquare_api_key: String, foursquare_api_key: String,
openrouter_api_key: String, openrouter_api_key: String,
openrouter_model: String, openrouter_model: String,
backup_service: Arc<BackupService>,
) -> Self { ) -> Self {
Self { Self {
roaster_repo, roaster_repo,
@ -94,6 +97,7 @@ impl AppState {
foursquare_api_key, foursquare_api_key,
openrouter_api_key, openrouter_api_key,
openrouter_model, openrouter_model,
backup_service,
} }
} }
} }
@ -119,6 +123,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
let session_repo: Arc<dyn SessionRepository> = let session_repo: Arc<dyn SessionRepository> =
Arc::new(SqlSessionRepository::new(database.clone_pool())); 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 if no users exist
bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?; 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.foursquare_api_key,
config.openrouter_api_key, config.openrouter_api_key,
config.openrouter_model, config.openrouter_model,
backup_service,
); );
let listener = TcpListener::bind(config.bind_address) let listener = TcpListener::bind(config.bind_address)

View file

@ -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<BackupData> {
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),
}
}
}

View file

@ -1,3 +1,4 @@
pub mod backup;
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes; pub mod cafes;
@ -44,6 +45,10 @@ impl BrewlogClient {
Self::new(url) Self::new(url)
} }
pub fn backup(&self) -> backup::BackupClient<'_> {
backup::BackupClient::new(self)
}
pub fn roasters(&self) -> roasters::RoastersClient<'_> { pub fn roasters(&self) -> roasters::RoastersClient<'_> {
roasters::RoastersClient::new(self) roasters::RoastersClient::new(self)
} }

View file

@ -1,8 +1,7 @@
use anyhow::Result; use anyhow::Result;
use brewlog::application::{ServerConfig, serve}; use brewlog::application::{ServerConfig, serve};
use brewlog::infrastructure::backup::{BackupData, BackupService}; use brewlog::infrastructure::backup::BackupData;
use brewlog::infrastructure::client::BrewlogClient; use brewlog::infrastructure::client::BrewlogClient;
use brewlog::infrastructure::database::Database;
use brewlog::presentation::cli::{ use brewlog::presentation::cli::{
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens, 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)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
tokens::run(&client, command).await tokens::run(&client, command).await
} }
Commands::Backup(cmd) => { Commands::Backup(_cmd) => {
let database = Database::connect(&cmd.database_url).await?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
let service = BackupService::new(database.clone_pool()); let data = client.backup().export().await?;
let data = service.export().await?;
let json = serde_json::to_string_pretty(&data)?; let json = serde_json::to_string_pretty(&data)?;
println!("{json}"); println!("{json}");
Ok(()) Ok(())
@ -69,9 +67,8 @@ async fn main() -> Result<()> {
Commands::Restore(cmd) => { Commands::Restore(cmd) => {
let contents = std::fs::read_to_string(&cmd.file)?; let contents = std::fs::read_to_string(&cmd.file)?;
let data: BackupData = serde_json::from_str(&contents)?; let data: BackupData = serde_json::from_str(&contents)?;
let database = Database::connect(&cmd.database_url).await?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
let service = BackupService::new(database.clone_pool()); client.backup().restore(&data).await?;
service.restore(data).await?;
eprintln!("Restore complete."); eprintln!("Restore complete.");
Ok(()) Ok(())
} }

View file

@ -1,26 +1,10 @@
use clap::Args; use clap::Args;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct BackupCommand { pub struct BackupCommand;
/// Database URL to back up from
#[arg(
long,
env = "BREWLOG_DATABASE_URL",
default_value = "sqlite://brewlog.db"
)]
pub database_url: String,
}
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct RestoreCommand { 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 /// Path to the backup JSON file
#[arg(long)] #[arg(long)]
pub file: String, pub file: String,

37
tests/cli/backup_cli.rs Normal file
View file

@ -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"
);
}

View file

@ -1,3 +1,4 @@
pub mod backup_cli;
pub mod bags_cli; pub mod bags_cli;
pub mod brews_cli; pub mod brews_cli;
pub mod cafes_cli; pub mod cafes_cli;

View file

@ -22,6 +22,8 @@ use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth};
struct TestDb { struct TestDb {
roaster_repo: Arc<dyn RoasterRepository>, roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>, roast_repo: Arc<dyn RoastRepository>,
@ -454,3 +456,145 @@ async fn backup_empty_database() {
let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize"); let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize");
assert_eq!(parsed.version, 1); 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<Roaster> = response.json().await.expect("failed to parse roasters");
assert_eq!(roasters.len(), 1);
assert_eq!(roasters[0].name, "Test Roasters");
}

View file

@ -10,6 +10,7 @@ use brewlog::domain::repositories::{
use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::roasters::{NewRoaster, Roaster};
use brewlog::domain::users::NewUser; use brewlog::domain::users::NewUser;
use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::auth::hash_password;
use brewlog::infrastructure::backup::BackupService;
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::bags::SqlBagRepository;
use brewlog::infrastructure::repositories::brews::SqlBrewRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
@ -122,6 +123,8 @@ async fn spawn_app_inner(
foursquare_api_key: String, foursquare_api_key: String,
mock_server: Option<wiremock::MockServer>, mock_server: Option<wiremock::MockServer>,
) -> TestApp { ) -> TestApp {
let backup_service = Arc::new(BackupService::new(_database.clone_pool()));
// Create application state // Create application state
let state = AppState::new( let state = AppState::new(
roaster_repo.clone(), roaster_repo.clone(),
@ -140,6 +143,7 @@ async fn spawn_app_inner(
foursquare_api_key, foursquare_api_key,
String::new(), String::new(),
"openrouter/free".to_string(), "openrouter/free".to_string(),
backup_service,
); );
// Create router // Create router