1233 lines
37 KiB
Rust
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
|
|
);
|
|
}
|