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:
parent
e31f1c741e
commit
50a372015c
12 changed files with 304 additions and 30 deletions
11
README.md
11
README.md
|
|
@ -130,8 +130,8 @@ brewlog brew <cmd> Manage brews (add, list, get, delete — no update)
|
|||
brewlog cafe <cmd> Manage cafes
|
||||
brewlog cup <cmd> Manage cups (cafe visits with ratings)
|
||||
brewlog token <cmd> 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 <command> --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
|
||||
|
||||
|
|
|
|||
39
src/application/routes/backup.rs
Normal file
39
src/application/routes/backup.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<BackupService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -76,6 +78,7 @@ impl AppState {
|
|||
foursquare_api_key: String,
|
||||
openrouter_api_key: String,
|
||||
openrouter_model: String,
|
||||
backup_service: Arc<BackupService>,
|
||||
) -> 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<dyn SessionRepository> =
|
||||
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)
|
||||
|
|
|
|||
44
src/infrastructure/client/backup.rs
Normal file
44
src/infrastructure/client/backup.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
15
src/main.rs
15
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
37
tests/cli/backup_cli.rs
Normal file
37
tests/cli/backup_cli.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod backup_cli;
|
||||
pub mod bags_cli;
|
||||
pub mod brews_cli;
|
||||
pub mod cafes_cli;
|
||||
|
|
|
|||
|
|
@ -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<dyn RoasterRepository>,
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<wiremock::MockServer>,
|
||||
) -> 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue