Files
mikrotik-backup/tests/api_contract.rs
T
2026-10-28 09:16:54 +01:00

1233 lines
37 KiB
Rust

//! 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<Value>) -> (StatusCode, Value) {
self.as_token(method, path, body, Some(&self.token)).await
}
async fn as_token(
&self,
method: &str,
path: &str,
body: Option<Value>,
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("<html lang=\"en\""));
f.state
.db
.with_conn(|conn| {
conn.execute(
"INSERT INTO operation_logs(message,timestamp) VALUES('old','2000-01-01 00:00:00')",
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
f.request("GET", "/api/logs?limit=501", None).await.0,
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
f.request("DELETE", "/api/logs/older-than/0", None).await.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
f.request("DELETE", "/api/logs/older-than/1", None).await.1["deleted"],
1
);
for path in [
"/",
"/login",
"/devices",
"/devices/1",
"/routers/1",
"/files",
"/diff-configs",
"/logs",
"/settings",
"/change-password",
"/register",
env!("APP_JS_ROUTE"),
env!("APP_CSS_ROUTE"),
] {
assert_eq!(
f.as_token("GET", path, None, None).await.0,
StatusCode::OK,
"{path}"
);
}
assert_eq!(
f.as_token("GET", "/favicon.ico", None, None).await.0,
StatusCode::NO_CONTENT
);
for path in [
"/assets/app.js",
"/assets/app.css",
"/api/not-real",
"/storage/secrets/jwt.key",
"/Cargo.toml",
"/src/main.rs",
"/assets/no.js",
] {
assert_eq!(
f.as_token("GET", path, None, None).await.0,
StatusCode::NOT_FOUND,
"{path}"
);
}
assert_eq!(
f.as_token("GET", "/api/health", None, None).await.1["version"],
"0.0.1"
);
let f = Fixture::with_options(&[("API_PREFIX", "/management/api")]);
assert_eq!(
f.as_token("GET", "/management/api/health", None, None)
.await
.0,
StatusCode::OK
);
assert!(
f.as_token("GET", "/", None, None)
.await
.1
.as_str()
.unwrap()
.contains("content=\"/management/api\"")
);
}
#[tokio::test]
async fn switchos_explicit_source_keeps_complete_pairs_and_saved_local_credentials() {
let f = Fixture::new();
let (status, settings_response) = f
.request(
"PUT",
"/api/settings",
Some(json!({
"default_switchos_username":"global-user",
"default_switchos_password":"global-password-fixture"
})),
)
.await;
assert_eq!(status, StatusCode::OK, "{settings_response}");
assert!(settings_response["default_switchos_password"].is_null());
let (status, created) = f
.request(
"POST",
"/api/routers",
Some(json!({
"name":"explicit_switch", "host":"127.0.0.1", "device_type":"switchos",
"ssh_user":"local-user", "ssh_password":"local-password-fixture",
"use_global_switchos_credentials":true
})),
)
.await;
assert_eq!(status, StatusCode::OK, "{created}");
let id = created["id"].as_i64().unwrap();
let path = format!("/api/routers/{id}");
assert_eq!(created["use_global_switchos_credentials"], true);
assert_eq!(created["uses_global_switchos_credentials"], true);
assert_eq!(created["effective_username"], "global-user");
assert_eq!(created["has_effective_password"], true);
assert_eq!(created["has_own_password"], true);
assert!(created["ssh_password"].is_null());
assert!(!created.to_string().contains("password-fixture"));
let record = f.state.db.router(id).unwrap().unwrap();
let settings = f.state.db.get_settings().unwrap();
assert_eq!(record.ssh_user, "local-user");
assert_eq!(
record.ssh_password.as_deref(),
Some("local-password-fixture")
);
assert_eq!(
record.switchos_credentials(&settings).unwrap(),
("global-user", "global-password-fixture")
);
let (status, edited) = f
.request("PUT", &path, Some(json!({"disable_ping":true})))
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(edited["use_global_switchos_credentials"], true);
let (status, _) = f
.request(
"PUT",
"/api/settings",
Some(json!({
"default_switchos_username":"updated-global",
"default_switchos_password":"updated-global-password"
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let record = f.state.db.router(id).unwrap().unwrap();
let settings = f.state.db.get_settings().unwrap();
assert_eq!(
record.switchos_credentials(&settings).unwrap(),
("updated-global", "updated-global-password")
);
assert_eq!(
record.ssh_password.as_deref(),
Some("local-password-fixture")
);
assert_eq!(
f.request("GET", &path, None).await.1["effective_username"],
"updated-global"
);
let (status, local) = f
.request(
"PUT",
&path,
Some(json!({
"use_global_switchos_credentials":false, "ssh_password":null
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(local["effective_username"], "local-user");
assert_eq!(local["uses_global_switchos_credentials"], false);
assert_eq!(local["has_effective_password"], true);
let record = f.state.db.router(id).unwrap().unwrap();
assert_eq!(record.use_global_switchos_credentials, Some(false));
assert_eq!(
record.switchos_credentials(&settings).unwrap(),
("local-user", "local-password-fixture")
);
let (status, partial) = f
.request(
"PUT",
&path,
Some(json!({
"use_global_switchos_credentials":null, "ssh_password":"", "name":"renamed_switch"
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(partial["use_global_switchos_credentials"], false);
assert!(partial["ssh_password"].is_null());
assert_eq!(
f.state
.db
.router(id)
.unwrap()
.unwrap()
.ssh_password
.as_deref(),
Some("local-password-fixture")
);
assert_eq!(
f.state
.db
.get_settings()
.unwrap()
.default_switchos_password
.as_deref(),
Some("updated-global-password")
);
}
#[tokio::test]
async fn switchos_legacy_mixed_sources_survive_partial_put_until_explicit_selection() {
let f = Fixture::new();
assert_eq!(f.request("PUT", "/api/settings", Some(json!({
"default_switchos_username":"global-user", "default_switchos_password":"global-password"
}))).await.0, StatusCode::OK);
for (name, own_user, own_password, expected_user, expected_password) in [
(
"mixed_username",
"local-user",
"",
"local-user",
"global-password",
),
(
"mixed_password",
"",
"local-password",
"global-user",
"local-password",
),
] {
let (status, created) = f
.request(
"POST",
"/api/routers",
Some(json!({
"name":name,"host":"127.0.0.1","device_type":"switchos",
"ssh_user":own_user,"ssh_password":own_password
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let id = created["id"].as_i64().unwrap();
assert!(created["use_global_switchos_credentials"].is_null());
assert_eq!(created["uses_global_switchos_credentials"], true);
let (status, updated) = f
.request(
"PUT",
&format!("/api/routers/{id}"),
Some(json!({"disable_ping":true,"ssh_password":null})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(updated["use_global_switchos_credentials"].is_null());
let record = f.state.db.router(id).unwrap().unwrap();
let settings = f.state.db.get_settings().unwrap();
assert_eq!(
record.switchos_credentials(&settings).unwrap(),
(expected_user, expected_password)
);
assert_eq!(record.ssh_password.as_deref().unwrap_or(""), own_password);
assert!(updated["ssh_password"].is_null());
assert!(!updated.to_string().contains("global-password"));
assert!(!updated.to_string().contains("local-password"));
}
}
#[tokio::test]
async fn switchos_selected_source_never_falls_back_when_credentials_are_missing() {
let f = Fixture::new();
let (status, created) = f
.request(
"POST",
"/api/routers",
Some(json!({
"name":"missing_global","host":"127.0.0.1","port":1,"device_type":"switchos",
"ssh_user":"saved-local","ssh_password":"saved-local-password",
"use_global_switchos_credentials":true
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let id = created["id"].as_i64().unwrap();
assert!(created["effective_username"].is_null());
assert_eq!(created["has_effective_password"], false);
assert_eq!(created["has_own_password"], true);
let record = f.state.db.router(id).unwrap().unwrap();
assert!(
record
.switchos_credentials(&f.state.db.get_settings().unwrap())
.unwrap_err()
.to_string()
.contains("Global SwitchOS username")
);
let (status, probe) = f
.request("GET", &format!("/api/routers/{id}/test-connection"), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(probe["success"], false);
assert!(
probe["error"]
.as_str()
.unwrap()
.contains("Global SwitchOS username")
);
assert!(!probe.to_string().contains("saved-local-password"));
assert_eq!(
f.request(
"PUT",
"/api/settings",
Some(json!({"default_switchos_username":"global-with-empty-password"}))
)
.await
.0,
StatusCode::OK
);
let settings = f.state.db.get_settings().unwrap();
assert_eq!(
record.switchos_credentials(&settings).unwrap(),
("global-with-empty-password", "")
);
assert_eq!(
f.request("GET", &format!("/api/routers/{id}"), None)
.await
.1["has_effective_password"],
false
);
assert_eq!(
f.request(
"PUT",
"/api/settings",
Some(json!({"default_switchos_password":"global-only-password"}))
)
.await
.0,
StatusCode::OK
);
let (status, local) = f
.request(
"POST",
"/api/routers",
Some(json!({
"name":"local_empty_password","host":"127.0.0.1","device_type":"switchos",
"ssh_user":"local-only","use_global_switchos_credentials":false
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(local["has_effective_password"], false);
assert_eq!(local["has_own_password"], false);
assert_eq!(local["uses_global_switchos_credentials"], false);
let local_id = local["id"].as_i64().unwrap();
let local_record = f.state.db.router(local_id).unwrap().unwrap();
assert_eq!(
local_record
.switchos_credentials(&f.state.db.get_settings().unwrap())
.unwrap(),
("local-only", "")
);
assert_eq!(
f.request(
"PUT",
&format!("/api/routers/{local_id}"),
Some(json!({"ssh_user":""}))
)
.await
.0,
StatusCode::OK
);
assert!(
f.state
.db
.router(local_id)
.unwrap()
.unwrap()
.switchos_credentials(&f.state.db.get_settings().unwrap())
.unwrap_err()
.to_string()
.contains("on this device")
);
assert_eq!(
f.request(
"PUT",
&format!("/api/routers/{id}"),
Some(json!({"use_global_switchos_credentials":"true"}))
)
.await
.0,
StatusCode::UNPROCESSABLE_ENTITY
);
}