From 9f4336b701761dac6f4e4a2427c2d629ab9306d6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 16:21:48 +0800 Subject: [PATCH 01/30] test(agent): cover security and docker pipelines --- crates/agent/src/docker/mod.rs | 138 +++++++++++++++++++ crates/agent/src/security/manager.rs | 193 +++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/crates/agent/src/docker/mod.rs b/crates/agent/src/docker/mod.rs index 449f9739..a2aec112 100644 --- a/crates/agent/src/docker/mod.rs +++ b/crates/agent/src/docker/mod.rs @@ -665,4 +665,142 @@ mod tests { other => panic!("expected DockerUnavailable, got {other:?}"), } } + + #[tokio::test] + async fn verify_connection_reports_an_unreachable_daemon() { + let (manager, _rx) = make_manager(CAP_DOCKER); + assert!(manager.verify_connection().await.is_err()); + } + + #[tokio::test] + async fn poll_stats_skips_daemon_access_without_capability() { + let (mut manager, mut rx) = make_manager(0); + manager.poll_stats().await.unwrap(); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn poll_stats_notifies_when_the_daemon_is_unreachable() { + let (mut manager, mut rx) = make_manager(CAP_DOCKER); + assert!(manager.poll_stats().await.is_err()); + assert!(matches!( + rx.recv().await, + Some(AgentMessage::DockerUnavailable { msg_id: None }) + )); + } + + #[tokio::test] + async fn daemon_read_requests_preserve_their_message_ids_on_failure() { + let cases = [ + ServerMessage::DockerListContainers { + msg_id: "containers".to_string(), + }, + ServerMessage::DockerGetInfo { + msg_id: "info".to_string(), + }, + ServerMessage::DockerListNetworks { + msg_id: "networks".to_string(), + }, + ServerMessage::DockerListVolumes { + msg_id: "volumes".to_string(), + }, + ]; + + for (request, expected_id) in + cases + .into_iter() + .zip(["containers", "info", "networks", "volumes"]) + { + let (mut manager, mut rx) = make_manager(CAP_DOCKER); + assert!(manager.handle_server_message(request).await.is_err()); + assert!(matches!( + rx.recv().await, + Some(AgentMessage::DockerUnavailable { msg_id: Some(id) }) if id == expected_id + )); + } + } + + #[tokio::test] + async fn container_actions_report_daemon_errors_without_failing_dispatch() { + let actions = [ + DockerAction::Start, + DockerAction::Stop { timeout: None }, + DockerAction::Restart { timeout: Some(3) }, + DockerAction::Remove { force: true }, + ]; + + for (index, action) in actions.into_iter().enumerate() { + let (mut manager, mut rx) = make_manager(CAP_DOCKER); + let msg_id = format!("action-{index}"); + manager + .handle_server_message(ServerMessage::DockerContainerAction { + msg_id: msg_id.clone(), + container_id: "missing-container".to_string(), + action, + }) + .await + .unwrap(); + match rx.recv().await { + Some(AgentMessage::DockerActionResult { + msg_id: actual_id, + success, + error, + }) => { + assert_eq!(actual_id, msg_id); + assert!(!success); + assert!(error.is_some()); + } + other => panic!("expected DockerActionResult, got {other:?}"), + } + } + } + + #[tokio::test] + async fn stream_messages_replace_and_stop_existing_tasks() { + let (mut manager, _rx) = make_manager(CAP_DOCKER); + + manager + .handle_server_message(ServerMessage::DockerLogsStart { + session_id: "logs-1".to_string(), + container_id: "container-1".to_string(), + tail: Some(25), + follow: true, + }) + .await + .unwrap(); + assert_eq!(manager.log_sessions.len(), 1); + manager + .handle_server_message(ServerMessage::DockerLogsStart { + session_id: "logs-1".to_string(), + container_id: "container-2".to_string(), + tail: None, + follow: false, + }) + .await + .unwrap(); + assert_eq!(manager.log_sessions.len(), 1); + manager + .handle_server_message(ServerMessage::DockerLogsStop { + session_id: "logs-1".to_string(), + }) + .await + .unwrap(); + assert!(manager.log_sessions.is_empty()); + + manager + .handle_server_message(ServerMessage::DockerEventsStart) + .await + .unwrap(); + assert!(manager.event_stream_handle.is_some()); + manager + .handle_server_message(ServerMessage::DockerEventsStart) + .await + .unwrap(); + assert!(manager.event_stream_handle.is_some()); + manager + .handle_server_message(ServerMessage::DockerEventsStop) + .await + .unwrap(); + assert!(manager.event_stream_handle.is_none()); + } } diff --git a/crates/agent/src/security/manager.rs b/crates/agent/src/security/manager.rs index 2e3a4b9c..a4ca539d 100644 --- a/crates/agent/src/security/manager.rs +++ b/crates/agent/src/security/manager.rs @@ -321,8 +321,36 @@ async fn run_scan_pipeline( #[cfg(test)] mod tests { use super::*; + use crate::security::ssh_parser::{AuthMethodHint, AuthOutcome}; use serverbee_common::constants::CAP_DEFAULT; + fn login_attempt(username: &str, source_ip: &str) -> AuthAttempt { + AuthAttempt { + outcome: AuthOutcome::Success { + auth_method: AuthMethodHint::Publickey, + }, + username: username.to_string(), + source_ip: source_ip.to_string(), + source_port: Some(22), + } + } + + fn failed_attempt(username: &str, source_ip: &str, invalid_user: bool) -> AuthAttempt { + AuthAttempt { + outcome: AuthOutcome::Failure { invalid_user }, + username: username.to_string(), + source_ip: source_ip.to_string(), + source_port: Some(22), + } + } + + async fn recv_security_event(rx: &mut mpsc::Receiver) -> SecurityEventPayload { + match rx.recv().await { + Some(AgentMessage::SecurityEvent(payload)) => payload, + other => panic!("expected SecurityEvent, got {other:?}"), + } + } + #[tokio::test] async fn start_returns_empty_when_capability_missing() { let cfg = SecurityConfig::default(); @@ -343,6 +371,171 @@ mod tests { assert_eq!(mgr.handle_count(), 0); } + #[tokio::test] + async fn ssh_pipeline_marks_only_the_first_login_from_an_identity_as_new() { + let dir = tempfile::tempdir().unwrap(); + let first_seen = Arc::new(Mutex::new(FirstSeenStore::open( + dir.path().join("first_seen.json"), + FIRST_SEEN_CAP, + ))); + let (attempt_tx, attempt_rx) = mpsc::channel(4); + let (event_tx, mut event_rx) = mpsc::channel(4); + let task = tokio::spawn(run_ssh_pipeline( + attempt_rx, + crate::config::SshDetectorConfig::default(), + first_seen, + event_tx, + )); + + attempt_tx + .send(login_attempt("root", "203.0.113.10")) + .await + .unwrap(); + let first = recv_security_event(&mut event_rx).await; + assert_eq!(first.event_type, SecurityEventType::SshLogin); + assert_eq!(first.severity, Severity::Medium); + assert!(first.first_seen); + assert_eq!(first.username.as_deref(), Some("root")); + assert_eq!(first.source_ip, "203.0.113.10"); + assert_eq!(first.source_port, Some(22)); + assert!(matches!( + first.evidence, + SecurityEvidence::SshLogin { + auth_method: SshAuthMethod::Publickey + } + )); + + attempt_tx + .send(login_attempt("root", "203.0.113.10")) + .await + .unwrap(); + let repeated = recv_security_event(&mut event_rx).await; + assert_eq!(repeated.severity, Severity::Info); + assert!(!repeated.first_seen); + + drop(attempt_tx); + task.await.unwrap(); + } + + #[tokio::test] + async fn ssh_pipeline_emits_brute_force_evidence_at_the_configured_threshold() { + let dir = tempfile::tempdir().unwrap(); + let first_seen = Arc::new(Mutex::new(FirstSeenStore::open( + dir.path().join("first_seen.json"), + FIRST_SEEN_CAP, + ))); + let (attempt_tx, attempt_rx) = mpsc::channel(4); + let (event_tx, mut event_rx) = mpsc::channel(4); + let task = tokio::spawn(run_ssh_pipeline( + attempt_rx, + crate::config::SshDetectorConfig { + window_seconds: 30, + failed_threshold: 2, + }, + first_seen, + event_tx, + )); + + attempt_tx + .send(failed_attempt("root", "198.51.100.20", false)) + .await + .unwrap(); + attempt_tx + .send(failed_attempt("admin", "198.51.100.20", true)) + .await + .unwrap(); + + let payload = recv_security_event(&mut event_rx).await; + assert_eq!(payload.event_type, SecurityEventType::SshBruteForce); + assert_eq!(payload.severity, Severity::High); + assert_eq!(payload.source_ip, "198.51.100.20"); + assert_eq!(payload.source_port, None); + assert!(!payload.first_seen); + assert!(payload.started_at <= payload.ended_at); + match payload.evidence { + SecurityEvidence::SshBruteForce { + failed_count, + distinct_users, + invalid_user_count, + window_seconds, + threshold, + .. + } => { + assert_eq!(failed_count, 2); + assert_eq!(distinct_users, 2); + assert_eq!(invalid_user_count, 1); + assert_eq!(window_seconds, 30); + assert_eq!(threshold, 2); + } + other => panic!("expected SshBruteForce evidence, got {other:?}"), + } + + drop(attempt_tx); + task.await.unwrap(); + } + + #[tokio::test] + async fn scan_pipeline_emits_port_scan_and_records_blocked_attempts() { + let (conntrack_tx, conntrack_rx) = mpsc::channel(4); + let (blocked_tx, blocked_rx) = mpsc::channel(1); + let (event_tx, mut event_rx) = mpsc::channel(4); + + blocked_tx.send("192.0.2.44".to_string()).await.unwrap(); + let task = tokio::spawn(run_scan_pipeline( + conntrack_rx, + blocked_rx, + crate::config::PortScanConfig { + enabled: true, + window_seconds: 15, + distinct_port_threshold: 2, + }, + event_tx, + )); + + tokio::time::timeout(Duration::from_secs(1), async { + while blocked_tx.capacity() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + for dst_port in [22, 443] { + conntrack_tx + .send(ConntrackEvent { + source_ip: "192.0.2.44".to_string(), + dst_port, + }) + .await + .unwrap(); + } + + let payload = recv_security_event(&mut event_rx).await; + assert_eq!(payload.event_type, SecurityEventType::PortScan); + assert_eq!(payload.severity, Severity::High); + assert_eq!(payload.detector_source, DetectorSource::Conntrack); + assert_eq!(payload.source_ip, "192.0.2.44"); + match payload.evidence { + SecurityEvidence::PortScan { + distinct_ports, + total_attempts, + blocked_count, + window_seconds, + threshold, + .. + } => { + assert_eq!(distinct_ports, 2); + assert_eq!(total_attempts, 2); + assert_eq!(blocked_count, 1); + assert_eq!(window_seconds, 15); + assert_eq!(threshold, 2); + } + other => panic!("expected PortScan evidence, got {other:?}"), + } + + task.abort(); + task.await.unwrap_err(); + } + #[cfg(not(target_os = "linux"))] #[tokio::test] async fn start_returns_empty_on_non_linux() { From 2989eae171db942d952df85f52db3be366ebffbe Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 16:21:57 +0800 Subject: [PATCH 02/30] docs: highlight rust coverage quality --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- apps/docs/content/docs/en/testing.mdx | 10 +++++----- apps/docs/content/docs/zh/testing.mdx | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e840b511..2c839dc4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ServerBee watches all your servers from one place. A central **server** receives - 📦 **Single binary** — server + embedded web UI in one file. Deploy with Docker, a one-line script, or Railway. - 🔋 **Batteries included** — alerts, notifications, web terminal, file manager, Docker, firewall, status pages, and more. - 🔒 **Secure by default** — OAuth + 2FA, RBAC, audit logs, one-time agent enrollment, agent-owned capability gates. -- ✅ **Battle-tested** — 3,800+ automated tests, including integration suites that drive the real router over HTTP + WebSocket via a mock-agent harness; green on every CI run. +- ✅ **Battle-tested** — 3,800+ automated tests and over 91% measured Rust region coverage, including integration suites that drive the real router over HTTP + WebSocket via a mock-agent harness; green on every CI run. > [!NOTE] > ServerBee is in active development (`v1.0.0-alpha.12`). Expect rapid iteration. @@ -136,7 +136,7 @@ make cargo-clippy # Rust lint ### Testing & quality -ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (common ~98%, agent ~90%, server ~92% region). Every push runs zero-warning Clippy, the full Rust suite, and frontend type/lint checks in CI. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. +ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (over 91% region overall; common ~98%, agent ~90%, server ~92%). Every push runs zero-warning Clippy, the full Rust suite, and frontend type/lint checks in CI. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. ## API diff --git a/README.zh-CN.md b/README.zh-CN.md index 187161b6..8cdd5287 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ ServerBee 在一处统一监控你的所有服务器。中心 **Server** 通过 - 📦 **单一二进制** —— Server 与内嵌 Web UI 打包成一个文件,支持 Docker、一行脚本、Railway 部署。 - 🔋 **开箱即用** —— 告警、通知、Web 终端、文件管理、Docker、防火墙、状态页等功能。 - 🔒 **默认安全** —— OAuth + 2FA、RBAC、审计日志、一次性 Agent 注册、Agent 自主掌管的能力门控。 -- ✅ **充分测试** —— 3800+ 自动化测试,其中集成套件通过 mock-agent 测试桩驱动真实路由、经由 HTTP + WebSocket 演练控制面;每次 CI 全部通过。 +- ✅ **充分测试** —— 3800+ 自动化测试,Rust 区域覆盖率实测超过 91%;其中集成套件通过 mock-agent 测试桩驱动真实路由、经由 HTTP + WebSocket 演练控制面,每次 CI 全部通过。 > [!NOTE] > ServerBee 正在活跃开发中(`v1.0.0-alpha.12`),迭代频繁。 @@ -136,7 +136,7 @@ make cargo-clippy # Rust 代码检查 ### 测试与质量 -ServerBee 自带 **3800+ 自动化测试** —— 其中 3200+ 为 Rust(单元测试 + 42 个集成套件,通过 mock-agent 测试桩驱动真实 Axum 路由、经由 HTTP/WebSocket 演练),以及 600+ 前端 Vitest 测试。覆盖率使用 `cargo-llvm-cov` 测量(common ~98%、agent ~90%、server ~92% region)。每次 push 都会在 CI 中运行零警告 Clippy、完整 Rust 测试套件,以及前端类型/lint 检查。详见 [测试与质量](https://docs.serverbee.app/zh/docs/testing) 指南。 +ServerBee 自带 **3800+ 自动化测试** —— 其中 3200+ 为 Rust(单元测试 + 42 个集成套件,通过 mock-agent 测试桩驱动真实 Axum 路由、经由 HTTP/WebSocket 演练),以及 600+ 前端 Vitest 测试。覆盖率使用 `cargo-llvm-cov` 测量(总体区域覆盖率超过 91%;common ~98%、agent ~90%、server ~92%)。每次 push 都会在 CI 中运行零警告 Clippy、完整 Rust 测试套件,以及前端类型/lint 检查。详见 [测试与质量](https://docs.serverbee.app/zh/docs/testing) 指南。 ## API diff --git a/apps/docs/content/docs/en/testing.mdx b/apps/docs/content/docs/en/testing.mdx index ad7d8ddb..4bb2ae3b 100644 --- a/apps/docs/content/docs/en/testing.mdx +++ b/apps/docs/content/docs/en/testing.mdx @@ -10,9 +10,9 @@ ServerBee treats reliability as a feature. The codebase ships with a large, fast | Area | Tests | What it covers | |------|-------|----------------| -| `common` crate | 148 | Protocol messages, capability bitmask, SSRF guard, shared types | -| `agent` crate | 865 | Collectors, reporter WebSocket loop, pinger, file manager, security detectors, firewall, IP quality | -| `server` crate | 2,188 | REST handlers, WebSocket handlers, services, background tasks, migrations | +| `common` crate | 149 | Protocol messages, capability bitmask, SSRF guard, shared types | +| `agent` crate | 891 | Collectors, reporter WebSocket loop, pinger, file manager, security detectors, firewall, IP quality | +| `server` crate | 2,223 | REST handlers, WebSocket handlers, services, background tasks, migrations | | Frontend (`apps/web`) | 600+ | Hooks, components, stores, and utilities (Vitest) | That is **3,800+ automated tests** in total, all green on every CI run. @@ -50,10 +50,10 @@ Coverage is measured with [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-ll | Crate | Region | Line | |-------|--------|------| | `common` | 98% | 97% | -| `agent` | 90% | 89% | +| `agent` | 90% | 90% | | `server` | 92% | 93% | -The remaining uncovered lines are I/O paths that only execute in a live environment — a Docker daemon, Linux kernel facilities (nftables / conntrack / journald), raw ICMP sockets, a PTY, or external services (OAuth, SMTP, APNs). These are exercised through the integration harness and the manual end-to-end checklists in `tests/` rather than unit tests. +Overall Rust region coverage is above 91%. The largest remaining gaps are concentrated in process startup and I/O paths that need a live Docker daemon, Linux kernel facilities (nftables / conntrack / journald), raw ICMP sockets, a PTY, or external services (OAuth, SMTP, APNs). Those environment-dependent paths are exercised through the integration harness and the manual end-to-end checklists in `tests/` where practical. ## Running tests locally diff --git a/apps/docs/content/docs/zh/testing.mdx b/apps/docs/content/docs/zh/testing.mdx index cd29ed04..a39a6702 100644 --- a/apps/docs/content/docs/zh/testing.mdx +++ b/apps/docs/content/docs/zh/testing.mdx @@ -10,9 +10,9 @@ ServerBee 把可靠性当作一项功能来对待。代码库自带一套规模 | 范围 | 测试数 | 覆盖内容 | |------|--------|----------| -| `common` crate | 148 | 协议消息、能力位掩码、SSRF guard、共享类型 | -| `agent` crate | 865 | 采集器、reporter WebSocket 循环、pinger、文件管理、安全检测器、防火墙、IP 质量 | -| `server` crate | 2,188 | REST 处理器、WebSocket 处理器、服务层、后台任务、数据库迁移 | +| `common` crate | 149 | 协议消息、能力位掩码、SSRF guard、共享类型 | +| `agent` crate | 891 | 采集器、reporter WebSocket 循环、pinger、文件管理、安全检测器、防火墙、IP 质量 | +| `server` crate | 2,223 | REST 处理器、WebSocket 处理器、服务层、后台任务、数据库迁移 | | 前端(`apps/web`) | 600+ | Hooks、组件、状态 store、工具函数(Vitest) | 合计 **3800+ 自动化测试**,每次 CI 运行全部通过。 @@ -50,10 +50,10 @@ React 应用由 [Vitest](https://vitest.dev) 覆盖,分布在 89 个测试文件 | Crate | Region | Line | |-------|--------|------| | `common` | 98% | 97% | -| `agent` | 90% | 89% | +| `agent` | 90% | 90% | | `server` | 92% | 93% | -剩余未覆盖的代码是只能在真实环境中执行的 I/O 路径 —— Docker 守护进程、Linux 内核设施(nftables / conntrack / journald)、原始 ICMP 套接字、PTY,或外部服务(OAuth、SMTP、APNs)。这些路径通过集成测试桩以及 `tests/` 目录下的手动端到端检查清单来演练,而非单元测试。 +Rust 总体区域覆盖率超过 91%。当前较大的剩余缺口集中在进程启动和依赖真实环境的 I/O 路径,包括 Docker 守护进程、Linux 内核设施(nftables / conntrack / journald)、原始 ICMP 套接字、PTY,以及 OAuth、SMTP、APNs 等外部服务。在可行范围内,这些环境相关路径通过集成测试桩和 `tests/` 目录下的手动端到端检查清单演练。 ## 本地运行测试 From 32ac573e6ae56029627a2faf68692b1560e69605 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 16:55:29 +0800 Subject: [PATCH 03/30] test: cover high-value rust protocol paths --- crates/agent/src/reporter/mod.rs | 143 +++++++++++++++++++++++ crates/server/src/router/api/oauth.rs | 140 ++++++++++++++++++++++ crates/server/tests/browser_ws.rs | 67 +++++++++++ crates/server/tests/ws_docker_browser.rs | 54 +++++++++ 4 files changed, 404 insertions(+) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index 324590d2..d08e6666 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -1321,6 +1321,149 @@ mod tests { ); } + #[tokio::test] + async fn test_e2e_unexpected_first_message_uses_default_interval() { + let (listener, addr) = bind_fake_server().await; + let tmp = tempfile::tempdir().unwrap(); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); + + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_server_msg(&mut ws, &ServerMessage::Ping).await; + let info = handshake_collect_system_info(&mut ws).await; + ws.send(WsMessage::Close(None)).await.ok(); + info + }); + + let connect = run_connect_once(&mut reporter, Duration::from_secs(10)).await; + let info = tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server task timed out") + .expect("server task panicked"); + + assert!(matches!(info, AgentMessage::SystemInfo { .. })); + assert!( + connect + .expect("connect loop should finish before the timeout") + .is_ok(), + "an unexpected first application message should fall back and keep reporting" + ); + } + + #[tokio::test] + async fn test_e2e_binary_first_message_uses_default_interval() { + let (listener, addr) = bind_fake_server().await; + let tmp = tempfile::tempdir().unwrap(); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); + + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + ws.send(WsMessage::Binary(vec![1, 2, 3].into())) + .await + .expect("send binary preface"); + let info = handshake_collect_system_info(&mut ws).await; + ws.send(WsMessage::Close(None)).await.ok(); + info + }); + + let connect = run_connect_once(&mut reporter, Duration::from_secs(10)).await; + let info = tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server task timed out") + .expect("server task panicked"); + + assert!(matches!(info, AgentMessage::SystemInfo { .. })); + assert!( + connect + .expect("connect loop should finish before the timeout") + .is_ok(), + "a non-text first frame should fall back and keep reporting" + ); + } + + #[tokio::test] + async fn test_e2e_malformed_welcome_is_rejected() { + let (listener, addr) = bind_fake_server().await; + let tmp = tempfile::tempdir().unwrap(); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); + + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_raw_text(&mut ws, "{not-json").await; + }); + + let connect = run_connect_once(&mut reporter, Duration::from_secs(10)) + .await + .expect("malformed welcome should fail before the timeout"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server task timed out") + .expect("server task panicked"); + + assert!(connect.is_err(), "malformed Welcome JSON must be rejected"); + } + + #[tokio::test] + async fn test_e2e_external_message_is_forwarded() { + let (listener, addr) = bind_fake_server().await; + let tmp = tempfile::tempdir().unwrap(); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); + let (external_tx, external_rx) = mpsc::channel(1); + external_tx + .send(AgentMessage::IpChanged { + ipv4: Some("203.0.113.9".to_string()), + ipv6: None, + interfaces: Vec::new(), + }) + .await + .expect("queue external message"); + let mut external = Some(external_rx); + + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + let forwarded = read_agent_until(&mut ws, |msg| { + matches!( + msg, + AgentMessage::IpChanged { + ipv4: Some(ipv4), + .. + } if ipv4 == "203.0.113.9" + ) + }) + .await; + ws.send(WsMessage::Close(None)).await.ok(); + forwarded + }); + + let connect = tokio::time::timeout( + Duration::from_secs(10), + reporter.connect_and_report(&mut external), + ) + .await + .expect("connect loop should finish before the timeout"); + let forwarded = tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server task timed out") + .expect("server task panicked"); + + assert!(matches!(forwarded, AgentMessage::IpChanged { .. })); + assert!(connect.is_ok(), "clean close should still return Ok"); + } + #[tokio::test] async fn test_e2e_report_loop_emits_periodic_reports() { // Send loop: with a 1s report interval the agent must push at least one diff --git a/crates/server/src/router/api/oauth.rs b/crates/server/src/router/api/oauth.rs index 762a956f..ae37d56a 100644 --- a/crates/server/src/router/api/oauth.rs +++ b/crates/server/src/router/api/oauth.rs @@ -323,9 +323,49 @@ pub async fn oauth_callback( #[cfg(test)] mod tests { use super::*; + use crate::config::{AppConfig, OAuthConfig, OAuthProviderConfig}; use crate::state::OAuthFlowState; + use crate::test_utils::setup_test_db; + use axum::extract::{Path, State}; + use axum::http::header::{LOCATION, SET_COOKIE}; + use axum::response::IntoResponse; use dashmap::DashMap; + async fn configured_state(secure_cookie: bool) -> (Arc, tempfile::TempDir) { + let (db, tmp) = setup_test_db().await; + let mut config = AppConfig::default(); + config.auth.secure_cookie = secure_cookie; + config.server.data_dir = tmp.path().to_string_lossy().to_string(); + config.oauth = OAuthConfig { + github: Some(OAuthProviderConfig { + client_id: "github-client".to_string(), + client_secret: "github-secret".to_string(), + }), + google: Some(OAuthProviderConfig { + client_id: "google-client".to_string(), + client_secret: "google-secret".to_string(), + }), + base_url: "https://serverbee.example".to_string(), + allow_registration: false, + oidc: None, + }; + let state = AppState::new(db, config) + .await + .expect("configured OAuth state"); + (state, tmp) + } + + fn redirect_url(redirect: Redirect) -> url::Url { + let response = redirect.into_response(); + let location = response + .headers() + .get(LOCATION) + .expect("OAuth redirect location") + .to_str() + .expect("OAuth redirect location text"); + url::Url::parse(location).expect("OAuth redirect URL") + } + fn make_states( state: &str, provider: &str, @@ -391,4 +431,104 @@ mod tests { let err = validate_and_consume_state(&states, "s1", "github", Some("nonce1")).unwrap_err(); assert!(matches!(err, AppError::BadRequest(_))); } + + #[tokio::test] + async fn authorize_github_sets_pkce_state_nonce_and_scopes() { + let (state, _tmp) = configured_state(false).await; + state.oauth_states.insert( + "expired-state".to_string(), + OAuthFlowState { + provider: "github".to_string(), + created_at: Utc::now() - chrono::Duration::minutes(11), + nonce: "expired-nonce".to_string(), + pkce_verifier: "expired-verifier".to_string(), + }, + ); + + let (headers, redirect) = + oauth_authorize(State(Arc::clone(&state)), Path("github".to_string())) + .await + .expect("GitHub authorize should redirect"); + let auth_url = redirect_url(redirect); + let query: std::collections::HashMap<_, _> = auth_url.query_pairs().into_owned().collect(); + + assert_eq!(auth_url.host_str(), Some("github.com")); + assert_eq!( + query.get("client_id").map(String::as_str), + Some("github-client") + ); + assert_eq!( + query.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert!( + query + .get("code_challenge") + .is_some_and(|value| !value.is_empty()) + ); + let scopes: Vec<&str> = query + .get("scope") + .expect("GitHub scopes") + .split_whitespace() + .collect(); + assert!(scopes.contains(&"read:user")); + assert!(scopes.contains(&"user:email")); + + let state_key = query.get("state").expect("OAuth state"); + let flow = state + .oauth_states + .get(state_key) + .expect("stored OAuth flow"); + assert_eq!(flow.provider, "github"); + assert!(!flow.pkce_verifier.is_empty()); + let nonce = flow.nonce.clone(); + drop(flow); + assert!(state.oauth_states.get("expired-state").is_none()); + + let cookie = headers + .get(SET_COOKIE) + .expect("OAuth nonce cookie") + .to_str() + .expect("OAuth nonce cookie text"); + assert!(cookie.starts_with(&format!("oauth_nonce={nonce};"))); + assert!(cookie.contains("HttpOnly; SameSite=Lax")); + assert!(cookie.contains("Max-Age=600")); + assert!(!cookie.contains("; Secure")); + } + + #[tokio::test] + async fn authorize_google_lists_provider_and_sets_secure_cookie() { + let (state, _tmp) = configured_state(true).await; + + let providers = list_providers(State(Arc::clone(&state))).await; + assert_eq!( + providers.0.data.providers, + vec!["github".to_string(), "google".to_string()] + ); + + let (headers, redirect) = + oauth_authorize(State(Arc::clone(&state)), Path("google".to_string())) + .await + .expect("Google authorize should redirect"); + let auth_url = redirect_url(redirect); + let query: std::collections::HashMap<_, _> = auth_url.query_pairs().into_owned().collect(); + + assert_eq!(auth_url.host_str(), Some("accounts.google.com")); + let scopes: Vec<&str> = query + .get("scope") + .expect("Google scopes") + .split_whitespace() + .collect(); + assert!(scopes.contains(&"openid")); + assert!(scopes.contains(&"email")); + assert!(scopes.contains(&"profile")); + assert!( + headers + .get(SET_COOKIE) + .expect("OAuth nonce cookie") + .to_str() + .expect("OAuth nonce cookie text") + .ends_with("; Secure") + ); + } } diff --git a/crates/server/tests/browser_ws.rs b/crates/server/tests/browser_ws.rs index b525a87d..13c58ec6 100644 --- a/crates/server/tests/browser_ws.rs +++ b/crates/server/tests/browser_ws.rs @@ -177,6 +177,73 @@ async fn browser_ws_receives_server_online_when_agent_connects() { let _ = agent_sink.close().await; } +#[tokio::test] +async fn browser_ws_ignores_non_command_frames_and_keeps_forwarding() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let api_key = create_api_key(&client, &base_url, "browser-ws-tolerance-key").await; + let (server_id, token) = register_agent(&client, &base_url).await; + + let request = browser_ws_request_with_key(&base_url, &api_key); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("browser WebSocket connection should succeed"); + let (mut browser_sink, mut browser_reader) = browser_ws.split(); + assert_eq!( + recv_browser_text(&mut browser_reader).await["type"], + "full_sync" + ); + + browser_sink + .send(tungstenite::Message::Text("{not-json".into())) + .await + .expect("send malformed browser frame"); + browser_sink + .send(tungstenite::Message::Binary(vec![1, 2, 3].into())) + .await + .expect("send binary browser frame"); + browser_sink + .send(tungstenite::Message::Ping(vec![4, 5, 6].into())) + .await + .expect("send browser ping"); + + let (mut agent_sink, mut agent_reader) = connect_agent(&base_url, &token).await; + assert_eq!(recv_agent_text(&mut agent_reader).await["type"], "welcome"); + send_system_info( + &mut agent_sink, + &mut agent_reader, + "tolerant-browser-msg", + None, + ) + .await; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut saw_online = false; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let Ok(Some(Ok(frame))) = tokio::time::timeout(remaining, browser_reader.next()).await + else { + break; + }; + let tungstenite::Message::Text(text) = frame else { + continue; + }; + let parsed: Value = serde_json::from_str(&text).expect("parse browser frame"); + if parsed["type"] == "server_online" && parsed["server_id"] == server_id { + saw_online = true; + break; + } + } + + assert!( + saw_online, + "malformed, binary, and ping frames must not stop broadcast forwarding" + ); + let _ = browser_sink.close().await; + let _ = agent_sink.close().await; +} + // ── Session-cookie auth path ────────────────────────────────────────────── #[tokio::test] diff --git a/crates/server/tests/ws_docker_browser.rs b/crates/server/tests/ws_docker_browser.rs index 914ff8c5..ccb66058 100644 --- a/crates/server/tests/ws_docker_browser.rs +++ b/crates/server/tests/ws_docker_browser.rs @@ -610,6 +610,60 @@ async fn browser_docker_viewer_first_starts_second_noop_last_stops() { let _ = agent_sink.close().await; } +/// Disconnecting the only browser viewer must perform the same stream cleanup +/// as an explicit unsubscribe, otherwise the agent keeps unnecessary Docker +/// polling tasks alive after the UI is gone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn browser_docker_last_viewer_disconnect_stops_streams() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let api_key = create_api_key(&client, &base_url, "docker-disconnect-key").await; + let (server_id, mut agent_sink, mut agent_reader) = + bring_up_docker_agent(&client, &base_url, CAP_DEFAULT | CAP_DOCKER).await; + + let (browser_ws, _) = + tokio_tungstenite::connect_async(browser_request_with_key(&base_url, &api_key)) + .await + .expect("browser WS should connect"); + let (mut browser_sink, mut browser_reader) = browser_ws.split(); + assert_eq!( + recv_client_text(&mut browser_reader).await["type"], + "full_sync" + ); + + send_client_text( + &mut browser_sink, + json!({ "type": "docker_subscribe", "server_id": server_id }), + ) + .await; + let started = agent_recv_until_types( + &mut agent_reader, + &["docker_start_stats", "docker_events_start"], + Duration::from_secs(3), + ) + .await; + assert_eq!(started.len(), 2, "subscription should start both streams"); + + browser_sink + .send(tungstenite::Message::Close(None)) + .await + .expect("close browser viewer"); + let stopped = agent_recv_until_types( + &mut agent_reader, + &["docker_stop_stats", "docker_events_stop"], + Duration::from_secs(3), + ) + .await; + assert_eq!( + stopped.len(), + 2, + "disconnecting the last viewer should stop both docker streams" + ); + + let _ = agent_sink.close().await; +} + /// A docker_subscribe for a server whose agent lacks CAP_DOCKER is silently /// ignored: no start frame ever reaches the agent. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 9de3dcc7d9bc5ba24ea7de995f42e62bf756e4ba Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 16:55:29 +0800 Subject: [PATCH 04/30] ci: enforce rust coverage floor --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++++++ README.md | 2 +- apps/docs/content/docs/en/testing.mdx | 1 + apps/docs/content/docs/zh/testing.mdx | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4375194b..fea25d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,6 +224,51 @@ jobs: - name: Test server integrations run: cargo test -p serverbee-server ${{ matrix.tests }} + rust-coverage: + name: Rust Coverage + needs: [changes, check] + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: web-dist + path: apps/web/dist + + - uses: dtolnay/rust-toolchain@stable + + - uses: taiki-e/install-action@cargo-llvm-cov + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: rust-coverage-v1 + cache-workspace-crates: "true" + + - name: Measure Rust coverage + run: >- + cargo llvm-cov + --workspace + --all-features + --locked + --lcov + --output-path lcov.info + --fail-under-regions 91.5 + + - name: Print coverage summary + if: always() + run: cargo llvm-cov report --summary-only + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: rust-coverage-lcov + path: lcov.info + if-no-files-found: error + retention-days: 14 + frontend: name: Frontend Check runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2c839dc4..b08a8adc 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ make cargo-clippy # Rust lint ### Testing & quality -ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (over 91% region overall; common ~98%, agent ~90%, server ~92%). Every push runs zero-warning Clippy, the full Rust suite, and frontend type/lint checks in CI. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. +ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (over 91% region overall; common ~98%, agent ~90%, server ~92%). Every Rust change must keep region coverage at or above 91.5% in CI, in addition to passing zero-warning Clippy and the full Rust suite. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. ## API diff --git a/apps/docs/content/docs/en/testing.mdx b/apps/docs/content/docs/en/testing.mdx index 4bb2ae3b..a2690fd2 100644 --- a/apps/docs/content/docs/en/testing.mdx +++ b/apps/docs/content/docs/en/testing.mdx @@ -75,6 +75,7 @@ Every push and pull request runs: - `cargo clippy --workspace -- -D warnings` — zero-warning lint - `cargo test --workspace` — the full Rust suite +- `cargo llvm-cov --workspace --all-features --fail-under-regions 91.5` — measured Rust coverage floor and downloadable LCOV report - `bun run tsc -b` and `bun x ultracite check` — frontend type check and lint (Biome) - POSIX and ShellCheck linting of the `install.sh` install script diff --git a/apps/docs/content/docs/zh/testing.mdx b/apps/docs/content/docs/zh/testing.mdx index a39a6702..67a302d3 100644 --- a/apps/docs/content/docs/zh/testing.mdx +++ b/apps/docs/content/docs/zh/testing.mdx @@ -75,6 +75,7 @@ cargo llvm-cov -p serverbee-server --summary-only - `cargo clippy --workspace -- -D warnings` —— 零警告 lint - `cargo test --workspace` —— 完整的 Rust 测试套件 +- `cargo llvm-cov --workspace --all-features --fail-under-regions 91.5` —— Rust 区域覆盖率门槛与可下载的 LCOV 报告 - `bun run tsc -b` 与 `bun x ultracite check` —— 前端类型检查与 lint(Biome) - 安装脚本 `install.sh` 的 POSIX 与 ShellCheck 检查 From 3ba5a6badface9b9dbab7f7a006fc2f54bb52461 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 17:13:05 +0800 Subject: [PATCH 05/30] ci: avoid duplicate coverage test run --- .github/workflows/ci.yml | 45 --------------------------- README.md | 2 +- apps/docs/content/docs/en/testing.mdx | 1 - apps/docs/content/docs/zh/testing.mdx | 1 - 4 files changed, 1 insertion(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea25d8e..4375194b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,51 +224,6 @@ jobs: - name: Test server integrations run: cargo test -p serverbee-server ${{ matrix.tests }} - rust-coverage: - name: Rust Coverage - needs: [changes, check] - if: needs.changes.outputs.rust == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/download-artifact@v4 - with: - name: web-dist - path: apps/web/dist - - - uses: dtolnay/rust-toolchain@stable - - - uses: taiki-e/install-action@cargo-llvm-cov - - - uses: Swatinem/rust-cache@v2 - with: - shared-key: rust-coverage-v1 - cache-workspace-crates: "true" - - - name: Measure Rust coverage - run: >- - cargo llvm-cov - --workspace - --all-features - --locked - --lcov - --output-path lcov.info - --fail-under-regions 91.5 - - - name: Print coverage summary - if: always() - run: cargo llvm-cov report --summary-only - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v4 - with: - name: rust-coverage-lcov - path: lcov.info - if-no-files-found: error - retention-days: 14 - frontend: name: Frontend Check runs-on: ubuntu-latest diff --git a/README.md b/README.md index b08a8adc..2c839dc4 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ make cargo-clippy # Rust lint ### Testing & quality -ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (over 91% region overall; common ~98%, agent ~90%, server ~92%). Every Rust change must keep region coverage at or above 91.5% in CI, in addition to passing zero-warning Clippy and the full Rust suite. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. +ServerBee ships with **3,800+ automated tests** — 3,200+ Rust (unit + 42 integration suites that drive the real Axum router over HTTP/WebSocket with a mock-agent harness) and 600+ frontend Vitest tests. Coverage is measured with `cargo-llvm-cov` (over 91% region overall; common ~98%, agent ~90%, server ~92%). Every push runs zero-warning Clippy, the full Rust suite, and frontend type/lint checks in CI. See the [Testing & Quality](https://docs.serverbee.app/en/docs/testing) guide. ## API diff --git a/apps/docs/content/docs/en/testing.mdx b/apps/docs/content/docs/en/testing.mdx index a2690fd2..4bb2ae3b 100644 --- a/apps/docs/content/docs/en/testing.mdx +++ b/apps/docs/content/docs/en/testing.mdx @@ -75,7 +75,6 @@ Every push and pull request runs: - `cargo clippy --workspace -- -D warnings` — zero-warning lint - `cargo test --workspace` — the full Rust suite -- `cargo llvm-cov --workspace --all-features --fail-under-regions 91.5` — measured Rust coverage floor and downloadable LCOV report - `bun run tsc -b` and `bun x ultracite check` — frontend type check and lint (Biome) - POSIX and ShellCheck linting of the `install.sh` install script diff --git a/apps/docs/content/docs/zh/testing.mdx b/apps/docs/content/docs/zh/testing.mdx index 67a302d3..a39a6702 100644 --- a/apps/docs/content/docs/zh/testing.mdx +++ b/apps/docs/content/docs/zh/testing.mdx @@ -75,7 +75,6 @@ cargo llvm-cov -p serverbee-server --summary-only - `cargo clippy --workspace -- -D warnings` —— 零警告 lint - `cargo test --workspace` —— 完整的 Rust 测试套件 -- `cargo llvm-cov --workspace --all-features --fail-under-regions 91.5` —— Rust 区域覆盖率门槛与可下载的 LCOV 报告 - `bun run tsc -b` 与 `bun x ultracite check` —— 前端类型检查与 lint(Biome) - 安装脚本 `install.sh` 的 POSIX 与 ShellCheck 检查 From a5f72acdf1cf33c21bdaef56efe3809c7b9b32fa Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 17:34:34 +0800 Subject: [PATCH 06/30] test(agent): cover file protocol workflows --- crates/agent/src/reporter/mod.rs | 483 +++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index d08e6666..a9e0553f 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -2175,6 +2175,489 @@ mod tests { } } + #[tokio::test] + async fn test_e2e_dispatch_file_write_then_read_round_trips_content() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("round-trip.txt"); + std::fs::write(&path, b"before").unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let path_for_server = path.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileWrite { + msg_id: "fw-1".to_string(), + path: path_for_server.clone(), + content: "aGVsbG8gZnJvbSB3cw==".to_string(), + }, + ) + .await; + let write_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "fw-1") + }) + .await; + assert!(matches!( + write_reply, + AgentMessage::FileOpResult { + success: true, + error: None, + .. + } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileRead { + msg_id: "fr-1".to_string(), + path: path_for_server, + max_size: 1024, + }, + ) + .await; + let read_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileReadResult { msg_id, .. } if msg_id == "fr-1") + }) + .await; + assert!(matches!( + read_reply, + AgentMessage::FileReadResult { + content: Some(content), + error: None, + .. + } if content == "aGVsbG8gZnJvbSB3cw==" + )); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(path).unwrap(), b"hello from ws"); + } + + #[tokio::test] + async fn test_e2e_dispatch_file_read_rejects_path_outside_root() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let outside_path = outside.path().join("secret.txt"); + std::fs::write(&outside_path, b"secret").unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let outside_path = outside_path.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileRead { + msg_id: "fr-outside".to_string(), + path: outside_path, + max_size: 1024, + }, + ) + .await; + let reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileReadResult { msg_id, .. } if msg_id == "fr-outside") + }) + .await; + assert!(matches!( + reply, + AgentMessage::FileReadResult { + content: None, + error: Some(error), + .. + } if error.contains("outside allowed root paths") + )); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + } + + #[tokio::test] + async fn test_e2e_dispatch_file_upload_persists_complete_payload() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("uploaded.bin"); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let destination_for_server = destination.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-complete".to_string(), + path: destination_for_server, + size: 8, + }, + ) + .await; + let initial_ack = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-complete") + }) + .await; + assert!(matches!( + initial_ack, + AgentMessage::FileUploadAck { offset: 0, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-complete".to_string(), + offset: 0, + data: "dXBsb2FkZWQ=".to_string(), + }, + ) + .await; + let chunk_ack = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 8 } if transfer_id == "up-complete") + }) + .await; + assert!(matches!( + chunk_ack, + AgentMessage::FileUploadAck { offset: 8, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadEnd { + transfer_id: "up-complete".to_string(), + }, + ) + .await; + let complete = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadComplete { transfer_id } if transfer_id == "up-complete") + }) + .await; + assert!(matches!(complete, AgentMessage::FileUploadComplete { .. })); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(destination).unwrap(), b"uploaded"); + } + + #[tokio::test] + async fn test_e2e_dispatch_file_crud_lifecycle() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source.txt"); + let destination = root.path().join("destination.txt"); + let directory = root.path().join("nested"); + std::fs::write(&source, b"move me").unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let root_path = root.path().to_string_lossy().to_string(); + let source_path = source.to_string_lossy().to_string(); + let destination_path = destination.to_string_lossy().to_string(); + let directory_path = directory.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileMkdir { + msg_id: "mkdir-1".to_string(), + path: directory_path, + }, + ) + .await; + let mkdir_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "mkdir-1") + }) + .await; + assert!(matches!( + mkdir_reply, + AgentMessage::FileOpResult { + success: true, + error: None, + .. + } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileList { + msg_id: "list-1".to_string(), + path: root_path, + }, + ) + .await; + let list_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileListResult { msg_id, .. } if msg_id == "list-1") + }) + .await; + match list_reply { + AgentMessage::FileListResult { + entries, + error: None, + .. + } => { + assert!(entries.iter().any(|entry| entry.name == "nested")); + assert!(entries.iter().any(|entry| entry.name == "source.txt")); + } + other => panic!("expected successful FileListResult, got {other:?}"), + } + + send_server_msg( + &mut ws, + &ServerMessage::FileStat { + msg_id: "stat-1".to_string(), + path: source_path.clone(), + }, + ) + .await; + let stat_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileStatResult { msg_id, .. } if msg_id == "stat-1") + }) + .await; + assert!(matches!( + stat_reply, + AgentMessage::FileStatResult { + entry: Some(entry), + error: None, + .. + } if entry.name == "source.txt" && entry.size == 7 + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileMove { + msg_id: "move-1".to_string(), + from: source_path, + to: destination_path.clone(), + }, + ) + .await; + let move_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "move-1") + }) + .await; + assert!(matches!( + move_reply, + AgentMessage::FileOpResult { success: true, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileDelete { + msg_id: "delete-1".to_string(), + path: destination_path, + recursive: false, + }, + ) + .await; + let delete_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "delete-1") + }) + .await; + assert!(matches!( + delete_reply, + AgentMessage::FileOpResult { success: true, .. } + )); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert!(directory.is_dir()); + assert!(!source.exists()); + assert!(!destination.exists()); + } + + #[tokio::test] + async fn test_e2e_dispatch_file_upload_bad_offset_aborts_and_allows_retry() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("retry.bin"); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let destination_for_server = destination.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-retry".to_string(), + path: destination_for_server.clone(), + size: 1, + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-retry") + }) + .await; + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-retry".to_string(), + offset: 1, + data: "eA==".to_string(), + }, + ) + .await; + let error_reply = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadError { transfer_id, .. } if transfer_id == "up-retry") + }) + .await; + assert!(matches!( + error_reply, + AgentMessage::FileUploadError { error, .. } + if error.contains("Unexpected upload offset") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-retry".to_string(), + path: destination_for_server, + size: 1, + }, + ) + .await; + let retry_ack = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-retry") + }) + .await; + assert!(matches!( + retry_ack, + AgentMessage::FileUploadAck { offset: 0, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-retry".to_string(), + offset: 0, + data: "eA==".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 1 } if transfer_id == "up-retry") + }) + .await; + send_server_msg( + &mut ws, + &ServerMessage::FileUploadEnd { + transfer_id: "up-retry".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadComplete { transfer_id } if transfer_id == "up-retry") + }) + .await; + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(destination).unwrap(), b"x"); + } + + #[tokio::test] + async fn test_e2e_dispatch_file_download_streams_complete_payload() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("download.bin"); + std::fs::write(&path, b"download").unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let path = path.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileDownloadStart { + transfer_id: "down-1".to_string(), + path, + }, + ) + .await; + let ready = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileDownloadReady { transfer_id, .. } if transfer_id == "down-1") + }) + .await; + assert!(matches!( + ready, + AgentMessage::FileDownloadReady { size: 8, .. } + )); + + let chunk = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileDownloadChunk { transfer_id, .. } if transfer_id == "down-1") + }) + .await; + assert!(matches!( + chunk, + AgentMessage::FileDownloadChunk { + offset: 0, + data, + .. + } if data == "ZG93bmxvYWQ=" + )); + + let end = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileDownloadEnd { transfer_id } if transfer_id == "down-1") + }) + .await; + assert!(matches!(end, AgentMessage::FileDownloadEnd { .. })); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + } + #[tokio::test] async fn test_e2e_dispatch_exec_denied_forwards_capability_denied() { // CAP_EXEC revoked: Exec is denied and the CapabilityDenied is pushed From ac9b6f59cd4ab0a3f5a7d476d758894354b03338 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 18:15:25 +0800 Subject: [PATCH 07/30] test: cover file recovery and terminal frames --- crates/agent/src/reporter/mod.rs | 241 +++++++++++++++++++++++ crates/server/tests/ws_terminal_relay.rs | 78 ++++++++ 2 files changed, 319 insertions(+) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index a9e0553f..038991f4 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -2658,6 +2658,247 @@ mod tests { drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; } + #[tokio::test] + async fn test_e2e_dispatch_file_download_missing_path_returns_error() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let missing_path = root + .path() + .join("missing.bin") + .to_string_lossy() + .to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileDownloadStart { + transfer_id: "down-missing".to_string(), + path: missing_path, + }, + ) + .await; + let error = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileDownloadError { transfer_id, .. } if transfer_id == "down-missing") + }) + .await; + assert!(matches!( + error, + AgentMessage::FileDownloadError { error, .. } + if error.contains("Cannot resolve path") + )); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + } + + #[tokio::test] + async fn test_e2e_dispatch_file_upload_oversize_rejection_allows_retry() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("size-retry.bin"); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + config.file.max_file_size = 1; + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let destination_for_server = destination.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-size".to_string(), + path: destination_for_server.clone(), + size: 2, + }, + ) + .await; + let rejection = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadError { transfer_id, .. } if transfer_id == "up-size") + }) + .await; + assert!(matches!( + rejection, + AgentMessage::FileUploadError { error, .. } + if error.contains("exceeds max_file_size") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-size".to_string(), + path: destination_for_server, + size: 1, + }, + ) + .await; + let retry = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-size") + }) + .await; + assert!(matches!( + retry, + AgentMessage::FileUploadAck { offset: 0, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-size".to_string(), + offset: 0, + data: "eA==".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 1 } if transfer_id == "up-size") + }) + .await; + send_server_msg( + &mut ws, + &ServerMessage::FileUploadEnd { + transfer_id: "up-size".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadComplete { transfer_id } if transfer_id == "up-size") + }) + .await; + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(destination).unwrap(), b"x"); + } + + #[tokio::test] + async fn test_e2e_dispatch_file_upload_incomplete_finish_allows_retry() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("finish-retry.bin"); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let destination_for_server = destination.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-finish".to_string(), + path: destination_for_server.clone(), + size: 2, + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-finish") + }) + .await; + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-finish".to_string(), + offset: 0, + data: "eA==".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 1 } if transfer_id == "up-finish") + }) + .await; + send_server_msg( + &mut ws, + &ServerMessage::FileUploadEnd { + transfer_id: "up-finish".to_string(), + }, + ) + .await; + let incomplete = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadError { transfer_id, .. } if transfer_id == "up-finish") + }) + .await; + assert!(matches!( + incomplete, + AgentMessage::FileUploadError { error, .. } + if error.contains("Upload incomplete") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadStart { + transfer_id: "up-finish".to_string(), + path: destination_for_server, + size: 1, + }, + ) + .await; + let retry = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 0 } if transfer_id == "up-finish") + }) + .await; + assert!(matches!( + retry, + AgentMessage::FileUploadAck { offset: 0, .. } + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileUploadChunk { + transfer_id: "up-finish".to_string(), + offset: 0, + data: "eQ==".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadAck { transfer_id, offset: 1 } if transfer_id == "up-finish") + }) + .await; + send_server_msg( + &mut ws, + &ServerMessage::FileUploadEnd { + transfer_id: "up-finish".to_string(), + }, + ) + .await; + let _ = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileUploadComplete { transfer_id } if transfer_id == "up-finish") + }) + .await; + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(destination).unwrap(), b"y"); + } + #[tokio::test] async fn test_e2e_dispatch_exec_denied_forwards_capability_denied() { // CAP_EXEC revoked: Exec is denied and the CapabilityDenied is pushed diff --git a/crates/server/tests/ws_terminal_relay.rs b/crates/server/tests/ws_terminal_relay.rs index 4fb08c1d..a889b557 100644 --- a/crates/server/tests/ws_terminal_relay.rs +++ b/crates/server/tests/ws_terminal_relay.rs @@ -16,6 +16,7 @@ //! - Agent `terminal_output` (base64) -> browser `{"type":"output","data":..}`. //! - Browser `{"type":"input"}` -> server `TerminalInput` to agent. //! - Browser `{"type":"resize"}` -> server `TerminalResize` to agent. +//! - Malformed text, binary, and ping frames do not interrupt later input. //! - Agent `terminal_error` -> browser `{"type":"error","error":..}`. //! - Capability gate: an agent advertising CAP_DEFAULT (no CAP_TERMINAL bit) //! makes the handshake fail with HTTP 403. @@ -303,6 +304,83 @@ async fn terminal_ws_relays_session_started_output_and_input_resize() { assert_eq!(resize["rows"], 40, "TerminalResize rows must pass through"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_ws_ignores_non_command_frames_and_keeps_relaying_input() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let api_key = create_api_key(&client, &base_url, "term-frame-tolerance-key").await; + + let (server_id, sink, reader) = bring_up_agent( + &client, + &base_url, + CAP_DEFAULT | CAP_TERMINAL, + "term-frame-tolerance-hs", + ) + .await; + + let _agent_sink = sink; + let agent_task = { + let mut reader = reader; + tokio::spawn(async move { + let mut input = None; + loop { + let message = recv_agent_text(&mut reader).await; + match message["type"].as_str() { + Some("terminal_input") => input = Some(message), + Some("terminal_close") => return input, + other if is_first_connect_noise(other) => {} + _ => {} + } + } + }) + }; + + let request = terminal_ws_request_with_key(&base_url, &server_id, &api_key); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("terminal WebSocket connect should succeed"); + let (mut browser_sink, mut browser_reader): (BrowserSink, BrowserReader) = browser_ws.split(); + let session = recv_browser_until(&mut browser_reader, "session").await; + let session_id = session["session_id"] + .as_str() + .expect("session_id") + .to_string(); + + browser_sink + .send(tungstenite::Message::Text("{".into())) + .await + .expect("send malformed text"); + browser_sink + .send(tungstenite::Message::Binary(vec![0, 1, 2].into())) + .await + .expect("send binary frame"); + browser_sink + .send(tungstenite::Message::Ping(Vec::new().into())) + .await + .expect("send ping frame"); + browser_sink + .send(tungstenite::Message::Text( + json!({ "type": "input", "data": "d2hvYW1pCg==" }) + .to_string() + .into(), + )) + .await + .expect("send valid input after ignored frames"); + browser_sink + .send(tungstenite::Message::Close(None)) + .await + .expect("close browser ws"); + + let input = tokio::time::timeout(Duration::from_secs(5), agent_task) + .await + .expect("agent responder timed out") + .expect("agent responder panicked") + .expect("valid input should reach the agent after ignored frames"); + assert_eq!(input["session_id"], session_id); + assert_eq!(input["data"], "d2hvYW1pCg=="); +} + // ── Agent terminal_error → browser error frame ───────────────────────────── #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 14fe9cad9044bfb2e17b45a186a302eae1c49d50 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 18:38:12 +0800 Subject: [PATCH 08/30] test: cover terminal revocation and file errors --- crates/agent/src/reporter/mod.rs | 137 +++++++++++++++ crates/server/tests/ws_terminal_relay.rs | 206 ++++++++++++++++++++++- 2 files changed, 341 insertions(+), 2 deletions(-) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index 038991f4..dda8e8df 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -2498,6 +2498,143 @@ mod tests { assert!(!destination.exists()); } + #[tokio::test] + async fn test_e2e_dispatch_file_errors_are_typed_and_connection_survives() { + let (listener, addr) = bind_fake_server().await; + let root = tempfile::tempdir().unwrap(); + let existing = root.path().join("existing.txt"); + let missing = root.path().join("missing.txt"); + let destination = root.path().join("destination.txt"); + std::fs::write(&existing, b"unchanged").unwrap(); + + let state = tempfile::tempdir().unwrap(); + let mut config = e2e_config(&addr, state.path()); + config.file = enabled_file_cfg(root.path()); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); + + let existing_path = existing.to_string_lossy().to_string(); + let missing_path = missing.to_string_lossy().to_string(); + let destination_path = destination.to_string_lossy().to_string(); + let server = tokio::spawn(async move { + let mut ws = accept_ws(&listener).await; + send_welcome(&mut ws, 30).await; + let _ = handshake_collect_system_info(&mut ws).await; + + send_server_msg( + &mut ws, + &ServerMessage::FileStat { + msg_id: "stat-missing".to_string(), + path: missing_path.clone(), + }, + ) + .await; + let stat_error = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileStatResult { msg_id, .. } if msg_id == "stat-missing") + }) + .await; + assert!(matches!( + stat_error, + AgentMessage::FileStatResult { + entry: None, + error: Some(error), + .. + } if error.contains("Cannot resolve path") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileList { + msg_id: "list-file".to_string(), + path: existing_path.clone(), + }, + ) + .await; + let list_error = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileListResult { msg_id, .. } if msg_id == "list-file") + }) + .await; + assert!(matches!( + list_error, + AgentMessage::FileListResult { + entries, + error: Some(error), + .. + } if entries.is_empty() && !error.is_empty() + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileWrite { + msg_id: "write-invalid".to_string(), + path: existing_path.clone(), + content: "not-base64!".to_string(), + }, + ) + .await; + let write_error = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "write-invalid") + }) + .await; + assert!(matches!( + write_error, + AgentMessage::FileOpResult { + success: false, + error: Some(error), + .. + } if error.contains("Invalid base64 content") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileMove { + msg_id: "move-missing".to_string(), + from: missing_path, + to: destination_path, + }, + ) + .await; + let move_error = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileOpResult { msg_id, .. } if msg_id == "move-missing") + }) + .await; + assert!(matches!( + move_error, + AgentMessage::FileOpResult { + success: false, + error: Some(error), + .. + } if error.contains("Cannot resolve path") + )); + + send_server_msg( + &mut ws, + &ServerMessage::FileStat { + msg_id: "stat-after-errors".to_string(), + path: existing_path, + }, + ) + .await; + let stat_success = read_agent_until(&mut ws, |message| { + matches!(message, AgentMessage::FileStatResult { msg_id, .. } if msg_id == "stat-after-errors") + }) + .await; + assert!(matches!( + stat_success, + AgentMessage::FileStatResult { + entry: Some(entry), + error: None, + .. + } if entry.name == "existing.txt" + )); + + ws.send(WsMessage::Close(None)).await.ok(); + }); + + drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; + assert_eq!(std::fs::read(existing).unwrap(), b"unchanged"); + assert!(!destination.exists()); + } + #[tokio::test] async fn test_e2e_dispatch_file_upload_bad_offset_aborts_and_allows_retry() { let (listener, addr) = bind_fake_server().await; diff --git a/crates/server/tests/ws_terminal_relay.rs b/crates/server/tests/ws_terminal_relay.rs index a889b557..a4b2af85 100644 --- a/crates/server/tests/ws_terminal_relay.rs +++ b/crates/server/tests/ws_terminal_relay.rs @@ -74,6 +74,15 @@ type BrowserSink = futures_util::stream::SplitSink< /// Create an admin API key (admin-only endpoint) and return the raw key. async fn create_api_key(client: &reqwest::Client, base_url: &str, name: &str) -> String { + create_api_key_with_id(client, base_url, name).await.1 +} + +/// Create an admin API key and return `(id, raw_key)`. +async fn create_api_key_with_id( + client: &reqwest::Client, + base_url: &str, + name: &str, +) -> (String, String) { let resp = client .post(format!("{base_url}/api/auth/api-keys")) .json(&json!({ "name": name })) @@ -82,10 +91,15 @@ async fn create_api_key(client: &reqwest::Client, base_url: &str, name: &str) -> .expect("POST /api/auth/api-keys failed"); assert_eq!(resp.status(), 200, "API key creation should succeed"); let body: Value = resp.json().await.expect("parse api-key response"); - body["data"]["key"] + let id = body["data"]["id"] + .as_str() + .expect("api key id missing from response") + .to_string(); + let key = body["data"]["key"] .as_str() .expect("api key missing from response") - .to_string() + .to_string(); + (id, key) } /// Build a `/api/ws/terminal/{server_id}` client request carrying an @@ -109,6 +123,26 @@ fn terminal_ws_request_with_key( request } +/// Build a terminal WebSocket request carrying a browser session cookie. +fn terminal_ws_request_with_cookie( + base_url: &str, + server_id: &str, + cookie: &str, +) -> tungstenite::handshake::client::Request { + let ws_url = format!( + "{}/api/ws/terminal/{server_id}", + base_url.replace("http://", "ws://") + ); + let mut request = ws_url + .into_client_request() + .expect("terminal ws request should build"); + request.headers_mut().insert( + "cookie", + HeaderValue::from_str(cookie).expect("cookie header should be valid"), + ); + request +} + /// Receive the next browser text frame, parsed as JSON (5s timeout). async fn recv_browser_text(reader: &mut BrowserReader) -> Value { let message = tokio::time::timeout(Duration::from_secs(5), reader.next()) @@ -140,6 +174,44 @@ async fn recv_browser_until(reader: &mut BrowserReader, expected: &str) -> Value } } +/// Wait for a terminal_closed audit row and return its parsed detail. +async fn wait_for_terminal_closed_audit( + client: &reqwest::Client, + base_url: &str, + session_id: &str, +) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let response = client + .get(format!( + "{base_url}/api/audit-logs?action=terminal_closed&limit=200" + )) + .send() + .await + .expect("GET /api/audit-logs failed"); + assert_eq!(response.status(), 200, "audit log listing should succeed"); + let body: Value = response.json().await.expect("parse audit response"); + let entries = body["data"]["entries"] + .as_array() + .expect("audit entries should be an array"); + for entry in entries { + let Some(detail) = entry["detail"].as_str() else { + continue; + }; + let parsed: Value = serde_json::from_str(detail).expect("terminal audit detail JSON"); + if parsed["session_id"] == session_id { + return parsed; + } + } + + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for terminal_closed audit" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + /// Send a single agent frame as a JSON text message. async fn send_agent_frame(sink: &mut AgentSink, frame: Value) { sink.send(tungstenite::Message::Text(frame.to_string().into())) @@ -381,6 +453,136 @@ async fn terminal_ws_ignores_non_command_frames_and_keeps_relaying_input() { assert_eq!(input["data"], "d2hvYW1pCg=="); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_ws_closes_and_audits_when_api_key_is_deleted() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let (api_key_id, api_key) = + create_api_key_with_id(&client, &base_url, "term-revoked-key").await; + + let (server_id, agent_sink, mut agent_reader) = bring_up_agent( + &client, + &base_url, + CAP_DEFAULT | CAP_TERMINAL, + "term-revoked-key-hs", + ) + .await; + + let request = terminal_ws_request_with_key(&base_url, &server_id, &api_key); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("terminal WebSocket connect should succeed"); + let (_browser_sink, mut browser_reader): (BrowserSink, BrowserReader) = browser_ws.split(); + let session = recv_browser_until(&mut browser_reader, "session").await; + let session_id = session["session_id"] + .as_str() + .expect("session_id") + .to_string(); + + let terminal_open = recv_agent_text(&mut agent_reader).await; + assert_eq!(terminal_open["type"], "terminal_open"); + assert_eq!(terminal_open["session_id"], session_id); + + let delete = client + .delete(format!("{base_url}/api/auth/api-keys/{api_key_id}")) + .send() + .await + .expect("delete API key"); + assert_eq!(delete.status(), 200, "API key deletion should succeed"); + + let error = recv_browser_until(&mut browser_reader, "error").await; + assert_eq!(error["error"], "Authorization revoked"); + + let terminal_close = + tokio::time::timeout(Duration::from_secs(5), recv_agent_text(&mut agent_reader)) + .await + .expect("timed out waiting for TerminalClose"); + assert_eq!(terminal_close["type"], "terminal_close"); + assert_eq!(terminal_close["session_id"], session_id); + + let audit = wait_for_terminal_closed_audit(&client, &base_url, &session_id).await; + assert_eq!(audit["close_reason"], "authorization_revoked"); + + drop(agent_sink); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_ws_closes_and_audits_when_browser_session_logs_out() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + let login = client + .post(format!("{base_url}/api/auth/login")) + .json(&json!({ "username": "admin", "password": "testpass" })) + .send() + .await + .expect("admin login failed"); + assert_eq!(login.status(), 200, "admin login should succeed"); + let cookie = login + .headers() + .get(reqwest::header::SET_COOKIE) + .expect("login should set a session cookie") + .to_str() + .expect("session cookie should be valid text") + .split(';') + .next() + .expect("session cookie pair") + .to_string(); + + let (server_id, agent_sink, mut agent_reader) = bring_up_agent( + &client, + &base_url, + CAP_DEFAULT | CAP_TERMINAL, + "term-logout-hs", + ) + .await; + + let request = terminal_ws_request_with_cookie(&base_url, &server_id, &cookie); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("terminal WebSocket connect should succeed"); + let (_browser_sink, mut browser_reader): (BrowserSink, BrowserReader) = browser_ws.split(); + let session = recv_browser_until(&mut browser_reader, "session").await; + let session_id = session["session_id"] + .as_str() + .expect("session_id") + .to_string(); + + let terminal_open = recv_agent_text(&mut agent_reader).await; + assert_eq!(terminal_open["type"], "terminal_open"); + assert_eq!(terminal_open["session_id"], session_id); + + let logout = client + .post(format!("{base_url}/api/auth/logout")) + .header(reqwest::header::COOKIE, &cookie) + .send() + .await + .expect("logout request failed"); + assert_eq!(logout.status(), 200, "logout should succeed"); + + let error = recv_browser_until(&mut browser_reader, "error").await; + assert_eq!(error["error"], "Authorization revoked"); + + let terminal_close = + tokio::time::timeout(Duration::from_secs(5), recv_agent_text(&mut agent_reader)) + .await + .expect("timed out waiting for TerminalClose"); + assert_eq!(terminal_close["type"], "terminal_close"); + assert_eq!(terminal_close["session_id"], session_id); + + let relogin = client + .post(format!("{base_url}/api/auth/login")) + .json(&json!({ "username": "admin", "password": "testpass" })) + .send() + .await + .expect("admin re-login failed"); + assert_eq!(relogin.status(), 200, "admin re-login should succeed"); + let audit = wait_for_terminal_closed_audit(&client, &base_url, &session_id).await; + assert_eq!(audit["close_reason"], "authorization_revoked"); + + drop(agent_sink); +} + // ── Agent terminal_error → browser error frame ───────────────────────────── #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 0b6dd1edbce09b7571c8ef75f86dc7dbe9229f31 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:06:19 +0800 Subject: [PATCH 09/30] fix(server): close terminals when agents disconnect --- crates/server/src/router/ws/terminal.rs | 3 +- crates/server/src/service/agent_manager.rs | 25 +++++++++--- crates/server/tests/ws_terminal_relay.rs | 47 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/crates/server/src/router/ws/terminal.rs b/crates/server/src/router/ws/terminal.rs index 5455cc79..36c1a032 100644 --- a/crates/server/src/router/ws/terminal.rs +++ b/crates/server/src/router/ws/terminal.rs @@ -82,7 +82,7 @@ async fn handle_terminal_ws( // Register terminal session in agent manager state .agent_manager - .register_terminal_session(session_id.clone(), output_tx); + .register_terminal_session(session_id.clone(), server_id.clone(), output_tx); // Send TerminalOpen to agent to create the PTY let agent_tx = match state.agent_manager.get_sender(&server_id) { @@ -269,6 +269,7 @@ async fn handle_terminal_ws( ) .await; } + let _ = ws_sink.send(Message::Close(None)).await; tracing::info!("Terminal WS closed: session={session_id}"); } diff --git a/crates/server/src/service/agent_manager.rs b/crates/server/src/service/agent_manager.rs index cd4cd704..27cf8046 100644 --- a/crates/server/src/service/agent_manager.rs +++ b/crates/server/src/service/agent_manager.rs @@ -68,6 +68,11 @@ pub enum TerminalSessionEvent { Error(String), } +struct TerminalSession { + server_id: String, + tx: TerminalOutputTx, +} + #[derive(Clone, Debug)] pub struct TracerouteRequestMeta { pub server_id: String, @@ -105,7 +110,7 @@ pub struct AgentManager { latest_reports: DashMap, browser_tx: broadcast::Sender, /// Maps session_id -> terminal output channel (for routing agent output to browser WS) - terminal_sessions: DashMap, + terminal_sessions: DashMap, /// Maps msg_id -> (oneshot sender, creation time, TTL) for HTTP→WS relay pending_requests: DashMap< String, @@ -390,8 +395,14 @@ impl AgentManager { } /// Register a terminal session for routing output from agent to browser. - pub fn register_terminal_session(&self, session_id: String, tx: TerminalOutputTx) { - self.terminal_sessions.insert(session_id, tx); + pub fn register_terminal_session( + &self, + session_id: String, + server_id: String, + tx: TerminalOutputTx, + ) { + self.terminal_sessions + .insert(session_id, TerminalSession { server_id, tx }); } /// Unregister a terminal session. @@ -401,7 +412,9 @@ impl AgentManager { /// Get the terminal output sender for a session. pub fn get_terminal_session(&self, session_id: &str) -> Option { - self.terminal_sessions.get(session_id).map(|v| v.clone()) + self.terminal_sessions + .get(session_id) + .map(|session| session.tx.clone()) } /// Find agents that have not reported for `threshold_secs` seconds. @@ -436,6 +449,8 @@ impl AgentManager { fn finish_connection_removal(&self, server_id: &str) { self.agent_local_capabilities.remove(server_id); self.temporary_grants.remove(server_id); + self.terminal_sessions + .retain(|_, session| session.server_id != server_id); self.remove_docker_log_sessions_for_server(server_id); self.clear_docker_caches(server_id); @@ -1148,7 +1163,7 @@ mod tests { fn test_terminal_session_lifecycle() { let (mgr, _rx) = make_manager(); let (tx, _) = mpsc::channel(1); - mgr.register_terminal_session("sess1".into(), tx); + mgr.register_terminal_session("sess1".into(), "server1".into(), tx); assert!(mgr.get_terminal_session("sess1").is_some()); mgr.unregister_terminal_session("sess1"); assert!(mgr.get_terminal_session("sess1").is_none()); diff --git a/crates/server/tests/ws_terminal_relay.rs b/crates/server/tests/ws_terminal_relay.rs index a4b2af85..75401f66 100644 --- a/crates/server/tests/ws_terminal_relay.rs +++ b/crates/server/tests/ws_terminal_relay.rs @@ -583,6 +583,53 @@ async fn terminal_ws_closes_and_audits_when_browser_session_logs_out() { drop(agent_sink); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_ws_closes_and_audits_when_agent_disconnects() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let api_key = create_api_key(&client, &base_url, "term-agent-disconnect-key").await; + + let (server_id, mut agent_sink, mut agent_reader) = bring_up_agent( + &client, + &base_url, + CAP_DEFAULT | CAP_TERMINAL, + "term-agent-disconnect-hs", + ) + .await; + + let request = terminal_ws_request_with_key(&base_url, &server_id, &api_key); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("terminal WebSocket connect should succeed"); + let (_browser_sink, mut browser_reader): (BrowserSink, BrowserReader) = browser_ws.split(); + let session = recv_browser_until(&mut browser_reader, "session").await; + let session_id = session["session_id"] + .as_str() + .expect("session_id") + .to_string(); + + let terminal_open = recv_agent_text(&mut agent_reader).await; + assert_eq!(terminal_open["type"], "terminal_open"); + assert_eq!(terminal_open["session_id"], session_id); + + agent_sink + .send(tungstenite::Message::Close(None)) + .await + .expect("close agent WebSocket"); + + let browser_end = tokio::time::timeout(Duration::from_secs(5), browser_reader.next()) + .await + .expect("terminal browser WebSocket should close after agent disconnect"); + assert!( + matches!(browser_end, None | Some(Ok(tungstenite::Message::Close(_)))), + "expected terminal browser WebSocket to end, got {browser_end:?}" + ); + + let audit = wait_for_terminal_closed_audit(&client, &base_url, &session_id).await; + assert_eq!(audit["close_reason"], "agent_disconnect"); +} + // ── Agent terminal_error → browser error frame ───────────────────────────── #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From a3a1bf7b193aac67655277583e43a6c07e265b95 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 10/30] test(agent): cover upgrade transaction recovery and guards --- crates/agent/src/upgrade/transaction.rs | 199 ++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/crates/agent/src/upgrade/transaction.rs b/crates/agent/src/upgrade/transaction.rs index b65a91f6..5cf253c7 100644 --- a/crates/agent/src/upgrade/transaction.rs +++ b/crates/agent/src/upgrade/transaction.rs @@ -695,6 +695,205 @@ mod tests { assert_eq!(std::fs::read_to_string(&backup).expect("backup"), "old"); } + #[test] + fn rollback_without_state_file_still_restores_and_reports() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + let candidate = temp.path().join("agent.new"); + write(¤t, "old"); + write(&candidate, "new"); + install_candidate_at(¤t, &candidate, "2.0.0", Some("job-lost".to_string())) + .expect("install candidate"); + + // Simulate a state file lost between install and rollback: the rollback + // must still restore the backup and synthesize a recovery report. + std::fs::remove_file(state_path(¤t)).expect("drop state file"); + assert!(read_state(¤t).expect("read state").is_none()); + + rollback_failed_candidate_at( + ¤t, + "2.0.0", + Some("job-lost".to_string()), + "candidate never reported healthy".to_string(), + ) + .expect("rollback without state"); + + assert_eq!(std::fs::read_to_string(¤t).expect("restored"), "old"); + let report = read_recovery_report_at(¤t, "1.0.0") + .expect("read report") + .expect("recovery report"); + assert_eq!(report.job_id.as_deref(), Some("job-lost")); + assert_eq!(report.target_version, "2.0.0"); + assert!(report.error.contains("candidate never reported healthy")); + } + + #[test] + fn crash_leftover_temp_files_are_replaced_during_install() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + let candidate = temp.path().join("agent.new"); + write(¤t, "old"); + write(&candidate, "new"); + // Leftovers from a crashed install must not block the next attempt. + let backup_temp = backup_path(¤t).with_extension("bak.tmp"); + write(&backup_temp, "stale backup temp"); + write(&state_temp_path(¤t), "stale state temp"); + + let backup = + install_candidate_at(¤t, &candidate, "2.0.0", None).expect("install candidate"); + + assert!(!backup_temp.exists()); + assert!(!state_temp_path(¤t).exists()); + assert_eq!(std::fs::read_to_string(¤t).expect("current"), "new"); + assert_eq!(std::fs::read_to_string(&backup).expect("backup"), "old"); + } + + #[test] + fn rollback_without_backup_reports_missing_backup() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + write(¤t, "old"); + + let error = rollback_failed_candidate_at(¤t, "2.0.0", None, "boom".to_string()) + .expect_err("rollback should fail without a backup"); + assert!( + error.to_string().contains("upgrade backup is missing"), + "unexpected error: {error}" + ); + assert_eq!(std::fs::read_to_string(¤t).expect("untouched"), "old"); + } + + #[test] + fn rollback_replaces_a_leftover_failed_binary() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + let candidate = temp.path().join("agent.new"); + write(¤t, "old"); + write(&candidate, "new"); + install_candidate_at(¤t, &candidate, "2.0.0", None).expect("install candidate"); + write(&failed_path(¤t), "leftover from an earlier rollback"); + + rollback_failed_candidate_at(¤t, "2.0.0", None, "boom".to_string()) + .expect("rollback over leftover"); + + assert_eq!(std::fs::read_to_string(¤t).expect("restored"), "old"); + assert_eq!( + std::fs::read_to_string(failed_path(¤t)).expect("failed candidate"), + "new" + ); + } + + #[test] + fn install_rejects_missing_binaries() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + let candidate = temp.path().join("agent.new"); + + let error = install_candidate_at(¤t, &candidate, "2.0.0", None) + .expect_err("missing current binary"); + assert!( + error.to_string().contains("current Agent binary"), + "unexpected error: {error}" + ); + + write(¤t, "old"); + let error = install_candidate_at(¤t, &candidate, "2.0.0", None) + .expect_err("missing candidate binary"); + assert!( + error.to_string().contains("candidate Agent binary"), + "unexpected error: {error}" + ); + assert!(!state_path(¤t).exists()); + } + + #[test] + fn state_machine_is_inert_without_a_state_file() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + write(¤t, "old"); + + assert_eq!( + prepare_startup_at(¤t, "1.0.0").expect("prepare startup"), + StartupDisposition::Normal + ); + assert!(!commit_startup_trial_at(¤t, "1.0.0").expect("commit without state")); + finalize_startup_trial_at(¤t, "1.0.0").expect("finalize without state"); + assert!(!rollback_unhealthy_trial_at(¤t, "1.0.0").expect("watchdog without state")); + assert!( + read_recovery_report_at(¤t, "1.0.0") + .expect("read report") + .is_none() + ); + assert!(!trial_is_active_at(¤t).expect("no trial")); + } + + #[test] + fn out_of_phase_transitions_are_rejected() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let current = temp.path().join("agent"); + let candidate = temp.path().join("agent.new"); + write(¤t, "old"); + write(&candidate, "new"); + install_candidate_at(¤t, &candidate, "2.0.0", None).expect("install candidate"); + + // Pending, not Booting: the commit is a no-op. + assert!(!commit_startup_trial_at(¤t, "2.0.0").expect("commit while pending")); + prepare_startup_at(¤t, "2.0.0").expect("prepare trial"); + // Booting, but the running version does not match the target. + assert!(!commit_startup_trial_at(¤t, "1.0.0").expect("commit with wrong version")); + // Booting, not Healthy: finalize leaves the state in place. + finalize_startup_trial_at(¤t, "2.0.0").expect("finalize while booting"); + assert!(state_path(¤t).exists()); + // Healthy, but a mismatched version must not drop the state either. + assert!(commit_startup_trial_at(¤t, "2.0.0").expect("commit trial")); + finalize_startup_trial_at(¤t, "1.0.0").expect("finalize with wrong version"); + assert!(state_path(¤t).exists()); + // Healthy is not RolledBack, so no recovery report is available yet. + assert!( + read_recovery_report_at(¤t, "1.0.0") + .expect("read report") + .is_none() + ); + + assert!(rollback_unhealthy_trial_at(¤t, "2.0.0").expect("watchdog rollback")); + // Once rolled back, a start that still claims the candidate version is a + // hard error rather than a silent retry. + let error = prepare_startup_at(¤t, "2.0.0").expect_err("rolled back startup"); + assert!( + error.to_string().contains("rollback state still points at"), + "unexpected error: {error}" + ); + // A rolled-back trial is no longer active and cannot be committed. + assert!(!trial_is_active_at(¤t).expect("rolled back trial")); + assert!(!commit_startup_trial_at(¤t, "2.0.0").expect("commit after rollback")); + assert!(!rollback_unhealthy_trial_at(¤t, "2.0.0").expect("second watchdog pass")); + } + + #[cfg(unix)] + #[tokio::test] + async fn candidate_probe_reports_a_failing_exit_status() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::TempDir::new().expect("temp dir"); + let candidate = temp.path().join("candidate"); + write(&candidate, "#!/bin/sh\necho 'probe blew up' >&2\nexit 1\n"); + std::fs::set_permissions(&candidate, std::fs::Permissions::from_mode(0o755)) + .expect("candidate permissions"); + + let error = verify_candidate_version(&candidate, "2.0.0") + .await + .expect_err("non-zero exit should fail"); + let message = error.to_string(); + assert!( + message.contains("candidate probe exited with"), + "unexpected error: {message}" + ); + assert!( + message.contains("probe blew up"), + "stderr should be surfaced, got: {message}" + ); + } + #[cfg(unix)] #[tokio::test] async fn candidate_probe_checks_reported_version() { From e74b5939ccd9f489bce275956c1bfa14c177592e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 11/30] test(agent): cover upgrade rejection and rollback reporting --- crates/agent/src/reporter/mod.rs | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index dda8e8df..eaf8142c 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -3235,6 +3235,136 @@ mod tests { let pong = drive_e2e(&mut reporter, server, Duration::from_secs(10)).await; assert!(matches!(pong, AgentMessage::Pong)); } + + // ---------------------------------------------------------------------- + // perform_upgrade / rollback_and_restart_candidate — the guard rails that + // reject a request before any network or filesystem work happens. + // ---------------------------------------------------------------------- + + /// Run `perform_upgrade` against a config that must be rejected locally, + /// and return the error text carried by the emitted `UpgradeResult`. + async fn upgrade_rejection_reason(version: &str, upgrade_cfg: UpgradeConfig) -> String { + let (tx, mut rx) = mpsc::channel::(4); + let result = perform_upgrade(version, &upgrade_cfg, Some("job-x".to_string()), tx).await; + assert!(result.is_err(), "upgrade should have been rejected"); + + match rx.recv().await.expect("progress message expected") { + AgentMessage::UpgradeProgress { stage, .. } => { + assert_eq!(stage, UpgradeStage::Downloading); + } + other => panic!("expected UpgradeProgress, got {other:?}"), + } + match rx.recv().await.expect("failure message expected") { + AgentMessage::UpgradeResult { + job_id, + target_version, + stage, + error, + .. + } => { + assert_eq!(job_id, Some("job-x".to_string())); + assert_eq!(target_version, version); + assert_eq!(stage, UpgradeStage::Downloading); + error + } + other => panic!("expected UpgradeResult, got {other:?}"), + } + } + + #[tokio::test] + async fn test_perform_upgrade_rejects_downgrade_and_bad_source() { + // Same version as the running agent: not strictly greater, so refused. + let error = upgrade_rejection_reason( + serverbee_common::constants::VERSION, + UpgradeConfig::default(), + ) + .await; + assert!( + error.contains("anti-downgrade"), + "unexpected error: {error}" + ); + + // A malformed certificate pin is rejected before any client is built. + let error = upgrade_rejection_reason( + "999.0.0", + UpgradeConfig { + release_cert_spki_sha256: "not-a-valid-pin".to_string(), + ..UpgradeConfig::default() + }, + ) + .await; + assert!( + error.contains("invalid SPKI pin"), + "unexpected error: {error}" + ); + + // A plaintext release source never reaches the network. + let error = upgrade_rejection_reason( + "999.0.0", + UpgradeConfig { + release_repo_url: "http://example.invalid/releases".to_string(), + ..UpgradeConfig::default() + }, + ) + .await; + assert!(error.contains("derive url"), "unexpected error: {error}"); + assert!(error.contains("https"), "unexpected error: {error}"); + } + + #[tokio::test] + async fn test_rollback_and_restart_candidate_reports_failure_when_rollback_fails() { + let tmp = tempfile::tempdir().unwrap(); + let current_exe = tmp.path().join("agent"); + std::fs::write(¤t_exe, "candidate").unwrap(); + // No backup file exists, so the rollback itself fails and the function + // reports instead of restarting the process. + let backup_path = tmp.path().join("agent.bak"); + + let (tx, mut rx) = mpsc::channel::(4); + let result = rollback_and_restart_candidate( + ¤t_exe, + &backup_path, + &tx, + Some("job-rollback".to_string()), + "2.0.0", + "candidate missed the 90s startup health window".to_string(), + ) + .await; + let error = result.expect_err("rollback without a backup must fail"); + assert!( + error.to_string().contains("rollback also failed"), + "unexpected error: {error}" + ); + + match rx.recv().await.expect("failure message expected") { + AgentMessage::UpgradeResult { + job_id, + target_version, + stage, + error, + backup_path: reported_backup, + .. + } => { + assert_eq!(job_id, Some("job-rollback".to_string())); + assert_eq!(target_version, "2.0.0"); + assert_eq!(stage, UpgradeStage::Restarting); + assert!( + error.contains("startup health window"), + "the original reason should be preserved, got: {error}" + ); + assert!( + error.contains("upgrade backup is missing"), + "the rollback failure should be surfaced, got: {error}" + ); + assert_eq!( + reported_backup, + Some(backup_path.display().to_string()), + "operators need the backup path to recover by hand" + ); + } + other => panic!("expected UpgradeResult, got {other:?}"), + } + } } /// Fetch external IP address from a single remote service. From 64de2c91ca1b2d741d295b8e6e9e9ce49661e631 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 12/30] test(agent): cover terminal reader error paths --- crates/agent/src/terminal.rs | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/agent/src/terminal.rs b/crates/agent/src/terminal.rs index e576885e..9c2dc2e8 100644 --- a/crates/agent/src/terminal.rs +++ b/crates/agent/src/terminal.rs @@ -849,4 +849,65 @@ mod tests { }; assert!(matches!(exited, TerminalEvent::Exited { session_id } if session_id == "s4")); } + + /// A reader that always fails, standing in for a PTY master whose slave + /// side went away (EIO on Linux). + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("pty gone")) + } + } + + /// Run `body` on a helper thread and panic if it has not finished within + /// `secs`. `read_pty_output` blocks, so a regression that fails to break + /// out of the loop would otherwise hang the whole test run. + fn run_with_timeout(secs: u64, body: F) + where + F: FnOnce() + Send + 'static, + { + let handle = std::thread::spawn(body); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); + while !handle.is_finished() { + assert!( + std::time::Instant::now() < deadline, + "read_pty_output did not return within {secs}s" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + handle.join().expect("reader thread panicked"); + } + + /// A read error terminates the loop and still reports the session as + /// exited, so the server side is not left with a dangling session. + #[test] + fn read_pty_output_emits_exited_on_read_error() { + let (tx, mut rx) = mpsc::channel(8); + run_with_timeout(5, move || { + read_pty_output(Box::new(FailingReader), "err-session", &tx); + }); + + let ev = rx.blocking_recv().expect("exited event"); + assert!( + matches!(ev, TerminalEvent::Exited { ref session_id } if session_id == "err-session"), + "expected an Exited event for the failed reader" + ); + assert!( + rx.blocking_recv().is_none(), + "the reader should have dropped the sender and stopped" + ); + } + + /// Once the receiving end is gone there is nobody left to serve, so the + /// blocking reader must return instead of spinning on the PTY. + #[test] + fn read_pty_output_stops_when_channel_closed() { + let (tx, rx) = mpsc::channel::(8); + drop(rx); + run_with_timeout(5, move || { + let reader = std::io::Cursor::new(b"hi".to_vec()); + read_pty_output(Box::new(reader), "closed-session", &tx); + }); + } } From 9df67c0871b513d3253157c6e027cb08aaeaf713 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 13/30] test(server): cover expired offer terminal enforcement --- .../server/src/service/agent_authority/mod.rs | 207 +++++++++++++++++- 1 file changed, 198 insertions(+), 9 deletions(-) diff --git a/crates/server/src/service/agent_authority/mod.rs b/crates/server/src/service/agent_authority/mod.rs index dff87af0..cde15f84 100644 --- a/crates/server/src/service/agent_authority/mod.rs +++ b/crates/server/src/service/agent_authority/mod.rs @@ -1112,6 +1112,29 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9527) } + /// Rewind an offer's expiry into the past. The offer stays Outstanding + /// (`outcome IS NULL`) until some caller materializes the elapsed deadline. + async fn elapse_offer(db: &DatabaseConnection, offer_id: &OfferId) { + let mut row: enrollment_offer::ActiveModel = + enrollment_offer::Entity::find_by_id(offer_id.as_str()) + .one(db) + .await + .expect("read offer") + .expect("offer") + .into(); + row.expires_at = Set(Utc::now() - Duration::seconds(1)); + row.update(db).await.expect("expire offer"); + } + + async fn offer_outcome(db: &DatabaseConnection, offer_id: &OfferId) -> Option { + enrollment_offer::Entity::find_by_id(offer_id.as_str()) + .one(db) + .await + .expect("read offer") + .expect("offer") + .outcome + } + #[tokio::test] async fn issue_offer_for_unclaimed_exposes_one_outstanding_offer() { let fixture = authority_with_unclaimed_server().await; @@ -1403,15 +1426,7 @@ mod tests { async fn elapsed_offer_is_materialized_as_expired_before_successor_is_issued() { let fixture = authority_with_unclaimed_server().await; let expired = fixture.issue().await; - let mut row: enrollment_offer::ActiveModel = - enrollment_offer::Entity::find_by_id(expired.id.as_str()) - .one(&fixture.db) - .await - .expect("read offer") - .expect("offer") - .into(); - row.expires_at = Set(Utc::now() - Duration::seconds(1)); - row.update(&fixture.db).await.expect("expire offer"); + elapse_offer(&fixture.db, &expired.id).await; let successor = fixture .authority @@ -1442,6 +1457,180 @@ mod tests { ); } + #[tokio::test] + async fn expired_code_cannot_claim_authority_and_is_materialized_as_expired() { + let fixture = authority_with_unclaimed_server().await; + let offer = fixture.issue().await; + elapse_offer(&fixture.db, &offer.id).await; + + let result = fixture + .authority + .claim(ClaimAgent { + code: offer.code.clone(), + proposed_run_token: ProposedRunToken::parse(FIRST_TOKEN).expect("run token"), + source: agent_source(), + remote_addr: Some("127.0.0.1".to_string()), + }) + .await; + + assert!(matches!(result, Err(ClaimError::Rejected))); + assert_eq!( + offer_outcome(&fixture.db, &offer.id).await.as_deref(), + Some("expired") + ); + // A rejected claim must not install the proposed credential. + let stored = server::Entity::find_by_id(fixture.server_id.as_str()) + .one(&fixture.db) + .await + .expect("read server") + .expect("server"); + assert!(stored.token_hash.is_none()); + assert!(stored.token_prefix.is_none()); + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert!(state.outstanding_offer.is_none()); + } + + #[tokio::test] + async fn revoking_a_consumed_offer_reports_its_terminal_outcome() { + let fixture = authority_with_unclaimed_server().await; + let consumed = fixture.claim_initial(FIRST_TOKEN).await; + + let result = fixture + .authority + .revoke_offer(RevokeOffer { + server_id: fixture.server_id.clone(), + offer_id: consumed.id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await; + + assert!(matches!( + result, + Err(RevokeOfferError::Terminal(OfferOutcome::Consumed)) + )); + // The terminal outcome is immutable: revocation must not overwrite it. + assert_eq!( + offer_outcome(&fixture.db, &consumed.id).await.as_deref(), + Some("consumed") + ); + } + + #[tokio::test] + async fn elapsed_offers_reject_revocation_and_replacement_as_expired() { + let fixture = authority_with_unclaimed_server().await; + let revoked_target = fixture.issue().await; + elapse_offer(&fixture.db, &revoked_target.id).await; + + let revoke = fixture + .authority + .revoke_offer(RevokeOffer { + server_id: fixture.server_id.clone(), + offer_id: revoked_target.id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await; + + assert!(matches!( + revoke, + Err(RevokeOfferError::Terminal(OfferOutcome::Expired)) + )); + assert_eq!( + offer_outcome(&fixture.db, &revoked_target.id) + .await + .as_deref(), + Some("expired") + ); + + // The elapsed offer is terminal now, so a successor can be issued and + // driven into the same deadline for the replacement path. + let replace_target = fixture.issue().await; + elapse_offer(&fixture.db, &replace_target.id).await; + + let replace = fixture + .authority + .replace_offer(ReplaceOffer { + server_id: fixture.server_id.clone(), + offer_id: replace_target.id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await; + + assert!(matches!( + replace, + Err(ReplaceOfferError::NotOutstanding { + outcome: OfferOutcome::Expired, + current: None + }) + )); + assert_eq!( + offer_outcome(&fixture.db, &replace_target.id) + .await + .as_deref(), + Some("expired") + ); + assert!( + fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state") + .outstanding_offer + .is_none() + ); + } + + #[tokio::test] + async fn authority_revocation_marks_an_elapsed_open_offer_expired() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + let offer = fixture + .authority + .begin_reenrollment(BeginReenrollment { + server_id: fixture.server_id.clone(), + mode: ReenrollmentMode::Graceful, + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("begin graceful re-enrollment"); + elapse_offer(&fixture.db, &offer.id).await; + + let receipt = fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("revoke authority"); + + assert!(receipt.changed); + // The offer had already elapsed, so it records "expired" rather than + // being attributed to the revoking actor. + assert_eq!( + offer_outcome(&fixture.db, &offer.id).await.as_deref(), + Some("expired") + ); + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert!(state.outstanding_offer.is_none()); + } + #[tokio::test] async fn authority_revocation_creates_no_offer_and_is_idempotent() { let fixture = authority_with_unclaimed_server().await; From e518df2aec18900e35427c3ccb4740f73f36b956 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 14/30] test(server): cover full sync report projection --- crates/server/tests/browser_ws.rs | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/server/tests/browser_ws.rs b/crates/server/tests/browser_ws.rs index 13c58ec6..15eefbba 100644 --- a/crates/server/tests/browser_ws.rs +++ b/crates/server/tests/browser_ws.rs @@ -5,6 +5,9 @@ //! Coverage: //! - Happy path: an authenticated client (x-api-key header) connects and //! receives the initial `full_sync` frame. +//! - Report projection: a `full_sync` built while an agent has a cached report +//! carries that report's metrics (including summed disk I/O), not the +//! all-zero fallback. //! - State change: after a browser is connected, registering + connecting a //! mock agent broadcasts a `server_online` frame that the browser observes. //! - Auth paths: session-cookie auth and member API-key auth both connect. @@ -123,6 +126,99 @@ async fn browser_ws_sends_full_sync_on_connect_with_api_key() { ); } +#[tokio::test] +async fn browser_ws_full_sync_carries_latest_agent_report() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let api_key = create_api_key(&client, &base_url, "fullsync-report-key").await; + let (server_id, token) = register_agent(&client, &base_url).await; + + let (mut agent_sink, mut agent_reader) = connect_agent(&base_url, &token).await; + assert_eq!(recv_agent_text(&mut agent_reader).await["type"], "welcome"); + send_system_info(&mut agent_sink, &mut agent_reader, "fullsync-report", None).await; + + // `Report` is a newtype variant: the SystemReport fields sit alongside the + // "report" tag. Every metric is non-zero so a full_sync built from the + // all-zero fallback branch cannot pass this test. + let report = serde_json::json!({ + "type": "report", + "cpu": 45.5, + "mem_used": 8_000_000_000_i64, + "swap_used": 500_000_000_i64, + "disk_used": 30_000_000_000_i64, + "net_in_speed": 1_000_000_i64, + "net_out_speed": 500_000_i64, + "net_in_transfer": 10_000_000_000_i64, + "net_out_transfer": 5_000_000_000_i64, + "load1": 1.5, + "load5": 1.2, + "load15": 0.8, + "tcp_conn": 42, + "udp_conn": 5, + "process_count": 120, + "uptime": 86_400_u64, + "disk_io": [ + { "name": "sda", "read_bytes_per_sec": 1_000_u64, "write_bytes_per_sec": 2_000_u64 }, + { "name": "sdb", "read_bytes_per_sec": 500_u64, "write_bytes_per_sec": 250_u64 } + ], + "temperature": 55.0, + "gpu": null + }); + agent_sink + .send(tungstenite::Message::Text(report.to_string().into())) + .await + .expect("send report"); + + // The agent read loop handles frames in order, so an Ack for a handshake + // sent *after* the report proves the report already reached the cache. + send_system_info( + &mut agent_sink, + &mut agent_reader, + "post-report-handshake", + None, + ) + .await; + + let request = browser_ws_request_with_key(&base_url, &api_key); + let (browser_ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("browser WebSocket connection should succeed"); + let (_sink, mut reader) = browser_ws.split(); + let full_sync = recv_browser_text(&mut reader).await; + assert_eq!(full_sync["type"], "full_sync"); + + let entry = full_sync["servers"] + .as_array() + .expect("full_sync.servers should be an array") + .iter() + .find(|s| s["id"].as_str() == Some(server_id.as_str())) + .expect("full_sync should include the connected agent's server") + .clone(); + + assert_eq!(entry["online"], true, "the agent socket is still open"); + assert_eq!(entry["cpu"], 45.5); + assert_eq!(entry["mem_used"], 8_000_000_000_i64); + assert_eq!(entry["swap_used"], 500_000_000_i64); + assert_eq!(entry["disk_used"], 30_000_000_000_i64); + assert_eq!(entry["net_in_speed"], 1_000_000_i64); + assert_eq!(entry["net_out_speed"], 500_000_i64); + assert_eq!(entry["net_in_transfer"], 10_000_000_000_i64); + assert_eq!(entry["net_out_transfer"], 5_000_000_000_i64); + assert_eq!(entry["load1"], 1.5); + assert_eq!(entry["load5"], 1.2); + assert_eq!(entry["load15"], 0.8); + assert_eq!(entry["tcp_conn"], 42); + assert_eq!(entry["udp_conn"], 5); + assert_eq!(entry["process_count"], 120); + assert_eq!(entry["uptime"], 86_400_u64); + // Disk I/O is summed across devices before it reaches the browser. + assert_eq!(entry["disk_read_bytes_per_sec"], 1_500_u64); + assert_eq!(entry["disk_write_bytes_per_sec"], 2_250_u64); + + let _ = agent_sink.close().await; +} + // ── State change → server_online broadcast ──────────────────────────────── #[tokio::test] From 965874ee70d32ba022921db149fafa22f751571c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:19 +0800 Subject: [PATCH 15/30] test(server): cover offer api errors and ws auth rejects --- .../tests/agent_registration_integration.rs | 337 +++++++++++++++++- 1 file changed, 331 insertions(+), 6 deletions(-) diff --git a/crates/server/tests/agent_registration_integration.rs b/crates/server/tests/agent_registration_integration.rs index 4328a4e7..bbe5b88d 100644 --- a/crates/server/tests/agent_registration_integration.rs +++ b/crates/server/tests/agent_registration_integration.rs @@ -4,7 +4,9 @@ use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use tokio_tungstenite::tungstenite; -use common::{connect_agent, http_client, login_admin, start_test_server}; +use common::{ + connect_agent, http_client, login_admin, register_agent, send_system_info, start_test_server, +}; fn onboarding_body(request_id: &str, name: &str) -> Value { json!({ @@ -39,11 +41,7 @@ async fn claim( .expect("claim request") } -async fn assert_ws_unauthorized(base_url: &str, token: &str) { - let url = format!( - "{}/api/agent/ws?token={token}", - base_url.replace("http://", "ws://") - ); +async fn assert_ws_handshake_unauthorized(url: String) { let error = tokio_tungstenite::connect_async(url) .await .expect_err("credential must be rejected"); @@ -53,6 +51,14 @@ async fn assert_ws_unauthorized(base_url: &str, token: &str) { )); } +async fn assert_ws_unauthorized(base_url: &str, token: &str) { + assert_ws_handshake_unauthorized(format!( + "{}/api/agent/ws?token={token}", + base_url.replace("http://", "ws://") + )) + .await; +} + #[tokio::test] async fn onboarding_request_replay_returns_same_server_without_plaintext_code() { let (base_url, _tmp) = start_test_server().await; @@ -364,3 +370,322 @@ async fn authority_revocation_creates_no_offer_and_events_survive_server_delete( assert!(transitions.contains(&"authority_revoked")); assert!(transitions.contains(&"server_deleted")); } + +// =========================================================================== +// Authority offer error mapping — `map_issue_offer_error`, +// `map_revoke_offer_error` and `map_reenrollment_error` in +// `router/api/server.rs`. The success arms are covered above; these tests pin +// the HTTP contract (status + machine-readable `error.code` + `details`) that +// the web/iOS clients branch on. +// =========================================================================== + +/// Parse an error response into `(status, error.code, error.details)`. +async fn error_parts(response: reqwest::Response) -> (u16, String, Value) { + let status = response.status().as_u16(); + let body: Value = response.json().await.expect("error body"); + let code = body["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string(); + let details = body["error"]["details"].clone(); + (status, code, details) +} + +// Issuing an offer for a server id that does not exist → 404 +// (`IssueOfferError::NotFound`). The id is well-formed, so it gets past +// `parse_server_id` and reaches the store lookup. +#[tokio::test] +async fn issue_offer_unknown_server_is_404() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let response = client + .post(format!( + "{base_url}/api/servers/no-such-server/agent-authority/offers" + )) + .json(&json!({})) + .send() + .await + .expect("issue offer"); + let (status, code, _) = error_parts(response).await; + assert_eq!(status, 404); + assert_eq!(code, "NOT_FOUND"); +} + +// Onboarding already mints an Outstanding offer, so an immediate issue-offer +// call collides → 409 `ENROLLMENT_OFFER_OUTSTANDING`, and the payload carries +// the current offer so the UI can show/replace it without a second round trip. +#[tokio::test] +async fn issue_offer_when_outstanding_exists_is_409_with_current_offer_details() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let offer_id = created["data"]["enrollment"]["id"] + .as_str() + .expect("offer id"); + + let response = client + .post(format!( + "{base_url}/api/servers/{server_id}/agent-authority/offers" + )) + .json(&json!({})) + .send() + .await + .expect("issue offer"); + let (status, code, details) = error_parts(response).await; + assert_eq!(status, 409); + assert_eq!(code, "ENROLLMENT_OFFER_OUTSTANDING"); + assert_eq!(details["current_offer"]["id"], offer_id); + assert!( + details["current_offer"]["code_prefix"].is_string(), + "conflict details expose the offer prefix, never the plaintext code" + ); + assert!( + details["current_offer"].get("code").is_none(), + "the plaintext enrollment code must never reappear in an error body" + ); +} + +// Once an agent has claimed authority the offer is Consumed, so there is no +// Outstanding offer to collide with — the AlreadyClaimed guard fires first and +// steers the caller to re-enrollment instead. +#[tokio::test] +async fn issue_offer_after_agent_claimed_is_409_already_claimed() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + let (server_id, token) = register_agent(&client, &base_url).await; + + let (mut sink, mut reader) = connect_agent(&base_url, &token).await; + send_system_info(&mut sink, &mut reader, "sysinfo-1", None).await; + + let response = client + .post(format!( + "{base_url}/api/servers/{server_id}/agent-authority/offers" + )) + .json(&json!({})) + .send() + .await + .expect("issue offer"); + let (status, code, details) = error_parts(response).await; + assert_eq!(status, 409); + assert_eq!(code, "AGENT_AUTHORITY_ALREADY_CLAIMED"); + assert!( + details.is_null(), + "already-claimed carries no offer details" + ); + + sink.close().await.expect("close socket"); +} + +// Both NotFound arms of `map_revoke_offer_error`: an unknown server id, and a +// real server with an offer id that belongs to no offer. +#[tokio::test] +async fn revoke_offer_unknown_server_and_unknown_offer_are_404() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + + let unknown_server = client + .delete(format!( + "{base_url}/api/servers/no-such-server/agent-authority/offers/no-such-offer" + )) + .send() + .await + .expect("revoke on unknown server"); + let (status, code, _) = error_parts(unknown_server).await; + assert_eq!(status, 404, "the server is checked before the offer"); + assert_eq!(code, "NOT_FOUND"); + + let unknown_offer = client + .delete(format!( + "{base_url}/api/servers/{server_id}/agent-authority/offers/no-such-offer" + )) + .send() + .await + .expect("revoke unknown offer"); + let (status, code, _) = error_parts(unknown_offer).await; + assert_eq!(status, 404); + assert_eq!(code, "NOT_FOUND"); +} + +// Revoking an offer the agent already consumed is a terminal-state conflict, +// not a no-op: `RevokeOfferError::Terminal` → 409 `ENROLLMENT_OFFER_TERMINAL`. +// (Only the Revoked outcome short-circuits to `already_revoked: true`; that +// idempotent path is asserted in `offer_replacement_is_exact_and_revocation_is_idempotent`.) +#[tokio::test] +async fn revoke_offer_after_code_consumed_is_409_terminal() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let offer_id = created["data"]["enrollment"]["id"] + .as_str() + .expect("offer id"); + let code = created["data"]["enrollment"]["code"] + .as_str() + .expect("code"); + assert_eq!( + claim( + &client, + &base_url, + code, + "agent-token-0123456789abcdefghijklmnop" + ) + .await + .status(), + 200 + ); + + let response = client + .delete(format!( + "{base_url}/api/servers/{server_id}/agent-authority/offers/{offer_id}" + )) + .send() + .await + .expect("revoke consumed offer"); + let (status, error_code, _) = error_parts(response).await; + assert_eq!(status, 409); + assert_eq!(error_code, "ENROLLMENT_OFFER_TERMINAL"); +} + +// Re-enrollment presupposes an existing authority to rotate. On a freshly +// onboarded (Unclaimed) server it is a 409 that names the right next step. +#[tokio::test] +async fn begin_reenrollment_on_unclaimed_server_is_409() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + + let response = client + .post(format!( + "{base_url}/api/servers/{server_id}/agent-authority/re-enrollment" + )) + .json(&json!({ "mode": "graceful" })) + .send() + .await + .expect("begin re-enrollment"); + let (status, code, details) = error_parts(response).await; + assert_eq!(status, 409); + assert_eq!(code, "AGENT_AUTHORITY_UNCLAIMED"); + assert!(details.is_null()); +} + +// Re-enrollment against a server id that does not exist → 404 +// (`ReenrollmentError::NotFound`). +#[tokio::test] +async fn begin_reenrollment_unknown_server_is_404() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let response = client + .post(format!( + "{base_url}/api/servers/no-such-server/agent-authority/re-enrollment" + )) + .json(&json!({ "mode": "emergency" })) + .send() + .await + .expect("begin re-enrollment"); + let (status, code, _) = error_parts(response).await; + assert_eq!(status, 404); + assert_eq!(code, "NOT_FOUND"); +} + +// =========================================================================== +// Adjacent create/read arms on the same router module. +// =========================================================================== + +// `get_gpu_records` had no coverage at all. A server that never reported a GPU +// answers 200 with an empty array rather than 404 — the dashboard renders an +// empty chart instead of an error state. +#[tokio::test] +async fn gpu_records_empty_for_new_server_is_200() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + + let response = client + .get(format!( + "{base_url}/api/servers/{server_id}/gpu-records?from=2026-01-01T00:00:00Z&to=2026-01-02T00:00:00Z" + )) + .send() + .await + .expect("gpu records"); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.expect("gpu records body"); + assert_eq!(body["data"].as_array().map(Vec::len), Some(0)); +} + +// `create_server` rejects a malformed `onboarding_request_id` (whitespace is +// disallowed) and an out-of-range offer TTL before touching the database. +#[tokio::test] +async fn create_server_invalid_onboarding_request_id_is_400() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let bad_request_id = client + .post(format!("{base_url}/api/servers")) + .json(&json!({ "onboarding_request_id": "has space", "name": "Server One" })) + .send() + .await + .expect("create with bad request id"); + let (status, code, _) = error_parts(bad_request_id).await; + assert_eq!(status, 400); + assert_eq!(code, "BAD_REQUEST"); + + let bad_ttl = client + .post(format!("{base_url}/api/servers")) + .json(&json!({ + "onboarding_request_id": "request-ttl", + "name": "Server One", + "ttl_secs": 0 + })) + .send() + .await + .expect("create with bad ttl"); + let (status, code, _) = error_parts(bad_ttl).await; + assert_eq!(status, 400, "offer ttl must be 1..=86400 seconds"); + assert_eq!(code, "BAD_REQUEST"); + + let listed: Value = client + .get(format!("{base_url}/api/servers")) + .send() + .await + .expect("list servers") + .json() + .await + .expect("list body"); + assert_eq!( + listed["data"].as_array().map(Vec::len), + Some(0), + "rejected onboarding requests must not create server rows" + ); +} + +// The Agent WS rejects a request carrying no credential at all, and one whose +// credential cannot even be parsed, before any store lookup happens. Both must +// answer 401 — a parse failure leaking as a 500 would tell a prober that the +// endpoint reached the authority store. +#[tokio::test] +async fn agent_ws_rejects_missing_and_malformed_tokens() { + let (base_url, _tmp) = start_test_server().await; + + assert_ws_handshake_unauthorized(format!( + "{}/api/agent/ws", + base_url.replace("http://", "ws://") + )) + .await; + // Shorter than the 8-character minimum, so `PresentedRunToken::parse` fails. + assert_ws_unauthorized(&base_url, "short").await; +} From cf4c2084aedbcd16d0337bbb3cf246fab3ce64fa Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:20 +0800 Subject: [PATCH 16/30] test(server): cover oauth callback nonce rejection --- crates/server/tests/router_misc_endpoints.rs | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/server/tests/router_misc_endpoints.rs b/crates/server/tests/router_misc_endpoints.rs index 1095edc3..090be5ef 100644 --- a/crates/server/tests/router_misc_endpoints.rs +++ b/crates/server/tests/router_misc_endpoints.rs @@ -107,6 +107,36 @@ async fn oauth_callback_unknown_state_is_400() { ); } +// Callback that DOES carry an `oauth_nonce` pre-auth cookie but still has no +// stored flow state → 400. This drives `extract_oauth_nonce` through its +// success path (header present, parsed, prefix matched) and proves the stored +// state lookup — not the cookie — is the first gate, so a forged nonce alone +// buys nothing and no token exchange is attempted. +#[tokio::test] +async fn oauth_callback_with_nonce_cookie_but_no_stored_state_is_400() { + let (base_url, _tmp) = start_test_server().await; + let anon = http_client(); + + let resp = anon + .get(format!( + "{}/api/auth/oauth/github/callback?code=abc&state=never-issued", + base_url + )) + .header("Cookie", "other=1; oauth_nonce=forged-nonce-value") + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 400, + "a nonce cookie without a matching stored state is still rejected" + ); + assert!( + resp.headers().get("set-cookie").is_none(), + "a rejected callback must not mint a session cookie" + ); +} + // Callback missing the required `state` query param → 400 (axum Query rejection). #[tokio::test] async fn oauth_callback_missing_state_param_is_400() { From a3d06c43560156a43f250dfa84650a6de2476127 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 19:29:20 +0800 Subject: [PATCH 17/30] test(server): cover cleanup cascade pruning --- crates/server/tests/router_server_extra2.rs | 146 ++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/server/tests/router_server_extra2.rs b/crates/server/tests/router_server_extra2.rs index 30a3bc67..37db22e1 100644 --- a/crates/server/tests/router_server_extra2.rs +++ b/crates/server/tests/router_server_extra2.rs @@ -368,6 +368,152 @@ async fn cleanup_no_orphans_returns_zero() { ); } +// `cleanup_json_array_tables` prunes the deleted orphan's id out of every table +// that stores a `server_ids_json` array, and each table has its OWN rule for the +// now-empty case. This drives the three distinct outcomes in one pass: +// - ping_tasks: referenced ONLY the orphan -> row deleted. +// - alert_rules: referenced both -> row survives with just the kept id. +// - service_monitors: referenced ONLY the orphan -> row survives with +// server_ids_json set to NULL (monitor + its history are preserved). +#[tokio::test] +async fn cleanup_orphan_prunes_server_ids_from_related_tables() { + let (base_url, _tmp) = start_test_server().await; + let admin = http_client(); + login_admin(&admin, &base_url).await; + + let orphan = create_server(&admin, &base_url, "New Server").await; + let keep = create_server(&admin, &base_url, "kept-named-host").await; + + let ping_task: Value = admin + .post(format!("{}/api/ping-tasks", base_url)) + .json(&json!({ + "name": "orphan-only-probe", + "probe_type": "icmp", + "target": "8.8.8.8", + "interval": 60, + "server_ids": [orphan] + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let ping_task_id = ping_task["data"]["id"].as_str().expect("ping task id"); + + let alert_rule: Value = admin + .post(format!("{}/api/alert-rules", base_url)) + .json(&json!({ + "name": "cpu-on-both", + "rules": [{ "rule_type": "cpu", "min": 90.0 }], + "trigger_mode": "once", + "cover_type": "include", + "server_ids": [orphan, keep], + "enabled": true + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let alert_rule_id = alert_rule["data"]["id"].as_str().expect("alert rule id"); + + let monitor: Value = admin + .post(format!("{}/api/service-monitors", base_url)) + .json(&json!({ + "name": "orphan-only-monitor", + "monitor_type": "tcp", + // TEST-NET-1 (RFC 5737): passes the SSRF guard, never dialed here. + "target": "192.0.2.1:9", + "interval": 300, + "config_json": {}, + "server_ids_json": [orphan], + "enabled": true + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let monitor_id = monitor["data"]["id"].as_str().expect("monitor id"); + + let resp = admin + .delete(format!("{}/api/servers/cleanup", base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["data"]["deleted_count"].as_u64(), Some(1)); + + // ping_tasks: the array emptied out, so the task itself is gone. + let tasks: Value = admin + .get(format!("{}/api/ping-tasks", base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + !tasks["data"] + .as_array() + .expect("ping task list") + .iter() + .any(|t| t["id"] == ping_task_id), + "a ping task left with no servers is deleted" + ); + + // alert_rules: still has the kept server, so the rule survives pruned. + let rules: Value = admin + .get(format!("{}/api/alert-rules", base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let rule = rules["data"] + .as_array() + .expect("alert rule list") + .iter() + .find(|r| r["id"] == alert_rule_id) + .expect("alert rule with a remaining server survives"); + let rule_ids: Vec = serde_json::from_str( + rule["server_ids_json"] + .as_str() + .expect("server_ids_json stays a JSON array string"), + ) + .expect("parse pruned server ids"); + assert_eq!( + rule_ids, + vec![keep.clone()], + "only the orphan id is removed from the alert rule" + ); + + // service_monitors: emptied out, but the row is kept with a NULL array. + let monitors: Value = admin + .get(format!("{}/api/service-monitors", base_url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let monitor = monitors["data"] + .as_array() + .expect("monitor list") + .iter() + .find(|m| m["id"] == monitor_id) + .expect("service monitors survive orphan cleanup"); + assert!( + monitor["server_ids_json"].is_null(), + "an emptied monitor is nulled, not deleted, so its history survives" + ); +} + // DELETE /api/servers/cleanup is admin-only: a member gets 403. #[tokio::test] async fn cleanup_member_forbidden() { From 3653c68af0014ba91c7972ae02d46d4e697a317d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:27:22 +0800 Subject: [PATCH 18/30] refactor(server): extract http keyword parse and verdict logic --- .../src/service/checker/http_keyword.rs | 685 ++++++++---------- 1 file changed, 294 insertions(+), 391 deletions(-) diff --git a/crates/server/src/service/checker/http_keyword.rs b/crates/server/src/service/checker/http_keyword.rs index 3cec3101..e9a0e11f 100644 --- a/crates/server/src/service/checker/http_keyword.rs +++ b/crates/server/src/service/checker/http_keyword.rs @@ -10,19 +10,18 @@ fn keyword_label(keyword: Option<&str>) -> &str { keyword.unwrap_or("") } -/// Check an HTTP endpoint for status code and keyword presence. -/// -/// Config options: -/// - `method`: "GET" or "POST" (default "GET") -/// - `keyword`: string to search for in the response body -/// - `keyword_exists`: whether the keyword should exist (default true) -/// - `expected_status`: array of acceptable status codes (default [200]) -/// - `headers`: object of custom request headers -/// - `body`: optional request body string (for POST) -/// - `timeout`: request timeout in seconds (default 10) -pub async fn check(target: &str, config: &Value) -> CheckResult { - let start = Instant::now(); +/// Request/verdict options parsed out of a monitor's JSON config. +struct CheckConfig<'a> { + method: String, + keyword: Option<&'a str>, + keyword_exists: bool, + expected_status: Vec, + timeout_secs: u64, + body: Option<&'a str>, +} +/// Parse the monitor config, applying the documented defaults. +fn parse_config(config: &Value) -> CheckConfig<'_> { let method = config .get("method") .and_then(|v| v.as_str()) @@ -47,7 +46,96 @@ pub async fn check(target: &str, config: &Value) -> CheckResult { let timeout_secs = config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(10); - let body_str = config.get("body").and_then(|v| v.as_str()); + let body = config.get("body").and_then(|v| v.as_str()); + + CheckConfig { + method, + keyword, + keyword_exists, + expected_status, + timeout_secs, + body, + } +} + +/// Turn a completed response into a verdict: status code membership plus +/// keyword presence/absence, with a combined error message on failure. +fn evaluate( + status_code: u16, + response_body: &str, + config: &CheckConfig<'_>, + latency: f64, +) -> CheckResult { + let expected_status = &config.expected_status; + let keyword = config.keyword; + let keyword_exists = config.keyword_exists; + + // Check status code + let status_ok = expected_status.contains(&status_code); + + // Check keyword + let keyword_found = keyword.map(|kw| response_body.contains(kw)); + let keyword_ok = match (keyword, keyword_found) { + (Some(_), Some(found)) => found == keyword_exists, + (None, _) => true, // No keyword check configured + _ => true, + }; + + let success = status_ok && keyword_ok; + + let detail = json!({ + "status_code": status_code, + "keyword_found": keyword_found, + "response_time_ms": latency, + }); + + let error = if success { + None + } else { + let mut reasons = Vec::new(); + if !status_ok { + reasons.push(format!( + "Status code {status_code} not in expected {expected_status:?}" + )); + } + if !keyword_ok { + if keyword_exists { + reasons.push(format!( + "Keyword '{}' not found in response", + keyword_label(keyword) + )); + } else { + reasons.push(format!( + "Keyword '{}' found in response but should be absent", + keyword_label(keyword) + )); + } + } + Some(reasons.join("; ")) + }; + + CheckResult { + success, + latency: Some(latency), + detail, + error, + } +} + +/// Check an HTTP endpoint for status code and keyword presence. +/// +/// Config options: +/// - `method`: "GET" or "POST" (default "GET") +/// - `keyword`: string to search for in the response body +/// - `keyword_exists`: whether the keyword should exist (default true) +/// - `expected_status`: array of acceptable status codes (default [200]) +/// - `headers`: object of custom request headers +/// - `body`: optional request body string (for POST) +/// - `timeout`: request timeout in seconds (default 10) +pub async fn check(target: &str, config: &Value) -> CheckResult { + let start = Instant::now(); + + let parsed = parse_config(config); // Build custom headers let custom_headers = match build_headers(config) { @@ -100,7 +188,7 @@ pub async fn check(target: &str, config: &Value) -> CheckResult { }; let client = match reqwest::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) + .timeout(Duration::from_secs(parsed.timeout_secs)) .danger_accept_invalid_certs(false) .redirect(reqwest::redirect::Policy::none()) .resolve_to_addrs(&host, &validated_addrs) @@ -115,11 +203,11 @@ pub async fn check(target: &str, config: &Value) -> CheckResult { // headers, so a POST body and any secrets in those headers are never // replayed to a redirected (possibly attacker-controlled) host. let request = if hop == 0 { - let base = match method.as_str() { + let base = match parsed.method.as_str() { "GET" => client.get(url.clone()), "POST" => { let mut req = client.post(url.clone()); - if let Some(body) = body_str { + if let Some(body) = parsed.body { req = req.body(body.to_string()); } req @@ -210,56 +298,7 @@ pub async fn check(target: &str, config: &Value) -> CheckResult { let latency = start.elapsed().as_secs_f64() * 1000.0; - // Check status code - let status_ok = expected_status.contains(&status_code); - - // Check keyword - let keyword_found = keyword.map(|kw| response_body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, // No keyword check configured - _ => true, - }; - - let success = status_ok && keyword_ok; - - let detail = json!({ - "status_code": status_code, - "keyword_found": keyword_found, - "response_time_ms": latency, - }); - - let error = if !success { - let mut reasons = Vec::new(); - if !status_ok { - reasons.push(format!( - "Status code {status_code} not in expected {expected_status:?}" - )); - } - if !keyword_ok { - if keyword_exists { - reasons.push(format!( - "Keyword '{}' not found in response", - keyword_label(keyword) - )); - } else { - reasons.push(format!( - "Keyword '{}' found in response but should be absent", - keyword_label(keyword) - )); - } - } - Some(reasons.join("; ")) - } else { - None - }; - - CheckResult { - success, - latency: Some(latency), - detail, - error, - } + evaluate(status_code, &response_body, &parsed, latency) } /// Build a `HeaderMap` from the config's `headers` object. @@ -286,6 +325,21 @@ fn build_headers(config: &Value) -> Result { mod tests { use super::*; + /// Fixed latency used by the `evaluate` tests so the reported value is + /// deterministic. + const TEST_LATENCY: f64 = 12.5; + + /// Run the real config parser and verdict logic over a config/response pair. + fn eval(config: Value, status_code: u16, response_body: &str) -> CheckResult { + let parsed = parse_config(&config); + evaluate(status_code, response_body, &parsed, TEST_LATENCY) + } + + /// Read the `keyword_found` field out of an `evaluate` detail payload. + fn keyword_found(result: &CheckResult) -> Option { + result.detail.get("keyword_found").and_then(|v| v.as_bool()) + } + #[test] fn test_build_headers_empty() { let config = json!({}); @@ -456,119 +510,68 @@ mod tests { #[test] fn test_expected_status_parsing_default() { - // When `expected_status` is absent, the default of [200] applies. This - // mirrors the parsing branch in `check`. - let config = json!({}); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - assert_eq!(expected_status, vec![200]); + // When `expected_status` is absent, the default of [200] applies. + assert_eq!(parse_config(&json!({})).expected_status, vec![200]); } #[test] fn test_expected_status_parsing_custom() { // Explicit codes are parsed; non-numeric array entries are skipped. let config = json!({ "expected_status": [200, 204, "skip-me", 301] }); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - assert_eq!(expected_status, vec![200, 204, 301]); + assert_eq!(parse_config(&config).expected_status, vec![200, 204, 301]); } #[test] fn test_keyword_classification_present_expected() { - // Replicates the keyword classification logic in `check`: keyword found - // and expected to exist => ok. - let body = "all systems operational"; - let keyword = Some("operational"); - let keyword_exists = true; - let keyword_found = keyword.map(|kw| body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert_eq!(keyword_found, Some(true)); - assert!(keyword_ok); + // Keyword found and expected to exist => ok. + let result = eval( + json!({ "keyword": "operational" }), + 200, + "all systems operational", + ); + assert_eq!(keyword_found(&result), Some(true)); + assert!(result.success); + assert!(result.error.is_none()); } #[test] fn test_keyword_classification_absent_expected_present() { // Keyword expected but not found => not ok. - let body = "page not found"; - let keyword = Some("operational"); - let keyword_exists = true; - let keyword_found = keyword.map(|kw| body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert_eq!(keyword_found, Some(false)); - assert!(!keyword_ok); + let result = eval(json!({ "keyword": "operational" }), 200, "page not found"); + assert_eq!(keyword_found(&result), Some(false)); + assert!(!result.success); } #[test] fn test_keyword_classification_present_but_should_be_absent() { // keyword_exists=false: presence of the keyword fails the check. - let body = "ERROR: database down"; - let keyword = Some("ERROR"); - let keyword_exists = false; - let keyword_found = keyword.map(|kw| body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert_eq!(keyword_found, Some(true)); - assert!(!keyword_ok, "keyword present while expected absent must fail"); + let result = eval( + json!({ "keyword": "ERROR", "keyword_exists": false }), + 200, + "ERROR: database down", + ); + assert_eq!(keyword_found(&result), Some(true)); + assert!( + !result.success, + "keyword present while expected absent must fail" + ); } #[test] fn test_keyword_classification_no_keyword() { - // No keyword configured => keyword check always passes. - let body = "anything"; - let keyword: Option<&str> = None; - let keyword_exists = true; - let keyword_found = keyword.map(|kw| body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert_eq!(keyword_found, None); - assert!(keyword_ok); + // No keyword configured => keyword check always passes and the detail + // reports a null `keyword_found`. + let result = eval(json!({}), 200, "anything"); + assert_eq!(result.detail.get("keyword_found"), Some(&Value::Null)); + assert!(result.success); } #[test] fn test_error_message_status_only_failure() { - // Replicates the error-message assembly: status mismatch only. - let status_code: u16 = 500; - let expected_status: Vec = vec![200]; - let status_ok = expected_status.contains(&status_code); - let keyword_ok = true; - let success = status_ok && keyword_ok; - assert!(!success); - - let mut reasons = Vec::new(); - if !status_ok { - reasons.push(format!( - "Status code {status_code} not in expected {expected_status:?}" - )); - } - let msg = reasons.join("; "); + // Status mismatch only: the message names the code and the expected list. + let result = eval(json!({}), 500, "body"); + assert!(!result.success); + let msg = result.error.unwrap_or_default(); assert!(msg.contains("Status code 500")); assert!(msg.contains("expected [200]")); } @@ -576,87 +579,47 @@ mod tests { #[test] fn test_error_message_keyword_missing_failure() { // keyword_exists=true but keyword not found => "not found" message. - let keyword = Some("healthy"); - let keyword_exists = true; - let keyword_ok = false; - let mut reasons = Vec::new(); - if !keyword_ok { - if keyword_exists { - reasons.push(format!( - "Keyword '{}' not found in response", - keyword_label(keyword) - )); - } else { - reasons.push(format!( - "Keyword '{}' found in response but should be absent", - keyword_label(keyword) - )); - } - } - let msg = reasons.join("; "); - assert!(msg.contains("'healthy' not found")); + let result = eval(json!({ "keyword": "healthy" }), 200, "degraded"); + assert!(!result.success); + assert!( + result + .error + .unwrap_or_default() + .contains("'healthy' not found") + ); } #[test] fn test_error_message_keyword_should_be_absent_failure() { // keyword_exists=false and keyword present => "should be absent" message. - let keyword = Some("maintenance"); - let keyword_exists = false; - let keyword_ok = false; - let mut reasons = Vec::new(); - if !keyword_ok { - if keyword_exists { - reasons.push(format!( - "Keyword '{}' not found in response", - keyword_label(keyword) - )); - } else { - reasons.push(format!( - "Keyword '{}' found in response but should be absent", - keyword_label(keyword) - )); - } - } - let msg = reasons.join("; "); - assert!(msg.contains("'maintenance' found in response but should be absent")); + let result = eval( + json!({ "keyword": "maintenance", "keyword_exists": false }), + 200, + "scheduled maintenance in progress", + ); + assert!(!result.success); + assert!( + result + .error + .unwrap_or_default() + .contains("'maintenance' found in response but should be absent") + ); } #[test] fn test_method_default_and_normalization() { // Method parsing: absent => "GET"; provided lowercase => uppercased. - let cfg_default = json!({}); - let method = cfg_default - .get("method") - .and_then(|v| v.as_str()) - .unwrap_or("GET") - .to_uppercase(); - assert_eq!(method, "GET"); - - let cfg_post = json!({ "method": "post" }); - let method = cfg_post - .get("method") - .and_then(|v| v.as_str()) - .unwrap_or("GET") - .to_uppercase(); - assert_eq!(method, "POST"); + assert_eq!(parse_config(&json!({})).method, "GET"); + assert_eq!(parse_config(&json!({ "method": "post" })).method, "POST"); } #[test] fn test_keyword_exists_default_true() { - // `keyword_exists` defaults to true when absent or non-bool. - let cfg = json!({ "keyword": "ok" }); - let keyword_exists = cfg - .get("keyword_exists") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - assert!(keyword_exists); - - let cfg_explicit = json!({ "keyword": "ok", "keyword_exists": false }); - let keyword_exists = cfg_explicit - .get("keyword_exists") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - assert!(!keyword_exists); + // `keyword_exists` defaults to true when absent. + assert!(parse_config(&json!({ "keyword": "ok" })).keyword_exists); + assert!(!parse_config(&json!({ "keyword": "ok", "keyword_exists": false })).keyword_exists); + // A non-bool value also falls back to the default. + assert!(parse_config(&json!({ "keyword_exists": "no" })).keyword_exists); } // ── check(): additional deterministic (offline) error paths ─────────────── @@ -786,119 +749,105 @@ mod tests { #[test] fn test_keyword_match_is_case_sensitive() { // `String::contains` is case-sensitive: a case mismatch is not a match. - let body = "Status: OPERATIONAL"; - assert!(!body.contains("operational")); - assert!(body.contains("OPERATIONAL")); + let lower = eval( + json!({ "keyword": "operational" }), + 200, + "Status: OPERATIONAL", + ); + assert_eq!(keyword_found(&lower), Some(false)); + assert!(!lower.success); + + let exact = eval( + json!({ "keyword": "OPERATIONAL" }), + 200, + "Status: OPERATIONAL", + ); + assert_eq!(keyword_found(&exact), Some(true)); + assert!(exact.success); } #[test] fn test_keyword_match_substring_within_word() { // Keyword matching is plain substring (no word-boundary requirement). - let body = "maintenancewindow active"; - let keyword = "maintenance"; - let keyword_found = Some(body.contains(keyword)); - assert_eq!(keyword_found, Some(true)); + let result = eval( + json!({ "keyword": "maintenance" }), + 200, + "maintenancewindow active", + ); + assert_eq!(keyword_found(&result), Some(true)); } #[test] fn test_keyword_match_empty_keyword_always_found() { // An empty keyword is a substring of every string, so it always matches. - let body = ""; - let keyword = ""; - assert!(body.contains(keyword)); + let result = eval(json!({ "keyword": "" }), 200, ""); + assert_eq!(keyword_found(&result), Some(true)); + assert!(result.success); } #[test] fn test_keyword_classification_absent_expected_absent_ok() { // keyword_exists=false and the keyword is genuinely absent => ok. This // covers the (false found == false expected) success case omitted above. - let body = "all good"; - let keyword = Some("ERROR"); - let keyword_exists = false; - let keyword_found = keyword.map(|kw| body.contains(kw)); - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert_eq!(keyword_found, Some(false)); - assert!(keyword_ok, "absent keyword expected absent must pass"); + let result = eval( + json!({ "keyword": "ERROR", "keyword_exists": false }), + 200, + "all good", + ); + assert_eq!(keyword_found(&result), Some(false)); + assert!(result.success, "absent keyword expected absent must pass"); } // ── success composition: status + keyword combined ──────────────────────── #[test] fn test_success_requires_both_status_and_keyword_ok() { - // `success = status_ok && keyword_ok`: both true => success; either false - // => failure. Mirrors the final composition in `check`. - for (status_ok, keyword_ok, expected) in [ - (true, true, true), - (true, false, false), - (false, true, false), - (false, false, false), + // `success = status_ok && keyword_ok`: both true => success; either one + // failing => failure. + let config = json!({ "keyword": "ok" }); + for (status_code, body, expected) in [ + (200, "ok", true), + (200, "nope", false), + (500, "ok", false), + (500, "nope", false), ] { - assert_eq!(status_ok && keyword_ok, expected); + let result = eval(config.clone(), status_code, body); + assert_eq!( + result.success, expected, + "status {status_code} with body {body:?} should yield success={expected}" + ); + assert_eq!(result.error.is_none(), expected); } } #[test] fn test_error_message_combines_status_and_keyword_failures() { // When both status and keyword fail, the reasons are joined with "; ". - let status_code: u16 = 503; - let expected_status: Vec = vec![200]; - let keyword = Some("healthy"); - let keyword_exists = true; - let status_ok = expected_status.contains(&status_code); - let keyword_ok = false; - - let mut reasons = Vec::new(); - if !status_ok { - reasons.push(format!( - "Status code {status_code} not in expected {expected_status:?}" - )); - } - if !keyword_ok { - if keyword_exists { - reasons.push(format!( - "Keyword '{}' not found in response", - keyword_label(keyword) - )); - } else { - reasons.push(format!( - "Keyword '{}' found in response but should be absent", - keyword_label(keyword) - )); - } - } - let msg = reasons.join("; "); + let result = eval(json!({ "keyword": "healthy" }), 503, "service unavailable"); + assert!(!result.success); + let msg = result.error.unwrap_or_default(); assert!(msg.contains("Status code 503")); assert!(msg.contains("'healthy' not found")); - assert!(msg.contains("; "), "two reasons must be joined with a separator"); + assert!( + msg.contains("; "), + "two reasons must be joined with a separator" + ); } #[test] fn test_status_classification_multiple_expected_codes() { // A status present in a multi-entry expected list classifies as ok. - let expected_status: Vec = vec![200, 204, 301]; - assert!(expected_status.contains(&301)); - assert!(!expected_status.contains(&500)); + let config = json!({ "expected_status": [200, 204, 301] }); + assert!(eval(config.clone(), 301, "").success); + assert!(!eval(config, 500, "").success); } #[test] fn test_timeout_default_and_custom_parsing() { // `timeout` defaults to 10s when absent and uses the configured value - // otherwise (the line-44 parsing branch in `check`). - let default = json!({}) - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(10); - assert_eq!(default, 10); - - let custom = json!({ "timeout": 25 }) - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(10); - assert_eq!(custom, 25); + // otherwise. + assert_eq!(parse_config(&json!({})).timeout_secs, 10); + assert_eq!(parse_config(&json!({ "timeout": 25 })).timeout_secs, 25); } // ── expected_status parsing: boundary array shapes ──────────────────────── @@ -906,19 +855,16 @@ mod tests { #[test] fn test_expected_status_parsing_empty_array() { // An explicit empty `expected_status` array parses to an empty Vec (NOT - // the default [200]), so `.contains()` later rejects every status code. + // the default [200]), so the verdict rejects every status code. let config = json!({ "expected_status": [] }); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - assert!(expected_status.is_empty(), "empty array stays empty, not defaulted"); - assert!(!expected_status.contains(&200), "no status is acceptable with an empty list"); + assert!( + parse_config(&config).expected_status.is_empty(), + "empty array stays empty, not defaulted" + ); + assert!( + !eval(config, 200, "").success, + "no status is acceptable with an empty list" + ); } #[test] @@ -926,34 +872,15 @@ mod tests { // Negative JSON numbers fail `as_u64()` and are filtered out, leaving // only the valid unsigned codes. let config = json!({ "expected_status": [-1, 200, -404, 302] }); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - assert_eq!(expected_status, vec![200, 302]); + assert_eq!(parse_config(&config).expected_status, vec![200, 302]); } #[test] fn test_expected_status_parsing_truncates_out_of_range() { // A value above u16::MAX passes `as_u64()` but `as u16` truncates it - // (70000 & 0xFFFF == 4464); this pins the lossy-cast contract. + // (70000 - 65536 == 4464); this pins the lossy-cast contract. let config = json!({ "expected_status": [70000] }); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - // 70000 wraps to 70000 - 65536 = 4464 under the production `as u16` cast. - assert_eq!(expected_status, vec![4464]); + assert_eq!(parse_config(&config).expected_status, vec![4464]); } #[test] @@ -961,16 +888,7 @@ mod tests { // `expected_status` present but not an array: `.as_array()` is None so // the whole `.map` is skipped and the [200] default applies. let config = json!({ "expected_status": 200 }); - let expected_status: Vec = config - .get("expected_status") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_u64().map(|n| n as u16)) - .collect() - }) - .unwrap_or_else(|| vec![200]); - assert_eq!(expected_status, vec![200]); + assert_eq!(parse_config(&config).expected_status, vec![200]); } // ── build_headers: additional value-type branches ───────────────────────── @@ -1016,11 +934,11 @@ mod tests { #[test] fn test_body_str_present_and_absent() { // `body` is read as an optional &str: present => Some, absent => None. - let with_body = json!({ "body": "payload" }); - assert_eq!(with_body.get("body").and_then(|v| v.as_str()), Some("payload")); - - let without_body = json!({ "method": "POST" }); - assert_eq!(without_body.get("body").and_then(|v| v.as_str()), None); + assert_eq!( + parse_config(&json!({ "body": "payload" })).body, + Some("payload") + ); + assert_eq!(parse_config(&json!({ "method": "POST" })).body, None); } #[test] @@ -1028,76 +946,61 @@ mod tests { // An arbitrary method string is uppercased verbatim; the `other =>` // rejection arm in `check` would only be reached after the SSRF guard, so // here we just pin the normalization step that feeds it. - let cfg = json!({ "method": "patch" }); - let method = cfg - .get("method") - .and_then(|v| v.as_str()) - .unwrap_or("GET") - .to_uppercase(); - assert_eq!(method, "PATCH"); + assert_eq!(parse_config(&json!({ "method": "patch" })).method, "PATCH"); } #[test] fn test_method_non_string_falls_back_to_get() { // A non-string `method` value fails `.as_str()` and defaults to GET. - let cfg = json!({ "method": 123 }); - let method = cfg - .get("method") - .and_then(|v| v.as_str()) - .unwrap_or("GET") - .to_uppercase(); - assert_eq!(method, "GET"); + assert_eq!(parse_config(&json!({ "method": 123 })).method, "GET"); } - // ── keyword classification: fall-through arm and keyword=None error label ── + // ── keyword=None never produces a keyword failure ───────────────────────── #[test] - fn test_keyword_classification_fallthrough_arm_is_ok() { - // The `_ => true` arm covers the logically-impossible (Some keyword, None - // found) shape; it must classify as ok so an inconsistent state never - // fails the check spuriously. - let keyword: Option<&str> = Some("x"); - let keyword_found: Option = None; - let keyword_exists = true; - let keyword_ok = match (keyword, keyword_found) { - (Some(_), Some(found)) => found == keyword_exists, - (None, _) => true, - _ => true, - }; - assert!(keyword_ok, "the catch-all arm defaults to ok"); + fn test_keyword_label_falls_back_to_empty_string() { + // `keyword_label` is the error-message fallback for a missing keyword. + assert_eq!(keyword_label(None), ""); + assert_eq!(keyword_label(Some("healthy")), "healthy"); } #[test] - fn test_error_message_keyword_none_uses_empty_label() { - // When keyword is None but the keyword branch is somehow hit, the message - // falls back to an empty-quoted label via `keyword_label`. - let keyword: Option<&str> = None; - let keyword_exists = true; - let msg = if keyword_exists { - format!("Keyword '{}' not found in response", keyword_label(keyword)) - } else { - format!( - "Keyword '{}' found in response but should be absent", - keyword_label(keyword) - ) - }; - assert_eq!(msg, "Keyword '' not found in response"); + fn test_no_keyword_configured_never_reports_a_keyword_failure() { + // With no keyword the verdict depends on the status code alone, and the + // error message never mentions a keyword. + let result = eval(json!({ "keyword_exists": false }), 500, "anything"); + assert!(!result.success); + let msg = result.error.unwrap_or_default(); + assert!(msg.contains("Status code 500")); + assert!( + !msg.contains("Keyword"), + "an unconfigured keyword must not appear in the error, got: {msg}" + ); } // ── success path: no error message when both checks pass ────────────────── #[test] fn test_no_error_when_success() { - // On success the `error` field is None (the else branch of the assembly). - let status_ok = true; - let keyword_ok = true; - let success = status_ok && keyword_ok; - let error: Option = if success { - None - } else { - Some("unused".to_string()) - }; - assert!(error.is_none(), "a passing check carries no error message"); + // On success the `error` field is None and the latency is echoed back. + let result = eval(json!({ "keyword": "ok" }), 200, "status: ok"); + assert!(result.success); + assert!( + result.error.is_none(), + "a passing check carries no error message" + ); + assert_eq!(result.latency, Some(TEST_LATENCY)); + assert_eq!( + result + .detail + .get("response_time_ms") + .and_then(|v| v.as_f64()), + Some(TEST_LATENCY) + ); + assert_eq!( + result.detail.get("status_code").and_then(|v| v.as_u64()), + Some(200) + ); } // ── port defaulting reasoning for the SSRF pin ──────────────────────────── From 3ecf3a563d9a586f30d9a10c2b69303272ede689 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:27:22 +0800 Subject: [PATCH 19/30] test(server): cover upload failure paths and size limit --- crates/server/tests/router_file_extra.rs | 372 ++++++++++++++++++++++- 1 file changed, 371 insertions(+), 1 deletion(-) diff --git a/crates/server/tests/router_file_extra.rs b/crates/server/tests/router_file_extra.rs index 88c3f30d..8db3bb48 100644 --- a/crates/server/tests/router_file_extra.rs +++ b/crates/server/tests/router_file_extra.rs @@ -13,7 +13,10 @@ //! * `cancel_transfer` success (owned, in-progress download → agent cancel), //! * `list_transfers` returning an active transfer, //! * upload validation arms: missing `path` field, empty `file` field, and -//! the agent rejecting the upload start with FileUploadError. +//! the agent rejecting the upload start with FileUploadError, +//! * upload chunk-phase failures (bad ack offset, agent error) and an error +//! reported at FileUploadEnd, each asserted through `mark_failed`, +//! * the streaming max-upload-size guard (needs a smaller configured limit). //! //! Tests whose HTTP handler blocks on an agent reply while a spawned responder //! must make progress use the multi-thread runtime so the responder is never @@ -47,6 +50,144 @@ async fn connect_agent_with_caps( (sink, reader) } +/// Look up the status of the transfer whose remote path is `path` in the +/// caller's transfer list. Upload failures keep the transfer around with +/// `status: "failed"`, so this is how a test proves `mark_failed` actually ran +/// instead of merely asserting the HTTP status the handler returned. +async fn transfer_status_for_path(client: &reqwest::Client, base_url: &str, path: &str) -> String { + let resp = client + .get(format!("{base_url}/api/files/transfers")) + .send() + .await + .expect("list transfers failed"); + assert_eq!(resp.status(), 200, "transfer listing should succeed"); + let body: Value = resp.json().await.expect("parse transfers response"); + let transfers = body["data"]["transfers"] + .as_array() + .expect("transfers array") + .clone(); + let entry = transfers + .iter() + .find(|t| t["file_path"] == json!(path)) + .unwrap_or_else(|| panic!("no transfer recorded for {path}, got {transfers:?}")); + entry["status"] + .as_str() + .expect("transfer status") + .to_string() +} + +/// Send a `file_upload_ack` for `transfer_id` acknowledging `offset` bytes. +async fn send_upload_ack(sink: &mut AgentSink, transfer_id: &str, offset: u64) { + sink.send(tungstenite::Message::Text( + json!({ + "type": "file_upload_ack", + "transfer_id": transfer_id, + "offset": offset + }) + .to_string() + .into(), + )) + .await + .expect("send file_upload_ack"); +} + +/// Send a `file_upload_error` for `transfer_id`. +async fn send_upload_error(sink: &mut AgentSink, transfer_id: &str, error: &str) { + sink.send(tungstenite::Message::Text( + json!({ + "type": "file_upload_error", + "transfer_id": transfer_id, + "error": error + }) + .to_string() + .into(), + )) + .await + .expect("send file_upload_error"); +} + +/// Number of raw bytes carried by a `file_upload_chunk` frame, so a responder +/// can ack the exact offset the server expects (`offset + len`). +fn decoded_chunk_len(msg: &Value) -> u64 { + use base64::Engine; + let data = msg["data"].as_str().expect("chunk data"); + base64::engine::general_purpose::STANDARD + .decode(data) + .expect("decode chunk data") + .len() as u64 +} + +/// Like `common::start_test_server`, but with a caller-chosen +/// `file.max_upload_size` so the streaming size guard can be reached without +/// pushing 100 MB through the socket. +async fn start_server_with_upload_limit(max_upload_size: u64) -> (String, tempfile::TempDir) { + use sea_orm::{ConnectOptions, ConnectionTrait, Database}; + use sea_orm_migration::MigratorTrait; + use serverbee_server::config::{ + AppConfig, AuthConfig, DatabaseConfig, FileConfig, ServerConfig, + }; + use serverbee_server::migration::Migrator; + use serverbee_server::router::create_router; + use serverbee_server::service::auth::AuthService; + use serverbee_server::state::AppState; + + let tmp = tempfile::tempdir().expect("create temp dir"); + let data_dir = tmp.path().to_str().expect("temp dir path").to_string(); + + let config = AppConfig { + server: ServerConfig { + listen: "127.0.0.1:0".to_string(), + data_dir: data_dir.clone(), + trusted_proxies: Vec::new(), + }, + database: DatabaseConfig { + path: "test.db".to_string(), + max_connections: 5, + }, + auth: AuthConfig { + session_ttl: 86400, + secure_cookie: false, + max_servers: 0, + }, + file: FileConfig { max_upload_size }, + ..AppConfig::default() + }; + + let db_url = format!("sqlite://{data_dir}/test.db?mode=rwc"); + let mut opt = ConnectOptions::new(&db_url); + opt.max_connections(5); + opt.sqlx_logging(false); + let db = Database::connect(opt).await.expect("connect test database"); + db.execute_unprepared("PRAGMA journal_mode=WAL") + .await + .unwrap(); + db.execute_unprepared("PRAGMA foreign_keys=ON") + .await + .unwrap(); + Migrator::up(&db, None).await.expect("run migrations"); + AuthService::create_user(&db, "admin", "testpass", "admin") + .await + .expect("seed admin"); + + let state = AppState::new(db, config).await.expect("create AppState"); + let app = create_router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + (base_url, tmp) +} + /// Spawn a responder that waits for one forwarded request whose `type` equals /// `request_type`, then sends `build_response(msg_id)` back to the server. fn spawn_single_response( @@ -953,3 +1094,232 @@ async fn test_upload_agent_rejects_start_is_400() { agent_task.await.expect("agent responder failed"); } + +// =========================================================================== +// upload chunk phase: the agent acks the start, then answers the first chunk +// with an ack for an offset the server never sent → 500 + transfer failed. +// =========================================================================== + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_upload_chunk_ack_offset_mismatch_is_500() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let (server_id, token) = register_agent(&client, &base_url).await; + let (mut sink, mut reader) = + connect_agent_with_caps(&base_url, &token, CAP_DEFAULT | CAP_FILE).await; + + let agent_task = tokio::spawn(async move { + loop { + let msg = recv_agent_text(&mut reader).await; + match msg["type"].as_str() { + Some("file_upload_start") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_ack(&mut sink, transfer_id, 0).await; + } + Some("file_upload_chunk") => { + // Ack an offset that does not match `offset + chunk len`, + // which the server treats as a desynced agent. + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_ack(&mut sink, transfer_id, 999_999).await; + return; + } + other if is_first_connect_noise(other) => {} + Some(other) => panic!("unexpected agent command: {other}"), + None => {} + } + } + }); + + let form = reqwest::multipart::Form::new() + .text("path", "/tmp/mismatch.txt") + .part( + "file", + reqwest::multipart::Part::bytes(b"chunk payload".to_vec()).file_name("mismatch.txt"), + ); + + let resp = client + .post(format!("{base_url}/api/files/{server_id}/upload")) + .multipart(form) + .send() + .await + .expect("upload request failed"); + assert_eq!(resp.status(), 500, "bad ack offset should be 500"); + + agent_task.await.expect("agent responder failed"); + assert_eq!( + transfer_status_for_path(&client, &base_url, "/tmp/mismatch.txt").await, + "failed", + "offset mismatch must mark the transfer failed" + ); +} + +// =========================================================================== +// upload chunk phase: the agent reports FileUploadError while chunks are still +// streaming. No upload-complete slot is registered yet, so the error resolves +// the chunk ack instead → 400 + transfer failed. +// =========================================================================== + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_upload_chunk_agent_error_is_400() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let (server_id, token) = register_agent(&client, &base_url).await; + let (mut sink, mut reader) = + connect_agent_with_caps(&base_url, &token, CAP_DEFAULT | CAP_FILE).await; + + let agent_task = tokio::spawn(async move { + loop { + let msg = recv_agent_text(&mut reader).await; + match msg["type"].as_str() { + Some("file_upload_start") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_ack(&mut sink, transfer_id, 0).await; + } + Some("file_upload_chunk") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_error(&mut sink, transfer_id, "Disk quota exceeded").await; + return; + } + other if is_first_connect_noise(other) => {} + Some(other) => panic!("unexpected agent command: {other}"), + None => {} + } + } + }); + + let form = reqwest::multipart::Form::new() + .text("path", "/tmp/quota.txt") + .part( + "file", + reqwest::multipart::Part::bytes(b"chunk payload".to_vec()).file_name("quota.txt"), + ); + + let resp = client + .post(format!("{base_url}/api/files/{server_id}/upload")) + .multipart(form) + .send() + .await + .expect("upload request failed"); + assert_eq!(resp.status(), 400, "chunk-phase agent error should be 400"); + + agent_task.await.expect("agent responder failed"); + assert_eq!( + transfer_status_for_path(&client, &base_url, "/tmp/quota.txt").await, + "failed", + "chunk-phase error must mark the transfer failed" + ); +} + +// =========================================================================== +// upload completion: every chunk is acked normally, but the agent fails while +// finalising and answers FileUploadEnd with FileUploadError → 400 + failed. +// =========================================================================== + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_upload_end_reports_error_is_400() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let (server_id, token) = register_agent(&client, &base_url).await; + let (mut sink, mut reader) = + connect_agent_with_caps(&base_url, &token, CAP_DEFAULT | CAP_FILE).await; + + let agent_task = tokio::spawn(async move { + loop { + let msg = recv_agent_text(&mut reader).await; + match msg["type"].as_str() { + Some("file_upload_start") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_ack(&mut sink, transfer_id, 0).await; + } + Some("file_upload_chunk") => { + // Ack exactly what the server expects so the transfer walks + // all the way to the completion handshake. + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + let offset = msg["offset"].as_u64().expect("chunk offset"); + let written = decoded_chunk_len(&msg); + send_upload_ack(&mut sink, transfer_id, offset + written).await; + } + Some("file_upload_end") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_error(&mut sink, transfer_id, "fsync failed").await; + return; + } + other if is_first_connect_noise(other) => {} + Some(other) => panic!("unexpected agent command: {other}"), + None => {} + } + } + }); + + let form = reqwest::multipart::Form::new() + .text("path", "/tmp/finalize.txt") + .part( + "file", + reqwest::multipart::Part::bytes(b"complete payload".to_vec()).file_name("finalize.txt"), + ); + + let resp = client + .post(format!("{base_url}/api/files/{server_id}/upload")) + .multipart(form) + .send() + .await + .expect("upload request failed"); + assert_eq!(resp.status(), 400, "error at upload end should be 400"); + + agent_task.await.expect("agent responder failed"); + assert_eq!( + transfer_status_for_path(&client, &base_url, "/tmp/finalize.txt").await, + "failed", + "completion error must mark the transfer failed" + ); +} + +// =========================================================================== +// upload: the streaming size guard. `write_router` sets the Axum body limit to +// `max_upload_size + 5 MB`, so a body just over a tiny configured limit still +// reaches the handler and is rejected by its own per-chunk size accounting. +// =========================================================================== + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_upload_exceeding_max_upload_size_is_400() { + const MAX_UPLOAD: u64 = 1024; + + let (base_url, _tmp) = start_server_with_upload_limit(MAX_UPLOAD).await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let (server_id, token) = register_agent(&client, &base_url).await; + let (mut sink, _reader) = + connect_agent_with_caps(&base_url, &token, CAP_DEFAULT | CAP_FILE).await; + + // Twice the limit: the handler aborts mid-stream, before any agent traffic. + let form = reqwest::multipart::Form::new() + .text("path", "/tmp/too-big.bin") + .part( + "file", + reqwest::multipart::Part::bytes(vec![b'x'; (MAX_UPLOAD * 2) as usize]) + .file_name("too-big.bin"), + ); + + let resp = client + .post(format!("{base_url}/api/files/{server_id}/upload")) + .multipart(form) + .send() + .await + .expect("upload request failed"); + assert_eq!(resp.status(), 400, "oversized upload should be 400"); + let body: Value = resp.json().await.expect("parse error response"); + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains(&format!("File size exceeds limit of {MAX_UPLOAD} bytes")), + "error should name the configured limit, got {body}" + ); + + let _ = sink.close().await; +} From ab36dec5942a9d8bd844b7fadc96465a193c59ed Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:27:22 +0800 Subject: [PATCH 20/30] test(server): cover geoip download guard and status --- crates/server/src/router/api/geoip.rs | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/server/src/router/api/geoip.rs b/crates/server/src/router/api/geoip.rs index 6a950237..075e9143 100644 --- a/crates/server/src/router/api/geoip.rs +++ b/crates/server/src/router/api/geoip.rs @@ -175,3 +175,51 @@ pub fn read_router() -> Router> { pub fn write_router() -> Router> { Router::new().route("/geoip/download", routing::post(geoip_download)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use crate::test_utils::setup_test_db; + + /// An `AppState` whose data dir points at a fresh tempdir, so no GeoIP + /// database is installed and nothing touches the real `./data` directory. + async fn test_state() -> (Arc, tempfile::TempDir) { + let (db, tmp) = setup_test_db().await; + let mut config = AppConfig::default(); + config.server.data_dir = tmp.path().to_string_lossy().to_string(); + let state = AppState::new(db, config).await.expect("build test state"); + (state, tmp) + } + + #[tokio::test] + async fn status_reports_not_installed_without_a_database() { + let (state, _tmp) = test_state().await; + + let response = geoip_status(State(state)) + .await + .expect("status should succeed"); + let status = &response.0.data; + assert!(!status.installed); + assert!(status.source.is_none()); + assert!(status.file_size.is_none()); + assert!(status.updated_at.is_none()); + } + + #[tokio::test] + async fn download_returns_already_in_progress_when_flag_set() { + let (state, _tmp) = test_state().await; + // Simulate a download already running: the guard must short-circuit + // before any network work instead of starting a second download. + state.geoip_downloading.store(true, Ordering::SeqCst); + + let response = geoip_download(State(Arc::clone(&state))) + .await + .expect("download should return a body, not an error"); + let body = &response.0.data; + assert!(!body.success); + assert_eq!(body.message, "Download already in progress"); + // The guard must leave the flag set for the in-flight download. + assert!(state.geoip_downloading.load(Ordering::SeqCst)); + } +} From b81d6731e43e68244ccb328482633cbdf54c9e76 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:27:22 +0800 Subject: [PATCH 21/30] test(server): cover scheduler retry and dispatch failures --- crates/server/src/service/task_scheduler.rs | 150 ++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/crates/server/src/service/task_scheduler.rs b/crates/server/src/service/task_scheduler.rs index afa82d37..64ad7be3 100644 --- a/crates/server/src/service/task_scheduler.rs +++ b/crates/server/src/service/task_scheduler.rs @@ -2108,4 +2108,154 @@ mod tests { "last_run_at should be stamped" ); } + + // ---- execute_scheduled_task: cron path honours retries ------------ + + /// Remote address for a fabricated agent connection. + fn test_addr() -> std::net::SocketAddr { + std::net::SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)), + 8080, + ) + } + + /// Fetch a task's result rows ordered by attempt. + async fn results_by_attempt(db: &DatabaseConnection, task_id: &str) -> Vec { + task_result::Entity::find() + .filter(task_result::Column::TaskId.eq(task_id)) + .order_by_asc(task_result::Column::Attempt) + .all(db) + .await + .expect("load task results") + } + + // The cron path (skip_retry == false) is the only caller that reads + // retry_count off the task model, so it is also the only one that runs the + // retry loop. An offline target is retried until the attempts are + // exhausted, and every attempt persists its own "Server offline" row. + #[tokio::test] + async fn test_execute_for_server_retries_offline_server_until_attempts_exhausted() { + let (state, _db, _dir) = build_test_state().await; + seed_server(&state.db, "srv-retry", (CAP_DEFAULT | CAP_EXEC) as i32).await; + seed_task(&state.db, "task-retry", &["srv-retry"]).await; + task::Entity::update_many() + .filter(task::Column::Id.eq("task-retry")) + .col_expr(task::Column::RetryCount, Expr::value(1)) + .col_expr(task::Column::RetryInterval, Expr::value(1)) + .exec(&state.db) + .await + .unwrap(); + + let started = execute_scheduled_task(&state, "task-retry", false, None) + .await + .unwrap(); + assert!(started); + + assert_eq!( + wait_for_results(&state.db, "task-retry", 2).await, + 2, + "retry_count = 1 must produce two attempts" + ); + let rows = results_by_attempt(&state.db, "task-retry").await; + assert_eq!( + rows.iter().map(|row| row.attempt).collect::>(), + vec![1, 2] + ); + for row in &rows { + assert_eq!(row.exit_code, -3); + assert_eq!(row.output, "Server offline"); + } + } + + // ---- execute_for_server: dispatch/response failure arms ----------- + + // A connection is registered but its receiver is already gone, so the + // manager resolves a sender and the send itself fails: the executor must + // record "Dispatch failed" (exit -3) rather than the offline text. + #[tokio::test] + async fn test_execute_for_server_writes_dispatch_failed() { + let (state, _db, _dir) = build_test_state().await; + let (tx, rx) = tokio::sync::mpsc::channel::(1); + drop(rx); + state.agent_manager.add_connection( + "srv-dispatch".to_string(), + "srv-dispatch".to_string(), + tx, + test_addr(), + ); + + execute_for_server( + &state, + "task-send", + "run-send", + "srv-dispatch", + "echo hi", + 1, + 0, + 1, + CancellationToken::new(), + ) + .await + .unwrap(); + + let rows = results_by_attempt(&state.db, "task-send").await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].exit_code, -3); + assert_eq!(rows[0].output, "Dispatch failed"); + assert_eq!(rows[0].attempt, 1); + } + + // An agent that accepts the Exec frame and then vanishes without replying + // drops the pending response slot, which resolves the request as + // Disconnected. That lands in the catch-all arm and is reported as + // "No response within Ns" (exit -4). + #[tokio::test] + async fn test_execute_for_server_writes_no_response_when_slot_is_dropped() { + let (state, _db, _dir) = build_test_state().await; + let (tx, mut rx) = tokio::sync::mpsc::channel::(1); + state.agent_manager.add_connection( + "srv-quiet".to_string(), + "srv-quiet".to_string(), + tx, + test_addr(), + ); + + let agent_state = state.clone(); + let fake_agent = tokio::spawn(async move { + let msg = rx.recv().await.expect("Exec frame should be dispatched"); + let ServerMessage::Exec { task_id, .. } = msg else { + panic!("expected Exec, got {msg:?}"); + }; + agent_state.agent_manager.cancel_pending_request(&task_id); + }); + + let started = std::time::Instant::now(); + execute_for_server( + &state, + "task-quiet", + "run-quiet", + "srv-quiet", + "echo hi", + 1, + 0, + 1, + CancellationToken::new(), + ) + .await + .unwrap(); + let elapsed = started.elapsed(); + fake_agent.await.unwrap(); + + // The dropped slot must resolve immediately; anything near the 11s + // request deadline would mean we exercised the timeout arm instead. + assert!( + elapsed < std::time::Duration::from_secs(5), + "expected an immediate Disconnected, took {elapsed:?}" + ); + let rows = results_by_attempt(&state.db, "task-quiet").await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].exit_code, -4); + assert_eq!(rows[0].output, "No response within 1s"); + assert_eq!(rows[0].attempt, 1); + } } From ec5598514065473d68fef6cf0011a4661a9a0b8e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:27:22 +0800 Subject: [PATCH 22/30] test(agent): cover external ip refresh reporting --- crates/agent/src/reporter/mod.rs | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index eaf8142c..83655eb5 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -925,6 +925,88 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_spawn_external_ip_refresh_emits_ip_changed_on_delta() { + // External discovery fails fast (closed local port), so the merged + // result comes purely from the interface scan. It differs from the + // stale baseline, so an IpChanged must reach cmd_result_tx carrying + // the derived primaries plus the untouched interface list. + let interfaces = vec![ + NetworkInterface { + name: "docker0".to_string(), + ipv4: vec!["172.17.0.1".to_string()], + ipv6: vec![], + }, + NetworkInterface { + name: "eth0".to_string(), + ipv4: vec!["203.0.113.42".to_string()], + ipv6: vec!["2001:db8::1".to_string()], + }, + ]; + let (tx, mut rx) = mpsc::channel(4); + let firewall_manager = Arc::new(FirewallManager::new(Arc::new(CliNftExecutor))); + + spawn_external_ip_refresh( + vec!["http://127.0.0.1:1/".to_string()], + interfaces.clone(), + Some("198.51.100.7".to_string()), + None, + tx, + firewall_manager, + ); + + let msg = tokio::time::timeout(Duration::from_secs(10), rx.recv()) + .await + .expect("IpChanged should be emitted well before the timeout") + .expect("cmd_result channel should stay open"); + match msg { + AgentMessage::IpChanged { + ipv4, + ipv6, + interfaces: reported, + } => { + assert_eq!(ipv4.as_deref(), Some("203.0.113.42")); + assert_eq!(ipv6.as_deref(), Some("2001:db8::1")); + assert_eq!(reported.len(), 2); + assert_eq!(reported[0].name, "docker0"); + assert_eq!(reported[1].name, "eth0"); + } + other => panic!("expected IpChanged, got {other:?}"), + } + } + + #[tokio::test] + async fn test_spawn_external_ip_refresh_stays_silent_when_unchanged() { + // Same inputs, but the baseline already matches what the interfaces + // derive: the refresh must return early without emitting anything. + let interfaces = vec![NetworkInterface { + name: "eth0".to_string(), + ipv4: vec!["203.0.113.42".to_string()], + ipv6: vec!["2001:db8::1".to_string()], + }]; + let (tx, mut rx) = mpsc::channel(4); + let firewall_manager = Arc::new(FirewallManager::new(Arc::new(CliNftExecutor))); + + spawn_external_ip_refresh( + Vec::new(), + interfaces, + Some("203.0.113.42".to_string()), + Some("2001:db8::1".to_string()), + tx, + firewall_manager, + ); + + // The task returns early and drops its sender, so the receiver closes + // instead of yielding a message. + let received = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("the refresh task should finish and drop its sender"); + assert!( + received.is_none(), + "an unchanged refresh must not emit IpChanged, got {received:?}" + ); + } + // ---------------------------------------------------------------------- // Pure-helper coverage (no I/O, no managers). // ---------------------------------------------------------------------- From 8b0ced49c6f51fc3d86a6e6cba41d070c26a6d90 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:56:41 +0800 Subject: [PATCH 23/30] fix(server): fail pending agent requests fast on disconnect --- crates/server/src/router/api/file.rs | 17 +++- crates/server/src/service/agent_manager.rs | 101 ++++++++++++++++----- crates/server/tests/router_file_extra.rs | 75 +++++++++++++++ 3 files changed, 168 insertions(+), 25 deletions(-) diff --git a/crates/server/src/router/api/file.rs b/crates/server/src/router/api/file.rs index 1d60fb53..77c5289d 100644 --- a/crates/server/src/router/api/file.rs +++ b/crates/server/src/router/api/file.rs @@ -915,7 +915,7 @@ async fn upload_file( let init_ack_key = AgentManager::upload_ack_key(&transfer_id); let init_ack_rx = state .agent_manager - .register_pending_request(init_ack_key.clone()); + .register_pending_request(&server_id, init_ack_key.clone()); sender .send(ServerMessage::FileUploadStart { @@ -943,13 +943,22 @@ async fn upload_file( let _ = tokio::fs::remove_file(&temp_upload).await; return Err(agent_error(error)); } - Ok(Ok(_)) | Ok(Err(_)) => { + Ok(Ok(_)) => { state .file_transfers .mark_failed(&transfer_id, "Agent rejected upload".into()); let _ = tokio::fs::remove_file(&temp_upload).await; return Err(AppError::Internal("Agent rejected upload".into())); } + Ok(Err(_)) => { + state + .file_transfers + .mark_failed(&transfer_id, "Agent disconnected".into()); + let _ = tokio::fs::remove_file(&temp_upload).await; + return Err(AppError::Internal( + "Agent disconnected during upload".into(), + )); + } Err(_) => { state.agent_manager.cancel_pending_request(&init_ack_key); state @@ -994,7 +1003,7 @@ async fn upload_file( let ack_msg_id = AgentManager::upload_ack_key(&transfer_id); let ack_rx = state .agent_manager - .register_pending_request(ack_msg_id.clone()); + .register_pending_request(&server_id, ack_msg_id.clone()); sender .send(ServerMessage::FileUploadChunk { @@ -1074,7 +1083,7 @@ async fn upload_file( let complete_msg_id = AgentManager::upload_complete_key(&transfer_id); let complete_rx = state .agent_manager - .register_pending_request(complete_msg_id.clone()); + .register_pending_request(&server_id, complete_msg_id.clone()); sender .send(ServerMessage::FileUploadEnd { diff --git a/crates/server/src/service/agent_manager.rs b/crates/server/src/service/agent_manager.rs index 27cf8046..910f9c3e 100644 --- a/crates/server/src/service/agent_manager.rs +++ b/crates/server/src/service/agent_manager.rs @@ -111,15 +111,8 @@ pub struct AgentManager { browser_tx: broadcast::Sender, /// Maps session_id -> terminal output channel (for routing agent output to browser WS) terminal_sessions: DashMap, - /// Maps msg_id -> (oneshot sender, creation time, TTL) for HTTP→WS relay - pending_requests: DashMap< - String, - ( - oneshot::Sender, - std::time::Instant, - std::time::Duration, - ), - >, + /// Maps msg_id -> pending response slot for HTTP→WS relay + pending_requests: DashMap, // Docker caches docker_containers: DashMap>, docker_stats: DashMap>, @@ -140,6 +133,16 @@ pub struct AgentManager { next_connection_id: AtomicU64, } +/// A response slot awaiting an agent reply. Tied to the server it was sent +/// to so disconnect cleanup can fail the waiter immediately instead of +/// letting it hang until the ack timeout. +struct PendingRequest { + tx: oneshot::Sender, + server_id: String, + created_at: std::time::Instant, + ttl: std::time::Duration, +} + #[allow(dead_code)] pub struct AgentConnection { pub connection_id: u64, @@ -449,6 +452,11 @@ impl AgentManager { fn finish_connection_removal(&self, server_id: &str) { self.agent_local_capabilities.remove(server_id); self.temporary_grants.remove(server_id); + // Fail in-flight request/response waiters (uploads, exec, listings) + // immediately: the reply can never arrive on a removed connection, so + // dropping the sender surfaces Disconnected instead of an ack timeout. + self.pending_requests + .retain(|_, pending| pending.server_id != server_id); self.terminal_sessions .retain(|_, session| session.server_id != server_id); self.remove_docker_log_sessions_for_server(server_id); @@ -503,14 +511,23 @@ impl AgentManager { /// Register a pending request for HTTP→WS relay with a custom TTL. /// Returns a oneshot receiver that will receive the agent's response. + /// The slot is dropped (failing the receiver) if `server_id` disconnects. pub fn register_pending_request_with_ttl( &self, + server_id: &str, msg_id: String, ttl: std::time::Duration, ) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); - self.pending_requests - .insert(msg_id, (tx, std::time::Instant::now(), ttl)); + self.pending_requests.insert( + msg_id, + PendingRequest { + tx, + server_id: server_id.to_string(), + created_at: std::time::Instant::now(), + ttl, + }, + ); rx } @@ -521,8 +538,16 @@ impl AgentManager { /// Register a pending request for HTTP→WS relay with a default 60s TTL. /// Returns a oneshot receiver that will receive the agent's response. - pub fn register_pending_request(&self, msg_id: String) -> oneshot::Receiver { - self.register_pending_request_with_ttl(msg_id, std::time::Duration::from_secs(60)) + pub fn register_pending_request( + &self, + server_id: &str, + msg_id: String, + ) -> oneshot::Receiver { + self.register_pending_request_with_ttl( + server_id, + msg_id, + std::time::Duration::from_secs(60), + ) } /// Remove a pending response slot when the request owner stops waiting. @@ -562,6 +587,7 @@ impl AgentManager { // TTL is a backstop above the await deadline; failure paths below // remove the slot eagerly so the sweep never has to. let rx = self.register_pending_request_with_ttl( + server_id, msg_id.clone(), timeout + std::time::Duration::from_secs(10), ); @@ -582,8 +608,8 @@ impl AgentManager { /// Dispatch a response from the agent to a pending HTTP request. /// Returns true if the response was delivered, false if no pending request was found. pub fn dispatch_pending_response(&self, msg_id: &str, message: AgentMessage) -> bool { - if let Some((_, (tx, _, _))) = self.pending_requests.remove(msg_id) { - let _ = tx.send(message); + if let Some((_, pending)) = self.pending_requests.remove(msg_id) { + let _ = pending.tx.send(message); true } else { false @@ -786,7 +812,7 @@ impl AgentManager { pub fn cleanup_expired_requests(&self) { let now = std::time::Instant::now(); self.pending_requests - .retain(|_, (_, created_at, ttl)| now.duration_since(*created_at) < *ttl); + .retain(|_, pending| now.duration_since(pending.created_at) < pending.ttl); } // --- Traceroute cache --- @@ -1353,8 +1379,11 @@ mod tests { #[test] fn test_cleanup_expired_requests() { let (mgr, _rx) = make_manager(); - let _rx1 = mgr - .register_pending_request_with_ttl("old".into(), std::time::Duration::from_millis(1)); + let _rx1 = mgr.register_pending_request_with_ttl( + "s1", + "old".into(), + std::time::Duration::from_millis(1), + ); std::thread::sleep(std::time::Duration::from_millis(10)); mgr.cleanup_expired_requests(); let dispatched = mgr.dispatch_pending_response( @@ -1371,7 +1400,7 @@ mod tests { #[test] fn test_pending_request_lifecycle() { let (mgr, _rx) = make_manager(); - let mut rx = mgr.register_pending_request("req1".into()); + let mut rx = mgr.register_pending_request("s1", "req1".into()); assert!(rx.try_recv().is_err()); let dispatched = mgr.dispatch_pending_response( @@ -1395,15 +1424,45 @@ mod tests { assert!(!dispatched2); } + #[tokio::test] + async fn test_disconnect_fails_pending_requests_for_that_server_only() { + let (mgr, _brx) = make_manager(); + let (tx1, _rx1) = mpsc::channel(8); + let (tx2, _rx2) = mpsc::channel(8); + mgr.add_connection("s1".into(), "A".into(), tx1, test_addr()); + mgr.add_connection("s2".into(), "B".into(), tx2, test_addr()); + let mut pending_s1 = mgr.register_pending_request("s1", "req-s1".into()); + let mut pending_s2 = mgr.register_pending_request("s2", "req-s2".into()); + + mgr.remove_connection("s1"); + + // The disconnected server's waiter fails immediately instead of + // hanging until its ack timeout; the other server's slot survives. + assert!(matches!( + pending_s1.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + assert!(!mgr.has_pending_request("req-s1")); + assert!(matches!( + pending_s2.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + assert!(mgr.has_pending_request("req-s2")); + } + #[test] fn test_cleanup_expired_requests_per_entry_ttl() { let (mgr, _rx) = make_manager(); let _rx1 = mgr.register_pending_request_with_ttl( + "s1", "short".into(), std::time::Duration::from_millis(10), ); - let _rx2 = mgr - .register_pending_request_with_ttl("long".into(), std::time::Duration::from_secs(300)); + let _rx2 = mgr.register_pending_request_with_ttl( + "s1", + "long".into(), + std::time::Duration::from_secs(300), + ); std::thread::sleep(std::time::Duration::from_millis(50)); mgr.cleanup_expired_requests(); assert!(!mgr.has_pending_request("short")); diff --git a/crates/server/tests/router_file_extra.rs b/crates/server/tests/router_file_extra.rs index 8db3bb48..be7d7de8 100644 --- a/crates/server/tests/router_file_extra.rs +++ b/crates/server/tests/router_file_extra.rs @@ -16,6 +16,8 @@ //! the agent rejecting the upload start with FileUploadError, //! * upload chunk-phase failures (bad ack offset, agent error) and an error //! reported at FileUploadEnd, each asserted through `mark_failed`, +//! * a mid-transfer agent disconnect failing fast (pending slots are dropped +//! by disconnect cleanup instead of waiting out the ack timeout), //! * the streaming max-upload-size guard (needs a smaller configured limit). //! //! Tests whose HTTP handler blocks on an agent reply while a spawned responder @@ -1280,6 +1282,79 @@ async fn test_upload_end_reports_error_is_400() { ); } +// =========================================================================== +// upload: the agent's WebSocket drops mid-transfer. Disconnect cleanup fails +// the pending ack slot immediately, so the upload errors out fast instead of +// hanging until the 30s ack timeout. +// =========================================================================== + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_upload_agent_disconnect_fails_fast() { + let (base_url, _tmp) = start_test_server().await; + let client = http_client(); + login_admin(&client, &base_url).await; + + let (server_id, token) = register_agent(&client, &base_url).await; + let (mut sink, mut reader) = + connect_agent_with_caps(&base_url, &token, CAP_DEFAULT | CAP_FILE).await; + + let agent_task = tokio::spawn(async move { + loop { + let msg = recv_agent_text(&mut reader).await; + match msg["type"].as_str() { + Some("file_upload_start") => { + let transfer_id = msg["transfer_id"].as_str().expect("transfer_id"); + send_upload_ack(&mut sink, transfer_id, 0).await; + } + Some("file_upload_chunk") => { + // Drop the whole connection instead of acking: returning + // moves sink and reader out of scope, closing the socket + // while the server is awaiting the chunk ack. + return; + } + other if is_first_connect_noise(other) => {} + Some(other) => panic!("unexpected agent command: {other}"), + None => {} + } + } + }); + + let form = reqwest::multipart::Form::new() + .text("path", "/tmp/vanish.txt") + .part( + "file", + reqwest::multipart::Part::bytes(b"doomed payload".to_vec()).file_name("vanish.txt"), + ); + + let started = std::time::Instant::now(); + let resp = client + .post(format!("{base_url}/api/files/{server_id}/upload")) + .multipart(form) + .send() + .await + .expect("upload request failed"); + let elapsed = started.elapsed(); + + assert_eq!(resp.status(), 500, "mid-transfer disconnect should be 500"); + let body: Value = resp.json().await.expect("error body"); + let message = body["error"]["message"].as_str().expect("error message"); + assert!( + message.contains("Agent disconnected during upload"), + "the error must name the disconnect, not a timeout: {message}" + ); + assert!( + elapsed < std::time::Duration::from_secs(10), + "disconnect must fail fast, took {elapsed:?}" + ); + + agent_task.await.expect("agent responder failed"); + assert_eq!( + transfer_status_for_path(&client, &base_url, "/tmp/vanish.txt").await, + "failed", + "mid-transfer disconnect must mark the transfer failed" + ); +} + // =========================================================================== // upload: the streaming size guard. `write_router` sets the Axum body limit to // `max_upload_size + 5 MB`, so a body just over a tiny configured limit still From 441e6a98e24bd6797e5e82bb3d6c440366137964 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:56:41 +0800 Subject: [PATCH 24/30] refactor(server): ignore binary frames on agent ws --- crates/server/src/router/ws/agent/mod.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/crates/server/src/router/ws/agent/mod.rs b/crates/server/src/router/ws/agent/mod.rs index 3c6337ba..9174d927 100644 --- a/crates/server/src/router/ws/agent/mod.rs +++ b/crates/server/src/router/ws/agent/mod.rs @@ -235,23 +235,11 @@ async fn handle_agent_ws( tracing::warn!("Invalid message from agent {sid_read}: {e}, text: {text}"); } }, - Ok(Message::Binary(data)) => match serde_json::from_slice::(&data) { - Ok(agent_msg) => { - if !handle_current_connection_frame( - &state_read, - &sid_read, - connection_id, - CurrentConnectionFrame::AgentMessage(Box::new(agent_msg)), - ) - .await - { - break; - } - } - Err(e) => { - tracing::warn!("Invalid binary message from agent {sid_read}: {e}"); - } - }, + Ok(Message::Binary(_)) => { + // The protocol is text-only (JSON frames); binary frames are + // not part of it and are ignored rather than parsed. + tracing::warn!("Ignoring unexpected binary frame from agent {sid_read}"); + } Ok(Message::Pong(_)) => { if !handle_current_connection_frame( &state_read, From 01c6048f46ba2304d0776cad813b6d176366fe69 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 20:56:41 +0800 Subject: [PATCH 25/30] chore(agent): remove dead reporter run wrapper --- crates/agent/src/reporter/mod.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index 83655eb5..bd55f967 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -49,14 +49,6 @@ impl Reporter { } } - /// Convenience wrapper around [`Self::run_with_external`] for tests - /// and historical callers that don't need a security stream. - #[cfg(test)] - #[allow(dead_code)] - pub async fn run(&mut self) { - self.run_with_external(None).await - } - /// Run with an optional external agent-message stream attached /// (currently sourced from [`crate::security::SecurityManager`]). pub async fn run_with_external( From 84ba9cfdff9c1e4496e6d816b394e1018eaf705b Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 21:57:30 +0800 Subject: [PATCH 26/30] fix(server): empty status page selection includes every server --- crates/server/src/service/public_status.rs | 25 +++++---- crates/server/tests/public_status_gating.rs | 57 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/server/src/service/public_status.rs b/crates/server/src/service/public_status.rs index 19398773..50f13ded 100644 --- a/crates/server/src/service/public_status.rs +++ b/crates/server/src/service/public_status.rs @@ -349,7 +349,8 @@ pub fn to_public_config(model: &status_page::Model) -> PublicStatusConfig { } /// Resolve the in-scope server IDs by intersecting the admin-selected list -/// with the live, non-hidden `servers` rows. +/// with the live, non-hidden `servers` rows. An empty selection means "every +/// server" — the admin UI advertises exactly that semantic for the selector. pub async fn resolve_scope(db: &DatabaseConnection) -> Result { let config = load_config(db).await?; let selected: Vec = if config.server_ids_json.trim().is_empty() { @@ -367,10 +368,14 @@ pub async fn resolve_scope(db: &DatabaseConnection) -> Result Date: Wed, 12 Aug 2026 21:57:30 +0800 Subject: [PATCH 27/30] fix(web): generate uuids outside secure contexts --- .../components/server/add-server-dialog.tsx | 3 ++- apps/web/src/hooks/use-dashboard-editor.ts | 3 ++- apps/web/src/lib/uuid.ts | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/uuid.ts diff --git a/apps/web/src/components/server/add-server-dialog.tsx b/apps/web/src/components/server/add-server-dialog.tsx index 78a1e6d2..10cb0071 100644 --- a/apps/web/src/components/server/add-server-dialog.tsx +++ b/apps/web/src/components/server/add-server-dialog.tsx @@ -26,6 +26,7 @@ import type { } from '@/lib/api-schema' import { refreshServerCatalog } from '@/lib/server-catalog' import { cn } from '@/lib/utils' +import { randomUUID } from '@/lib/uuid' const TAG_SPLIT_RE = /[\s,]+/ const TAG_VALID_RE = /^[A-Za-z0-9_.-]+$/ @@ -171,7 +172,7 @@ function initialAddServerFormState(): AddServerFormState { groupId: '', issued: null, name: '', - onboardingRequestId: crypto.randomUUID(), + onboardingRequestId: randomUUID(), price: '', publicRemark: '', remark: '', diff --git a/apps/web/src/hooks/use-dashboard-editor.ts b/apps/web/src/hooks/use-dashboard-editor.ts index 5c33fac4..1c095666 100644 --- a/apps/web/src/hooks/use-dashboard-editor.ts +++ b/apps/web/src/hooks/use-dashboard-editor.ts @@ -4,6 +4,7 @@ import { mergeLayoutPatch, normalizeNewWidgetPlacement } from '@/components/dashboard/dashboard-layout' +import { randomUUID } from '@/lib/uuid' import { parseConfig } from '@/lib/widget-helpers' import type { DashboardWidget } from '@/lib/widget-types' import { WIDGET_TYPES, type WidgetTypeDefinition } from '@/lib/widget-types' @@ -60,7 +61,7 @@ export function useDashboardEditor() { } function addWidget({ configJson, dashboardId, gridH, gridW, moduleId, title, widgetType }: AddWidgetInput): string { - const id = `temp-${crypto.randomUUID()}` + const id = `temp-${randomUUID()}` setDraftWidgets((current) => { const defaults = getWidgetTypeDefaults(widgetType) const newWidget: DashboardWidget = { diff --git a/apps/web/src/lib/uuid.ts b/apps/web/src/lib/uuid.ts new file mode 100644 index 00000000..53c3c521 --- /dev/null +++ b/apps/web/src/lib/uuid.ts @@ -0,0 +1,21 @@ +/** + * UUID v4 that also works outside secure contexts. + * + * `crypto.randomUUID` is only exposed on HTTPS/localhost origins; self-hosted + * dashboards are routinely served over plain HTTP on a LAN or VPS IP, where + * calling it throws. `crypto.getRandomValues` has no such restriction, so fall + * back to assembling the UUID manually from it. + */ +export function randomUUID(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + const bytes = crypto.getRandomValues(new Uint8Array(16)) + // Per RFC 4122 §4.4: version 4, variant 10xx. + // biome-ignore lint/suspicious/noBitwiseOperators: RFC 4122 version bits require masking + bytes[6] = (bytes[6] & 0x0f) | 0x40 + // biome-ignore lint/suspicious/noBitwiseOperators: RFC 4122 variant bits require masking + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')) + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}` +} From 888f1dc25f603cec8d8f25956603f15f603ef06e Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 22:24:33 +0800 Subject: [PATCH 28/30] test: record vps regression testloop report --- .gitignore | 3 +- .../2026-08-12-broad-vps-regression.md | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .testloop/reports/2026-08-12-broad-vps-regression.md diff --git a/.gitignore b/.gitignore index 402c9c01..56189edb 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ agent.toml # Git worktrees .worktrees .superpowers/ -.testloop/ +.testloop/* +!.testloop/reports/ diff --git a/.testloop/reports/2026-08-12-broad-vps-regression.md b/.testloop/reports/2026-08-12-broad-vps-regression.md new file mode 100644 index 00000000..406d0831 --- /dev/null +++ b/.testloop/reports/2026-08-12-broad-vps-regression.md @@ -0,0 +1,48 @@ +# testloop — 2026-08-12 — broad VPS regression + +**Verdict:** pass · **Rounds:** 3 + +Fresh install of current main on the dedicated Linux test VPS (Debian 13, +plain HTTP on :9527), one real agent on the same box with terminal / exec / +file / docker capabilities enabled. The deployment itself exercised the real +first-run path: random admin password from the logs → forced onboarding +password change → enrollment code → agent claim. + +## Covered + +- Auth (login, wrong-password error, session persistence, logout, member + role isolation, audit log), first-run onboarding, agent enrollment UI. +- Dashboards: create / rename / delete / edit mode, add-widget flow, layout + persistence; live WebSocket metric updates. +- Server detail: metrics charts, system info, terminal (PTY commands), file + manager (mkdir, upload, download with byte-for-byte verification, rename, + delete, root-path escape rejection), docker (list + log streaming, + read-only). +- Service monitors (HTTP keyword up/down verdicts), ping tasks (ICMP results + chart), scheduled commands (run-now via exec), alert rules (threshold + trigger with 5-min debounce, webhook failure handling), security events + (injected SSH auth failures), public status page. + +## Found & fixed + +- `crypto.randomUUID` is unavailable on plain-HTTP origins: the /servers page + crashed with an error boundary and the dashboard Add Widget flow failed + silently. Fixed with a `getRandomValues`-based fallback in + `apps/web/src/lib/uuid.ts` (used by `add-server-dialog.tsx` and + `use-dashboard-editor.ts`). +- Public status page: the admin UI promises "leave empty to include every + server" but an empty selection resolved to zero servers, so /status showed + "No servers available". Fixed in + `crates/server/src/service/public_status.rs` (`resolve_scope`), with an + integration regression test in `tests/public_status_gating.rs`. + +## Still open + +- UX observation, not a defect: ping-task latency lives only under + Settings → Ping Tasks; the server-detail Network tab belongs to the + separate network-probe feature and shows "No probe targets configured" + even when ping tasks are collecting. Two independent test rounds went + looking for ping data there first — worth considering a cross-link or an + empty-state hint. +- Round-1 terminal first-connect needed one manual Reconnect; not + reproducible in round 2 (connected first try). Watch for recurrence. From e0c3f0c1b9cc9cb5e5c3a68613b747c5935eea7c Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 23:36:15 +0800 Subject: [PATCH 29/30] docs: remove plaintext vps password from plan --- docs/superpowers/plans/2026-05-26-status-page-refactor.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-26-status-page-refactor.md b/docs/superpowers/plans/2026-05-26-status-page-refactor.md index 3ad6f62c..de8b544d 100644 --- a/docs/superpowers/plans/2026-05-26-status-page-refactor.md +++ b/docs/superpowers/plans/2026-05-26-status-page-refactor.md @@ -1452,7 +1452,7 @@ git commit -m "docs: log public status page refactor" # Phase 10 — Remote verification -VPS credentials: `root@207.241.173.217:22` / password `2ucW09DzI@!LZ!e47yG`. The host is the project's reusable test VPS (Ubuntu 24.04). The CLAUDE.md / memory note `reference_test_vps.md` covers it. +VPS credentials: `root@207.241.173.217:22`; the password is provided per-session (export it as `SSHPASS`, never write it into the repo). The host is the project's reusable test VPS (Ubuntu 24.04). The CLAUDE.md / memory note `reference_test_vps.md` covers it. ### Task 25: Build, ship, smoke test @@ -1461,7 +1461,7 @@ VPS credentials: `root@207.241.173.217:22` / password `2ucW09DzI@!LZ!e47yG`. The The host architecture varies; check via SSH first: ``` -sshpass -p '2ucW09DzI@!LZ!e47yG' ssh -o StrictHostKeyChecking=no root@207.241.173.217 'uname -m' +sshpass -e ssh -o StrictHostKeyChecking=no root@207.241.173.217 'uname -m' ``` If `x86_64`, run on macOS host (assuming `aarch64-apple-darwin` dev machine): @@ -1475,11 +1475,11 @@ Confirm a working toolchain exists; otherwise build inside a Linux container or - [ ] **Step 2: scp and run** ``` -sshpass -p '...' scp -o StrictHostKeyChecking=no \ +sshpass -e scp -o StrictHostKeyChecking=no \ target/x86_64-unknown-linux-musl/release/serverbee-server \ root@207.241.173.217:/opt/serverbee/serverbee-server.new -sshpass -p '...' ssh root@207.241.173.217 'systemctl stop serverbee || true; \ +sshpass -e ssh root@207.241.173.217 'systemctl stop serverbee || true; \ mv /opt/serverbee/serverbee-server.new /opt/serverbee/serverbee-server; \ chmod +x /opt/serverbee/serverbee-server; \ systemctl start serverbee || /opt/serverbee/serverbee-server &' From 30d71bbe7d0888e16ebfc3434f8dc04e22838ad6 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Wed, 12 Aug 2026 23:36:58 +0800 Subject: [PATCH 30/30] docs: remove plaintext vps password from ip-quality plan --- docs/superpowers/plans/2026-05-25-ip-quality-ipapi-is.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-25-ip-quality-ipapi-is.md b/docs/superpowers/plans/2026-05-25-ip-quality-ipapi-is.md index 675c9101..5a55efcb 100644 --- a/docs/superpowers/plans/2026-05-25-ip-quality-ipapi-is.md +++ b/docs/superpowers/plans/2026-05-25-ip-quality-ipapi-is.md @@ -1660,7 +1660,7 @@ cd ../.. - [ ] **Step 3: SCP to test VPS** ```bash -sshpass -p '2ucW09DzI@!LZ!e47yG' scp \ +sshpass -e scp \ -o StrictHostKeyChecking=accept-new \ target/x86_64-unknown-linux-gnu/release/serverbee-server \ root@207.241.173.217:/tmp/serverbee-server-test @@ -1673,7 +1673,7 @@ sshpass -p '2ucW09DzI@!LZ!e47yG' scp \ SSH in and run each of the 6 checklist items from `tests/ip-quality/ipapi-is.md`: ```bash -sshpass -p '2ucW09DzI@!LZ!e47yG' ssh -o StrictHostKeyChecking=accept-new \ +sshpass -e ssh -o StrictHostKeyChecking=accept-new \ root@207.241.173.217 ```