//! Contract and authorization tests against the actual Axum application and SQLite. use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use mikrotik_backup::{AppState, api, config::Config, db::Db, security}; use rusqlite::params; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use tower::ServiceExt; struct Fixture { _dir: tempfile::TempDir, state: AppState, app: Router, token: String, } impl Fixture { fn new() -> Self { Self::with_options(&[]) } fn with_options(options: &[(&str, &str)]) -> Self { let dir = tempfile::tempdir().unwrap(); let mut values = HashMap::from([ ("DATA_DIR".into(), dir.path().display().to_string()), ("DEFAULT_ADMIN_USERNAME".into(), "admin".into()), ("DEFAULT_ADMIN_PASSWORD".into(), "test-password-123".into()), ("SSH_TIMEOUT_SECONDS".into(), "1".into()), ]); for (key, value) in options { values.insert((*key).into(), (*value).into()); } let config = Config::from_values(&values).unwrap(); let db = Db::open(&config).unwrap(); let user = db.user_by_name("admin").unwrap().unwrap(); let token = security::create_token(&config, &user).unwrap(); let state = AppState::new(config, db); let app = api::router(state.clone()); Self { _dir: dir, state, app, token, } } async fn request(&self, method: &str, path: &str, body: Option) -> (StatusCode, Value) { self.as_token(method, path, body, Some(&self.token)).await } async fn as_token( &self, method: &str, path: &str, body: Option, token: Option<&str>, ) -> (StatusCode, Value) { let mut request = Request::builder().method(method).uri(path); if let Some(token) = token { request = request.header("authorization", format!("Bearer {token}")); } request = request.header("content-type", "application/json"); let response = self .app .clone() .oneshot( request .body(Body::from(body.map(|v| v.to_string()).unwrap_or_default())) .unwrap(), ) .await .unwrap(); let status = response.status(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let value = serde_json::from_slice(&bytes) .unwrap_or_else(|_| json!(String::from_utf8_lossy(&bytes))); (status, value) } async fn device(&self, name: &str) -> i64 { let (status,value)=self.request("POST","/api/routers",Some(json!({"name":name,"host":"127.0.0.1","ssh_user":"admin","ssh_password":"private-device-password"}))).await; assert_eq!(status, StatusCode::OK, "{value}"); value["id"].as_i64().unwrap() } fn file(&self, router_id: i64, name: &str, kind: &str, content: &[u8], created: &str) -> i64 { let path = self.state.config.data_dir.join(name); std::fs::write(&path, content).unwrap(); let checksum = format!("{:x}", Sha256::digest(content)); self.state.db.with_conn(|conn|{conn.execute("INSERT INTO backups(router_id,file_path,file_name,backup_type,checksum,created_at) VALUES(?1,?2,?3,?4,?5,?6)",params![router_id,path.display().to_string(),name,kind,checksum,created])?;Ok(conn.last_insert_rowid())}).unwrap() } } #[tokio::test] async fn auth_json_form_preferences_password_and_expiry() { let f = Fixture::new(); let (status, login) = f .as_token( "POST", "/api/auth/login", Some(json!({"username":"admin","password":"test-password-123"})), None, ) .await; assert_eq!(status, StatusCode::OK); assert_eq!(login["token_type"], "bearer"); assert_eq!(login["user"]["preferred_language"], "en"); assert!(login["user"].get("password_hash").is_none()); let response = f .app .clone() .oneshot( Request::post("/api/auth/login") .header("content-type", "application/x-www-form-urlencoded") .body(Body::from("username=admin&password=test-password-123")) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( f.as_token("GET", "/api/auth/me", None, None).await.0, StatusCode::UNAUTHORIZED ); assert_eq!( f.as_token("GET", "/api/auth/me", None, Some("corrupted")) .await .0, StatusCode::UNAUTHORIZED ); assert_eq!( f.request("GET", "/api/auth/me", None).await.1["username"], "admin" ); for lang in ["pl", "en", "es", "no"] { let (status, value) = f .request( "PUT", "/api/auth/preferences", Some(json!({"preferred_language":lang,"preferred_font":"hack"})), ) .await; assert_eq!(status, StatusCode::OK); assert_eq!(value["preferred_language"], lang); assert_eq!(value["preferred_font"], "hack"); } assert_eq!( f.request( "PUT", "/api/auth/preferences", Some(json!({"preferred_language":"xx","preferred_font":"hack"})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( f.request( "POST", "/api/auth/change-password", Some(json!({"current_password":"wrong","new_password":"new-password-456"})) ) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request( "POST", "/api/auth/change-password", Some(json!({"current_password":"test-password-123","new_password":"new-password-456"})) ) .await .0, StatusCode::OK ); assert_eq!( f.as_token( "POST", "/api/auth/login", Some(json!({"username":"admin","password":"test-password-123"})), None ) .await .0, StatusCode::UNAUTHORIZED ); assert_eq!( f.as_token( "POST", "/api/auth/login", Some(json!({"username":"admin","password":"new-password-456"})), None ) .await .0, StatusCode::OK ); } #[tokio::test] async fn registration_and_login_rate_limit() { let f = Fixture::new(); let (_, login_page) = f.as_token("GET", "/login", None, None).await; assert!( login_page .as_str() .unwrap() .contains("name=\"allow-registration\" content=\"false\"") ); assert_eq!( f.as_token( "POST", "/api/auth/register", Some(json!({"username":"reader","password":"valid-pass"})), None ) .await .0, StatusCode::FORBIDDEN ); let f = Fixture::with_options(&[("ALLOW_REGISTRATION", "true")]); let (_, login_page) = f.as_token("GET", "/login", None, None).await; assert!( login_page .as_str() .unwrap() .contains("name=\"allow-registration\" content=\"true\"") ); assert_eq!( f.as_token( "POST", "/api/auth/register", Some(json!({"username":"reader","password":"valid-pass"})), None ) .await .0, StatusCode::OK ); assert_eq!( f.as_token( "POST", "/api/auth/register", Some(json!({"username":"reader","password":"valid-pass"})), None ) .await .0, StatusCode::CONFLICT ); assert_eq!( f.as_token( "POST", "/api/auth/register", Some(json!({"username":"x","password":"1"})), None ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); for _ in 0..10 { assert_eq!( f.as_token( "POST", "/api/auth/login", Some(json!({"username":"nonexistent","password":"wrong"})), None ) .await .0, StatusCode::UNAUTHORIZED ); } assert_eq!( f.as_token( "POST", "/api/auth/login", Some(json!({"username":"nonexistent","password":"wrong"})), None ) .await .0, StatusCode::TOO_MANY_REQUESTS ); } #[tokio::test] async fn devices_defaults_validation_secrets_and_disabled_operations() { let f = Fixture::new(); let id = f.device("edge01").await; let (_, router) = f.request("GET", &format!("/api/routers/{id}"), None).await; assert_eq!(router["port"], 22); assert_eq!(router["supports_export"], true); assert_eq!(router["ssh_password"], Value::Null); assert_eq!(router["has_effective_password"], true); assert!(!router.to_string().contains("private-device-password")); assert_eq!( f.request( "POST", "/api/routers", Some(json!({"name":"has spaces","host":"127.0.0.1"})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( f.request( "PUT", &format!("/api/routers/{id}"), Some(json!({"port":0})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); f.request( "PUT", &format!("/api/routers/{id}"), Some(json!({"ssh_password":"","disable_all_backups":true,"disable_ping":true})), ) .await; assert_eq!( f.state .db .router(id) .unwrap() .unwrap() .ssh_password .as_deref(), Some("private-device-password") ); assert_eq!( f.request("POST", &format!("/api/backups/router/{id}/export"), None) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request("POST", &format!("/api/backups/router/{id}/binary"), None) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request("POST", "/api/backups/routers/export-all", None) .await .1[0]["status"], "skipped" ); assert_eq!( f.request("POST", "/api/backups/routers/binary-all", None) .await .1[0]["status"], "skipped" ); assert_eq!( f.request("GET", "/api/routers/ping-statuses", None).await.1["items"][0]["disabled"], true ); f.request( "PUT", &format!("/api/routers/{id}"), Some(json!({"clear_ssh_password":true})), ) .await; assert!( f.state .db .router(id) .unwrap() .unwrap() .ssh_password .is_none() ); let (status, sw) = f .request( "POST", "/api/routers", Some(json!({"name":"switch01","host":"127.0.0.1","device_type":"switchos"})), ) .await; assert_eq!(status, StatusCode::OK); assert_eq!(sw["port"], 80); assert_eq!(sw["supports_export"], false); assert_eq!(sw["ssh_user"], ""); let switch_id = sw["id"].as_i64().unwrap(); for host in [ "https://192.0.2.20", "http://switch.example:8080/", "https://[2001:db8::1]:8443/", ] { let (status, result) = f .request( "PUT", &format!("/api/routers/{switch_id}"), Some(json!({"host":host})), ) .await; assert_eq!(status, StatusCode::OK, "{result}"); assert_eq!(result["host"], host); } for host in [ "ftp://switch.example", "https://admin:secret@switch.example", "http://switch.example/path", "https://switch.example/?token=secret", "https://switch.example/#fragment", ] { assert_eq!( f.request( "PUT", &format!("/api/routers/{switch_id}"), Some(json!({"host":host})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); } assert_eq!( f.request( "PUT", &format!("/api/routers/{id}"), Some(json!({"host":"https://192.0.2.20"})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( f.request( "POST", &format!("/api/backups/router/{}/export", sw["id"]), None ) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request("DELETE", &format!("/api/routers/{id}"), None) .await .0, StatusCode::OK ); assert_eq!( f.request("GET", &format!("/api/routers/{id}"), None) .await .0, StatusCode::NOT_FOUND ); } #[tokio::test] async fn password_endpoints_share_a_bounded_worker_pool() { let f = Fixture::with_options(&[("ALLOW_REGISTRATION", "true")]); let permits = f .state .auth_operations .clone() .acquire_many_owned(4) .await .unwrap(); for (path, payload) in [ ( "/api/auth/login", json!({"username":"admin","password":"test-password-123"}), ), ( "/api/auth/register", json!({"username":"new-user","password":"test-password-123"}), ), ( "/api/auth/change-password", json!({"current_password":"test-password-123","new_password":"new-password-456"}), ), ( "/api/settings/reveal-ssh-key", json!({"password":"test-password-123"}), ), ] { let (status, response) = f.request("POST", path, Some(payload)).await; assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{path}: {response}"); } assert!(f.state.db.user_by_name("new-user").unwrap().is_none()); // Unrelated reads remain available and work resumes after capacity is released. assert_eq!( f.request("GET", "/api/health", None).await.0, StatusCode::OK ); drop(permits); assert_eq!( f.request( "POST", "/api/auth/login", Some(json!({"username":"admin","password":"test-password-123"})) ) .await .0, StatusCode::OK ); } #[tokio::test] async fn ownership_applies_to_every_device_and_backup_operation() { let f = Fixture::new(); let id = f.device("owned").await; let backup = f.file( id, "private.rsc", "export", b"private configuration\n", "2026-04-13 11:45:00", ); let foreign = f .state .db .insert_user( "another-user", &security::hash_password("other-password").unwrap(), ) .unwrap(); let token = security::create_token(&f.state.config, &foreign).unwrap(); for (method, path) in [ ("GET", format!("/api/routers/{id}")), ("PUT", format!("/api/routers/{id}")), ("DELETE", format!("/api/routers/{id}")), ("GET", format!("/api/routers/{id}/test-connection")), ("GET", format!("/api/backups/router/{id}")), ("POST", format!("/api/backups/router/{id}/export")), ("POST", format!("/api/backups/router/{id}/binary")), ("POST", format!("/api/backups/router/{id}/upload/{backup}")), ("GET", format!("/api/backups/{backup}/view")), ("GET", format!("/api/backups/{backup}/download")), ("POST", format!("/api/backups/{backup}/email")), ("DELETE", format!("/api/backups/{backup}")), ("GET", format!("/api/backups/{backup}/diff/{backup}")), ("GET", format!("/api/backups/{backup}/diff/{backup}/html")), ] { assert_eq!( f.as_token( method, &path, if method == "PUT" { Some(json!({"name":"attempt"})) } else { None }, Some(&token) ) .await .0, StatusCode::NOT_FOUND, "{method} {path}" ); } for action in ["delete", "download"] { assert_eq!( f.as_token( "POST", "/api/backups/bulk", Some(json!({"action":action,"backup_ids":[backup]})), Some(&token) ) .await .0, StatusCode::NOT_FOUND ); } assert_eq!( f.as_token("GET", "/api/routers", None, Some(&token)) .await .1, json!([]) ); assert_eq!( f.as_token("GET", "/api/backups", None, Some(&token)) .await .1, json!([]) ); assert_eq!( f.as_token("GET", "/api/dashboard", None, Some(&token)) .await .1["total_backups"], 0 ); assert!(f.state.db.backup(backup).unwrap().is_some()); } #[tokio::test] async fn files_filters_preview_diff_download_zip_and_bulk_deletion() { let f = Fixture::new(); let id = f.device("archive01").await; let left = f.file( id, "older.rsc", "export", b"/system identity\nset name=old\n", "2026-04-12 09:15:00", ); let right = f.file( id, "newer.rsc", "export", b"/system identity\nset name=new\n/ip address\n", "2026-04-13 11:45:00", ); let binary = f.file( id, "device.backup", "binary", &[0, 255, 1, 2, 3], "2026-04-13 12:00:00", ); let (_, items) = f .request( "GET", &format!("/api/backups?router_id={id}&created_on=2026-04-13&backup_type=export"), None, ) .await; assert_eq!(items.as_array().unwrap().len(), 1); assert_eq!(items[0]["id"], right); assert_eq!(items[0]["created_at"], "2026-04-13T11:45:00Z"); assert_eq!( f.request( "GET", "/api/backups?search=older&sort_by=file_name&order=asc", None ) .await .1[0]["id"], left ); assert_eq!( f.request("GET", "/api/backups?created_on=invalid", None) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( f.request("GET", &format!("/api/backups/{binary}/view"), None) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request("GET", &format!("/api/backups/{binary}/diff/{right}"), None) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request("GET", &format!("/api/backups/{left}/view"), None) .await .1["content"], "/system identity\nset name=old\n" ); let (status, diff) = f .request("GET", &format!("/api/backups/{left}/diff/{right}"), None) .await; assert_eq!(status, StatusCode::OK); assert_eq!(diff["stats"]["modified"], 1); assert_eq!(diff["stats"]["added"], 1); assert!( diff["diff_text"] .as_str() .unwrap() .contains("-set name=old") ); assert_eq!( f.request( "GET", &format!("/api/backups/{left}/diff/{right}/html"), None ) .await .0, StatusCode::OK ); let response = f .app .clone() .oneshot( Request::get(format!("/api/backups/{binary}/download")) .header("authorization", format!("Bearer {}", f.token)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()["content-type"], "application/octet-stream" ); assert_eq!( &response.into_body().collect().await.unwrap().to_bytes()[..], &[0, 255, 1, 2, 3] ); let response = f .app .clone() .oneshot( Request::post("/api/backups/bulk") .header("authorization", format!("Bearer {}", f.token)) .header("content-type", "application/json") .body(Body::from( json!({"action":"download","backup_ids":[left,right,binary]}).to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let data = response.into_body().collect().await.unwrap().to_bytes(); let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).unwrap(); assert_eq!(archive.len(), 3); assert_eq!(archive.by_name("older.rsc").unwrap().size(), 30); assert_eq!( f.request( "POST", "/api/backups/bulk", Some(json!({"action":"delete","backup_ids":[left,999999]})) ) .await .0, StatusCode::NOT_FOUND ); assert!(f.state.db.backup(left).unwrap().is_some()); assert_eq!( f.request( "POST", "/api/backups/bulk", Some(json!({"action":"delete","backup_ids":[left,right,left]})) ) .await .0, StatusCode::OK ); assert!(!f.state.config.data_dir.join("older.rsc").exists()); assert!(!f.state.config.data_dir.join("newer.rsc").exists()); assert_eq!( f.request("DELETE", &format!("/api/backups/{binary}"), None) .await .0, StatusCode::OK ); assert_eq!( f.request("GET", "/api/dashboard", None).await.1["total_backups"], 0 ); } #[tokio::test] async fn settings_secrets_validation_reveal_scheduler_and_notification_errors() { let f = Fixture::new(); let (status,value)=f.request("PUT","/api/settings",Some(json!({"global_ssh_key":"secret-key-text","smtp_password":"smtp-secret","pushover_token":"push-secret","export_cron":"15 2 * * *","enable_auto_export":true}))).await; assert_eq!(status, StatusCode::OK); assert_eq!(value["global_ssh_key"], Value::Null); assert_eq!(value["has_global_ssh_key"], true); assert!(!value.to_string().contains("smtp-secret")); f.request( "PUT", "/api/settings", Some(json!({"global_ssh_key":null,"smtp_password":""})), ) .await; assert_eq!( f.state.db.get_settings().unwrap().smtp_password.as_deref(), Some("smtp-secret") ); assert_eq!( f.request( "PUT", "/api/settings", Some(json!({"export_cron":"invalid"})) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!(f.state.db.get_settings().unwrap().export_cron, "15 2 * * *"); assert_eq!( f.request( "POST", "/api/settings/reveal-ssh-key", Some(json!({"password":"wrong"})) ) .await .0, StatusCode::BAD_REQUEST ); assert_eq!( f.request( "POST", "/api/settings/reveal-ssh-key", Some(json!({"password":"test-password-123"})) ) .await .1["global_ssh_key"], "secret-key-text" ); let (status, scheduled) = f .request("GET", "/api/settings/scheduler-status", None) .await; assert_eq!(status, StatusCode::OK); assert_eq!(scheduled["jobs"].as_array().unwrap().len(), 5); assert_eq!(scheduled["timezone"], "Europe/Warsaw"); assert_eq!( f.request("POST", "/api/settings/test-email", None).await.0, StatusCode::BAD_GATEWAY ); assert_eq!( f.request("POST", "/api/settings/test-pushover", None) .await .0, StatusCode::BAD_GATEWAY ); f.request("PUT","/api/settings",Some(json!({"clear_global_ssh_key":true,"clear_smtp_password":true,"clear_pushover_token":true}))).await; assert!(f.state.db.get_settings().unwrap().global_ssh_key.is_none()); } #[tokio::test] async fn logs_retention_and_embedded_web_do_not_expose_storage() { let f = Fixture::new(); let (_, page) = f.as_token("GET", "/login", None, None).await; let page = page.as_str().unwrap(); assert!(page.contains(env!("APP_JS_ROUTE"))); assert!(page.contains(env!("APP_CSS_ROUTE"))); assert!(!page.contains("?v=")); assert!(page.contains("