diff --git a/doc/specs/passive-scan.plan.md b/doc/specs/passive-scan.plan.md new file mode 100644 index 0000000..214ba65 --- /dev/null +++ b/doc/specs/passive-scan.plan.md @@ -0,0 +1,256 @@ +# Passive Scan Refactor Plan + +Status: Done (2026-06-25) + +## Goal + +Replace the passive-scan placeholder wait with a ZAP-driven workflow based on +the records-to-scan endpoint. + +- When passive scan starts, fetch the initial remaining record count from + JSON/pscan/view/recordsToScan. +- Poll the same endpoint regularly and keep the current remaining count in + worker state. +- Recalculate passive progress whenever the remaining count decreases. +- Mark passive scan done when the remaining count reaches 0. + +## Decisions + +- Keep this plan implementation-agnostic. +- Reuse existing worker poll cadence (scan_poll_interval); do not add a new + runtime setting. +- Keep passive progress monotonic by tracking the lowest observed + records-to-scan value. +- Treat endpoint payload contract as fixed: JSON object with field + "recordsToScan" containing a string-encoded non-negative integer. +- Treat recordsToScan as per-target progress input under current runtime + assumptions, even though the endpoint reflects global ZAP state: + - concurrent workers are expected to run on isolated ZAP instances once + multi-worker concurrency is supported + - targets are processed sequentially within a single worker/scan +- Read initial_records immediately when entering passive scan phase (before + waiting for the first poll interval tick). +- Use immediate running-then-done semantics when initial_records is zero: + enter passive running state, read initial_records, then transition directly + to passive done if the value is 0. +- If recordsToScan increases between polls, keep progress monotonic (ignore + the increase for percentage calculation) and emit a debug log with the + previous and current values. +- Keep logging minimal and consistent with existing runtime observability: + - temporary recordsToScan increases: debug log (already specified) + - repeated endpoint unavailability: rely on standard retry logs + (warn on retries, error on retry exhaustion) + - invalid endpoint content: rely on existing worker failure-path error logs + when scan execution fails +- Use the standard retry mechanism for recordsToScan endpoint calls; if retry + attempts are exhausted, fail the scan via existing worker failure handling. +- Use single-zero completion in the current implementation: when + current_records == 0, complete passive phase without an additional + confirmation read. +- Keep consecutive-zero confirmation as a future hardening option if runtime + evidence shows zero-value jitter. +- Use stop-first race handling in the passive loop: + - evaluate stop_requested before completion checks + - if stop is requested, stop path wins immediately + - evaluate completion (current_records == 0) only when no stop is pending +- Persist both raw passive-scan counts and the derived percentage: + - raw counts for observability/diagnostics (initial_records and current_records) + - percentage/state as canonical progress consumed by existing API/status logic +- Use a deterministic passive progress formula with explicit floor rounding and + clamping. +- Preserve passive-stage stop behavior (no separate ZAP passive-stop action). +- Remove obsolete passive placeholder-duration runtime configuration. + +## Passive Progress Formula + +Definitions: + +- initial_records: records-to-scan value read once at passive phase start +- current_records: most recent records-to-scan value from polling +- min_records_seen: minimum observed current_records value during the phase + +Computation: + +- If initial_records == 0, passive progress is 100 and passive state is done. +- Otherwise, use current_effective = min(current_records, min_records_seen). +- Compute ratio = (initial_records - current_effective) / initial_records. +- Compute percentage_raw = floor(100 * ratio). +- Clamp passive_progress_percentage = clamp(percentage_raw, 0, 100). + +Update rules: + +- Recalculate only when current_records decreases below min_records_seen. +- Persist raw count updates (initial/current) and persist percentage/state when + passive_progress_percentage changes. +- When current_effective reaches 0, mark passive state done (percentage 100). + +## Target Files + +- src/zapclient/mod.rs +- src/zapclient/pscan.rs (new) +- src/zapclient/pscan_tests.rs (new) +- src/scan/worker.rs +- src/scan/worker_tests.rs +- src/lib.rs + +## Non-Goals + +- No API contract changes for scan status endpoints. +- No changes to passive-scan weighting in progress calculations. +- No migration of unrelated worker polling or retry behavior. + +## Open Questions + +- No unresolved questions currently. + +## Phased Rollout + +### Phase 1: Add ZAP recordsToScan client support + +Status: Done (2026-06-25) + +- Add a new pscan module and wire it through zapclient exports. +- Implement a ZapClient method that calls JSON/pscan/view/recordsToScan, + sends apikey, parses numeric content, and validates non-negative values. +- Add RetryingZapClient wrapper support for this operation. +- Add sidecar tests for success, HTTP errors, parse errors, and invalid + content values. + +Acceptance: + +- Endpoint wrapper follows existing zapclient error-handling conventions. +- Retry wrapper parity matches other zapclient operations. +- New pscan tests pass. + +Validation recorded: + +- pscan tests executed and passed. +- zapclient suite executed and passed. + +### Phase 2: Replace passive placeholder workflow in worker + +Status: Done (2026-06-25) + +- Replace timer-based passive phase logic with records-based polling. +- Fetch initial records-to-scan count at passive phase start. +- Persist initial/current records-to-scan values in progress payload. +- If initial count is zero, finish passive stage immediately. +- Poll records-to-scan on scan_poll_interval until count reaches zero. +- Recompute passive percentage when the count decreases versus previously + observed minimum. +- Apply the agreed passive progress formula with floor rounding and + 0..100 clamping. +- Persist raw count updates and persist percentage/state when effective + passive percentage changes. +- Keep existing stop-request handling path for passive stage. + +Acceptance: + +- Passive phase completion is driven by records-to-scan reaching zero. +- Progress updates occur only on decreasing counts. +- Raw records counts are persisted for observability while percentage/state + remains the canonical progress signal. +- Stop handling behavior in passive phase is unchanged. + +Validation recorded: + +- progress tests executed and passed. +- worker runtime tests executed and passed. + +### Phase 3: Remove placeholder runtime config + +Status: Done (2026-06-25) + +- Remove DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION and related config fields + from scan runtime configuration. +- Remove remaining placeholder-duration references in worker logic and startup + config wiring. +- Update affected test config initializers. + +Acceptance: + +- No runtime code references passive_scan_placeholder_duration. +- Runtime and tests compile with updated config structs. + +Validation recorded: + +- worker runtime tests executed and passed. + +### Phase 4: Update worker runtime tests + +Status: Done (2026-06-25) + +- Add records-to-scan mock helpers in worker tests. +- Update passive-stage stop test setup to use records-driven passive flow. +- Add regression coverage for: + - progress updates only when records decrease + - passive phase completion when records reach zero + +Acceptance: + +- Worker tests validate records-driven passive lifecycle behavior. +- Existing stop semantics remain verified. + +Validation recorded: + +- worker runtime tests executed and passed. + +### Phase 5: Add pscan sequence-sidecar regressions + +Status: Done (2026-06-25) + +- Add sequence-oriented sidecar tests in `src/zapclient/pscan_tests.rs` for + mixed response progressions (for example, valid schema transitions and + malformed content boundaries across repeated calls). +- Keep helper abstraction local and shallow in the pscan test file. +- Preserve explicit endpoint contract assertions for + `JSON/pscan/view/recordsToScan`. + +Acceptance: + +- `pscan_tests` include targeted sequence regressions that remain easy to read. +- Existing pscan contract/error tests remain intact. + +Validation recorded: + +- cargo test pscan_tests -- --nocapture passed. +- cargo test zapclient -- --nocapture passed. + +## Validation + +After implementation, run targeted tests and then broader zapclient coverage. + +- cargo test pscan_tests -- --nocapture +- cargo test worker_tests -- --nocapture +- cargo test progress_tests -- --nocapture +- cargo test zapclient -- --nocapture + +Additional validation for Phase 5: + +- cargo test pscan_tests -- --nocapture + +Closing validation recorded (2026-06-25): + +- cargo test pscan_tests -- --nocapture passed. +- cargo test worker_tests -- --nocapture passed. +- cargo test progress_tests -- --nocapture passed. +- cargo test zapclient -- --nocapture passed. + +## Risks and Mitigations + +- Risk: records-to-scan value can increase transiently, causing backward progress. + - Mitigation: update progress only when current count drops below prior minimum. + +- Risk: excessive progress persistence writes during polling. + - Mitigation: persist percentage/state only on effective percentage change; + raw counts are persisted as observability data. + +- Risk: endpoint payload shape differs from assumptions. + - Mitigation: explicit parse and content-validation tests in pscan sidecar tests. + +## Done Definition + +- Passive scan no longer uses placeholder timing. +- Worker uses records-to-scan polling for passive-stage progress and completion. +- Placeholder runtime config is removed. +- New and updated tests pass with unchanged stop semantics. diff --git a/src/scan/progress.rs b/src/scan/progress.rs index 532d019..cf87c75 100644 --- a/src/scan/progress.rs +++ b/src/scan/progress.rs @@ -26,6 +26,10 @@ pub struct TargetProgress { pub passive_scan_state: StageState, #[serde(default)] pub passive_scan_percentage: i32, + #[serde(default)] + pub passive_scan_initial_records: Option, + #[serde(default)] + pub passive_scan_current_records: Option, pub overall_percentage: i32, } @@ -48,6 +52,8 @@ impl ScanProgress { active_scan_percentage: 0, passive_scan_state: StageState::Pending, passive_scan_percentage: 0, + passive_scan_initial_records: None, + passive_scan_current_records: None, overall_percentage: 0, }) .collect(); @@ -100,6 +106,12 @@ impl ScanProgress { self.refresh(); } + pub fn set_passive_scan_records(&mut self, index: usize, initial: u64, current: u64) { + let target = &mut self.targets[index]; + target.passive_scan_initial_records = Some(initial); + target.passive_scan_current_records = Some(current); + } + pub fn update_passive_scan(&mut self, index: usize, percentage: i32) { let target = &mut self.targets[index]; target.passive_scan_percentage = percentage.clamp(0, 100); diff --git a/src/scan/progress_tests.rs b/src/scan/progress_tests.rs index 788c67e..9eba4d5 100644 --- a/src/scan/progress_tests.rs +++ b/src/scan/progress_tests.rs @@ -23,6 +23,8 @@ fn new_creates_pending_targets_with_zero_progress() { assert_eq!(target.active_scan_percentage, 0); assert_eq!(target.passive_scan_state, StageState::Pending); assert_eq!(target.passive_scan_percentage, 0); + assert_eq!(target.passive_scan_initial_records, None); + assert_eq!(target.passive_scan_current_records, None); assert_eq!(target.overall_percentage, 0); } } @@ -278,6 +280,17 @@ fn mark_passive_scan_done_sets_percentage_to_100() { assert_eq!(progress.targets[0].passive_scan_state, StageState::Done); } +#[test] +fn set_passive_scan_records_updates_initial_and_current_values() { + let hosts = vec!["http://a.example".to_string()]; + let mut progress = ScanProgress::new(&hosts); + + progress.set_passive_scan_records(0, 123, 45); + + assert_eq!(progress.targets[0].passive_scan_initial_records, Some(123)); + assert_eq!(progress.targets[0].passive_scan_current_records, Some(45)); +} + // ─── overall_percentage (multi-target) ─────────────────────────────────────── #[test] @@ -320,6 +333,8 @@ fn as_value_serializes_to_json_and_deserializes_back() { assert_eq!(restored.targets[0].spider_state, StageState::Done); assert_eq!(restored.targets[0].active_scan_percentage, 50); assert_eq!(restored.targets[0].passive_scan_percentage, 0); + assert_eq!(restored.targets[0].passive_scan_initial_records, None); + assert_eq!(restored.targets[0].passive_scan_current_records, None); assert_eq!(restored.targets[0].overall_percentage, 60); assert_eq!(restored.overall_percentage, 60); } diff --git a/src/scan/worker.rs b/src/scan/worker.rs index f10ec39..22b7139 100644 --- a/src/scan/worker.rs +++ b/src/scan/worker.rs @@ -32,7 +32,6 @@ use crate::{ const DEFAULT_SCAN_POLL_INTERVAL: Duration = Duration::from_millis(50); const DEFAULT_ALERT_PAGE_SIZE: u32 = 100; -const DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION: Duration = Duration::from_secs(5); const DEFAULT_AJAX_SPIDER_TIMEOUT_SECONDS: u64 = 60 * 60; const DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD: Duration = Duration::from_secs(60); const DEFAULT_PHASE_STOP_STATUS_CHANGE_TIMEOUT: Duration = Duration::from_secs(60); @@ -43,7 +42,6 @@ pub struct ScanRuntimeConfig { pub alert_poll_interval: Duration, pub scan_poll_interval: Duration, pub alert_page_size: u32, - pub passive_scan_placeholder_duration: Duration, pub ajax_spider_timeout_grace_period: Duration, pub phase_stop_status_change_timeout: Duration, pub stop_grace_period: Duration, @@ -60,7 +58,6 @@ impl Default for ScanRuntimeConfig { alert_poll_interval: Duration::from_secs(10), scan_poll_interval: DEFAULT_SCAN_POLL_INTERVAL, alert_page_size: DEFAULT_ALERT_PAGE_SIZE, - passive_scan_placeholder_duration: DEFAULT_PASSIVE_SCAN_PLACEHOLDER_DURATION, ajax_spider_timeout_grace_period: DEFAULT_AJAX_SPIDER_TIMEOUT_GRACE_PERIOD, phase_stop_status_change_timeout: DEFAULT_PHASE_STOP_STATUS_CHANGE_TIMEOUT, stop_grace_period: Duration::from_secs(300), @@ -471,19 +468,79 @@ impl ScanWorker { progress.mark_passive_scan_running(index); self.persist_progress(scan_id, progress).await?; - let start = Instant::now(); - while start.elapsed() < self.config.passive_scan_placeholder_duration { + let initial_records = self.zap_client.get_passive_scan_records_to_scan().await?; + progress.set_passive_scan_records(index, initial_records, initial_records); + + if initial_records == 0 { + progress.mark_passive_scan_done(index); + self.persist_progress(scan_id, progress).await?; + return Ok(ScanExecutionControl::Continue); + } + + self.persist_progress(scan_id, progress).await?; + + let mut min_records_seen = initial_records; + + loop { if self.stop_requested(scan_id).await? { self.handle_stop_request(scan_id, Some(context_name), RunningStage::PassiveScan) .await?; return Ok(ScanExecutionControl::StopExecution); } + + let current_records = self.zap_client.get_passive_scan_records_to_scan().await?; + + let previous_current = progress.targets[index].passive_scan_current_records; + let mut should_persist = false; + + if previous_current != Some(current_records) { + progress.set_passive_scan_records(index, initial_records, current_records); + should_persist = true; + } + + if current_records > min_records_seen { + debug!( + scan_id, + target_index = index, + previous_records = min_records_seen, + current_records, + "recordsToScan increased during passive scan; keeping monotonic progress" + ); + } else if current_records < min_records_seen { + min_records_seen = current_records; + + let previous_percentage = progress.targets[index].passive_scan_percentage; + let percentage = + Self::calculate_passive_scan_percentage(initial_records, current_records); + progress.update_passive_scan(index, percentage); + + if progress.targets[index].passive_scan_percentage != previous_percentage { + should_persist = true; + } + } + + if current_records == 0 { + progress.mark_passive_scan_done(index); + self.persist_progress(scan_id, progress).await?; + return Ok(ScanExecutionControl::Continue); + } + + if should_persist { + self.persist_progress(scan_id, progress).await?; + } + sleep(self.config.scan_poll_interval).await; } + } - progress.mark_passive_scan_done(index); - self.persist_progress(scan_id, progress).await?; - Ok(ScanExecutionControl::Continue) + fn calculate_passive_scan_percentage(initial_records: u64, current_records: u64) -> i32 { + if initial_records == 0 { + return 100; + } + + let current_effective = current_records.min(initial_records); + let processed = initial_records.saturating_sub(current_effective); + ((processed as f64 / initial_records as f64) * 100.0).floor() as i32 } async fn run_active_scan_phase( diff --git a/src/scan/worker_tests.rs b/src/scan/worker_tests.rs index b8b9533..f463cdf 100644 --- a/src/scan/worker_tests.rs +++ b/src/scan/worker_tests.rs @@ -2,11 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tracing_test::traced_test; use wiremock::{ - Mock, MockServer, ResponseTemplate, + Mock, MockServer, Request, Respond, ResponseTemplate, matchers::{body_string_contains, method, path}, }; @@ -276,6 +277,73 @@ async fn mount_alerts_page(server: &MockServer, start: u64, body: &'static str) .await; } +async fn mount_pscan_records_to_scan(server: &MockServer, records_to_scan: &'static str) { + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + format!(r#"{{"recordsToScan":"{records_to_scan}"}}"#), + "application/json", + )) + .mount(server) + .await; +} + +async fn mount_pscan_records_to_scan_with_expect( + server: &MockServer, + records_to_scan: &'static str, + expected_calls: u64, +) { + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + format!(r#"{{"recordsToScan":"{records_to_scan}"}}"#), + "application/json", + )) + .expect(expected_calls) + .mount(server) + .await; +} + +#[derive(Debug)] +struct PassiveRecordsSequenceResponder { + sequence: Vec<&'static str>, + fallback: &'static str, + cursor: AtomicUsize, +} + +impl PassiveRecordsSequenceResponder { + fn new(sequence: Vec<&'static str>, fallback: &'static str) -> Self { + Self { + sequence, + fallback, + cursor: AtomicUsize::new(0), + } + } +} + +impl Respond for PassiveRecordsSequenceResponder { + fn respond(&self, _request: &Request) -> ResponseTemplate { + let index = self.cursor.fetch_add(1, Ordering::SeqCst); + let value = self.sequence.get(index).copied().unwrap_or(self.fallback); + ResponseTemplate::new(200).set_body_raw( + format!(r#"{{"recordsToScan":"{value}"}}"#), + "application/json", + ) + } +} + +async fn mount_pscan_records_to_scan_sequence( + server: &MockServer, + sequence: Vec<&'static str>, + fallback: &'static str, +) { + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .respond_with(PassiveRecordsSequenceResponder::new(sequence, fallback)) + .mount(server) + .await; +} + async fn mock_zap_server() -> MockServer { let server = MockServer::start().await; @@ -294,6 +362,7 @@ async fn mock_zap_server() -> MockServer { ) .await; mount_alerts_page(&server, 1, r#"{"alerts":[]}"#).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -310,6 +379,7 @@ async fn mock_zap_server_safe_mode_without_active_scan_requests() -> MockServer mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -327,6 +397,7 @@ async fn mock_zap_server_for_ajax_spider_timeout_enforcement() -> MockServer { mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -344,6 +415,7 @@ async fn mock_zap_server_for_unlimited_ajax_spider_timeout() -> MockServer { mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -360,6 +432,7 @@ async fn mock_zap_server_for_default_ajax_spider_timeout() -> MockServer { mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -377,6 +450,7 @@ async fn mock_zap_server_for_spider_timeout_grace_stop_request() -> MockServer { mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -394,6 +468,7 @@ async fn mock_zap_server_for_spider_stop_status_change_timeout() -> MockServer { mount_ascan_scan_ok_with_expect(&server, 0).await; mount_ascan_status_with_expect(&server, 200, r#"{"status":"100"}"#, 0).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -409,6 +484,7 @@ async fn mock_zap_server_with_active_status_error() -> MockServer { mount_ajax_spider_status(&server, "stopped").await; mount_ascan_scan_ok(&server).await; mount_ascan_status(&server, 500, r#"{"code":"internal"}"#).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -431,6 +507,7 @@ async fn mock_zap_server_with_remove_context_error() -> MockServer { ) .await; mount_alerts_page(&server, 1, r#"{"alerts":[]}"#).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 500, r#"{"code":"internal"}"#).await; server @@ -448,6 +525,7 @@ async fn mock_zap_server_for_running_stop_in_active_scan() -> MockServer { mount_ascan_status(&server, 200, r#"{"status":"10"}"#).await; mount_ascan_stop(&server, 200, 1).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -463,6 +541,7 @@ async fn mock_zap_server_for_running_stop_in_spider() -> MockServer { mount_ajax_spider_status(&server, "running").await; mount_ajax_spider_stop_ok_with_expect(&server, 1).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -480,6 +559,7 @@ async fn mock_zap_server_for_running_stop_in_active_stage_with_stop_failure() -> mount_ascan_status(&server, 200, r#"{"status":"10"}"#).await; mount_ascan_stop(&server, 500, 1).await; mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; server @@ -501,6 +581,84 @@ async fn mock_zap_server_for_forced_stop_timeout() -> MockServer { r#"{"status":"10"}"#, ) .await; + mount_alerts_empty(&server).await; + mount_pscan_records_to_scan(&server, "0").await; + mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; + + server +} + +async fn mock_zap_server_for_running_stop_in_passive_scan() -> MockServer { + let server = MockServer::start().await; + + mount_context_new_ok(&server).await; + mount_context_include_ok(&server).await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + mount_ajax_spider_scan_ok(&server).await; + mount_ajax_spider_status(&server, "stopped").await; + mount_ascan_scan_ok(&server).await; + mount_ascan_status(&server, 200, r#"{"status":"100"}"#).await; + mount_pscan_records_to_scan(&server, "10").await; + mount_alerts_empty(&server).await; + mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; + + server +} + +async fn mock_zap_server_for_passive_records_progression() -> MockServer { + let server = MockServer::start().await; + + mount_context_new_ok(&server).await; + mount_context_include_ok(&server).await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + mount_ajax_spider_scan_ok(&server).await; + mount_ajax_spider_status(&server, "stopped").await; + mount_ascan_scan_ok(&server).await; + mount_ascan_status(&server, 200, r#"{"status":"100"}"#).await; + mount_pscan_records_to_scan_with_expect(&server, "0", 1).await; + mount_alerts_empty(&server).await; + mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; + + server +} + +async fn mock_zap_server_for_passive_records_without_decrease() -> MockServer { + let server = MockServer::start().await; + + mount_context_new_ok(&server).await; + mount_context_include_ok(&server).await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + mount_ajax_spider_scan_ok(&server).await; + mount_ajax_spider_status(&server, "stopped").await; + mount_ascan_scan_ok(&server).await; + mount_ascan_status(&server, 200, r#"{"status":"100"}"#).await; + mount_pscan_records_to_scan(&server, "10").await; + mount_alerts_empty(&server).await; + mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; + + server +} + +async fn mock_zap_server_for_passive_records_temporary_increase() -> MockServer { + mock_zap_server_for_passive_records_sequence(vec!["10", "8", "9"], "9").await +} + +async fn mock_zap_server_for_passive_records_sequence( + sequence: Vec<&'static str>, + fallback: &'static str, +) -> MockServer { + let server = MockServer::start().await; + + mount_context_new_ok(&server).await; + mount_context_include_ok(&server).await; + mount_ajax_spider_set_option_max_duration_ok(&server).await; + mount_ajax_spider_scan_ok(&server).await; + mount_ajax_spider_status(&server, "stopped").await; + mount_ascan_scan_ok(&server).await; + mount_ascan_status(&server, 200, r#"{"status":"100"}"#).await; + + mount_pscan_records_to_scan_sequence(&server, sequence, fallback).await; + mount_alerts_empty(&server).await; mount_context_remove(&server, 200, r#"{"Result":"OK"}"#).await; @@ -548,6 +706,37 @@ async fn wait_for_passive_running(storage: &dyn ScanStorage, scan_id: &str) { panic!("scan did not reach passive running state"); } +async fn wait_for_passive_records_and_percentage( + storage: &dyn ScanStorage, + scan_id: &str, + expected_current_records: u64, + expected_percentage: i64, +) { + for _ in 0..200 { + let scan = storage.get_scan(scan_id).await.unwrap(); + let current_records = scan + .progress + .as_ref() + .and_then(|progress| progress.pointer("/targets/0/passive_scan_current_records")) + .and_then(serde_json::Value::as_u64); + let percentage = scan + .progress + .as_ref() + .and_then(|progress| progress.pointer("/targets/0/passive_scan_percentage")) + .and_then(serde_json::Value::as_i64); + + if current_records == Some(expected_current_records) + && percentage == Some(expected_percentage) + { + return; + } + + tokio::time::sleep(Duration::from_millis(20)).await; + } + + panic!("scan did not reach expected passive records/percentage state"); +} + async fn wait_for_request_path(server: &MockServer, expected_path: &str) { for _ in 0..200 { let seen = server @@ -581,7 +770,6 @@ async fn runtime_processes_requested_scan_to_succeeded_and_persists_alert_result alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -622,6 +810,282 @@ async fn runtime_processes_requested_scan_to_succeeded_and_persists_alert_result assert!(logs_contain("scan_queue_wait_seconds")); } +#[tokio::test] +async fn runtime_persists_passive_scan_raw_counts_and_completes_on_zero_records() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_passive_records_progression().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(1), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + let progress = scan.progress.expect("progress should be persisted"); + + assert_eq!( + progress + .pointer("/targets/0/passive_scan_initial_records") + .and_then(serde_json::Value::as_u64), + Some(0) + ); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_current_records") + .and_then(serde_json::Value::as_u64), + Some(0) + ); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_percentage") + .and_then(serde_json::Value::as_i64), + Some(100) + ); +} + +#[tokio::test] +async fn runtime_keeps_passive_scan_percentage_at_zero_without_records_decrease() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_passive_records_without_decrease().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(1), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(5), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 10, 0).await; + + // Passive records remain constant above zero, so passive percentage must stay at 0. + let scan = storage.get_scan(&scan_id).await.unwrap(); + let progress = scan.progress.expect("progress should be persisted"); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_initial_records") + .and_then(serde_json::Value::as_u64), + Some(10) + ); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_current_records") + .and_then(serde_json::Value::as_u64), + Some(10) + ); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_percentage") + .and_then(serde_json::Value::as_i64), + Some(0) + ); + + service.stop_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Stopped).await; +} + +#[tokio::test] +async fn runtime_keeps_passive_percentage_monotonic_when_records_temporarily_increase() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_passive_records_temporary_increase().await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(1), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(5), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + + // After 10 -> 8 -> 9, percentage must remain 20 (monotonic) despite the increase. + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 9, 20).await; + + service.stop_scan(&scan_id).await.unwrap(); + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Stopped).await; +} + +#[tokio::test] +async fn runtime_keeps_passive_percentage_stable_during_plateau_and_completes_on_zero() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = + mock_zap_server_for_passive_records_sequence(vec!["10", "8", "8", "8", "8", "8", "0"], "0") + .await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + + // 10 -> 8 sets 20%; repeated 8 values must keep percentage stable at 20. + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 8, 20).await; + + // Sequence eventually reaches zero and passive phase must complete. + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + let progress = scan.progress.expect("progress should be persisted"); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_current_records") + .and_then(serde_json::Value::as_u64), + Some(0) + ); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_percentage") + .and_then(serde_json::Value::as_i64), + Some(100) + ); +} + +#[tokio::test] +async fn runtime_keeps_passive_percentage_monotonic_across_multiple_temporary_increases() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = mock_zap_server_for_passive_records_sequence( + vec!["20", "15", "17", "14", "16", "16", "16", "0"], + "0", + ) + .await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + + // 20 -> 15 sets 25%; 17 is ignored; 14 sets 30%; 16 must keep 30%. + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 16, 30).await; + + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; + + let scan = storage.get_scan(&scan_id).await.unwrap(); + let progress = scan.progress.expect("progress should be persisted"); + assert_eq!( + progress + .pointer("/targets/0/passive_scan_percentage") + .and_then(serde_json::Value::as_i64), + Some(100) + ); +} + +#[tokio::test] +async fn runtime_ignores_records_increase_above_initial_until_a_lower_value_appears() { + let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); + let server = + mock_zap_server_for_passive_records_sequence(vec!["10", "12", "12", "12", "9", "0"], "0") + .await; + let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); + let runtime = start_scan_runtime( + storage.clone(), + zap_client, + ScanRuntimeConfig { + worker_count: 1, + alert_poll_interval: Duration::from_millis(1), + scan_poll_interval: Duration::from_millis(20), + alert_page_size: 100, + stop_grace_period: Duration::from_secs(300), + ..ScanRuntimeConfig::default() + }, + ); + let service = DefaultScanService::new(storage.clone(), runtime); + + let scan_id = service + .create_scan(make_request("https://example.test")) + .await + .unwrap(); + + service.start_scan(&scan_id).await.unwrap(); + wait_for_running(storage.as_ref(), &scan_id).await; + wait_for_passive_running(storage.as_ref(), &scan_id).await; + + // 10 -> 12 must not reduce below 0%; once 9 appears, progress advances to 10%. + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 12, 0).await; + wait_for_passive_records_and_percentage(storage.as_ref(), &scan_id, 9, 10).await; + + wait_for_status(storage.as_ref(), &scan_id, ScanStatus::Succeeded).await; +} + #[traced_test] #[tokio::test] async fn runtime_skips_active_scan_when_scan_mode_is_safe() { @@ -636,7 +1100,6 @@ async fn runtime_skips_active_scan_when_scan_mode_is_safe() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -670,7 +1133,6 @@ async fn runtime_sets_ajax_spider_timeout_option_and_continues_scan_flow() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(20), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -705,7 +1167,6 @@ async fn runtime_treats_zero_ajax_spider_timeout_as_unlimited() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(20), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -740,7 +1201,6 @@ async fn runtime_applies_default_ajax_spider_timeout_when_preference_is_omitted( alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(20), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -772,7 +1232,6 @@ async fn runtime_stops_ajax_spider_when_timeout_plus_grace_period_is_exceeded() alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(20), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), ajax_spider_timeout_grace_period: Duration::from_millis(50), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() @@ -812,7 +1271,6 @@ async fn runtime_continues_when_ajax_spider_status_does_not_change_after_local_s alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(20), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), ajax_spider_timeout_grace_period: Duration::from_millis(50), phase_stop_status_change_timeout: Duration::from_millis(80), stop_grace_period: Duration::from_secs(5), @@ -849,7 +1307,6 @@ async fn runtime_transitions_running_scan_to_failed_on_worker_error() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -881,7 +1338,6 @@ async fn runtime_keeps_succeeded_status_when_context_cleanup_fails() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(300), ..ScanRuntimeConfig::default() }, @@ -956,7 +1412,6 @@ async fn runtime_stop_running_scan_in_active_stage_transitions_to_stopped_and_cl alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -994,7 +1449,6 @@ async fn runtime_stop_running_scan_in_spider_stage_transitions_to_stopped_and_cl alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -1022,7 +1476,7 @@ async fn runtime_stop_running_scan_in_spider_stage_transitions_to_stopped_and_cl async fn runtime_stop_running_scan_in_passive_stage_transitions_to_stopped_and_clears_stop_requested() { let (storage, _temp_dir) = temporary_sqlite_storage().await.unwrap(); - let server = mock_zap_server().await; + let server = mock_zap_server_for_running_stop_in_passive_scan().await; let zap_client = ZapClient::new(server.uri(), "test-api-key".to_string()).unwrap(); let runtime = start_scan_runtime( storage.clone(), @@ -1032,7 +1486,6 @@ async fn runtime_stop_running_scan_in_passive_stage_transitions_to_stopped_and_c alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_secs(2), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -1069,7 +1522,6 @@ async fn runtime_stop_running_scan_fails_when_zap_stop_fails_non_transiently() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_secs(5), ..ScanRuntimeConfig::default() }, @@ -1105,7 +1557,6 @@ async fn runtime_forces_failed_when_stop_grace_period_expires() { alert_poll_interval: Duration::from_millis(1), scan_poll_interval: Duration::from_millis(1), alert_page_size: 100, - passive_scan_placeholder_duration: Duration::from_millis(1), stop_grace_period: Duration::from_millis(50), ..ScanRuntimeConfig::default() }, diff --git a/src/zapclient/mod.rs b/src/zapclient/mod.rs index 1e71601..ef44a61 100644 --- a/src/zapclient/mod.rs +++ b/src/zapclient/mod.rs @@ -8,6 +8,7 @@ pub mod ajaxspider; pub mod alert; pub mod ascan; pub mod context; +pub mod pscan; use crate::config::settings::Settings; use reqwest::StatusCode; @@ -339,6 +340,22 @@ impl RetryingZapClient { .await } + /// Get the number of records left for passive scanning. + pub async fn get_passive_scan_records_to_scan(&self) -> Result { + let inner = self.inner.clone(); + + crate::scan::retry::with_retry( + "zap.get_passive_scan_records_to_scan", + move || { + let inner = inner.clone(); + async move { inner.get_passive_scan_records_to_scan().await } + }, + self.max_retries, + self.max_delay, + ) + .await + } + /// Remove a context by name. This operation is **not retried** as cleanup /// operations are typically best-effort. pub async fn remove_context(&self, context_name: &str) -> Result<(), ZapClientError> { diff --git a/src/zapclient/pscan.rs b/src/zapclient/pscan.rs new file mode 100644 index 0000000..de7ccd0 --- /dev/null +++ b/src/zapclient/pscan.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ZapClient, ZapClientError}; +use serde::Deserialize; + +/// Response payload returned by the ZAP `pscan/view/recordsToScan` endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct PscanRecordsToScanResponse { + #[serde(rename = "recordsToScan")] + records_to_scan: String, +} + +impl ZapClient { + /// Get the number of records left to process in the passive scanner. + pub async fn get_passive_scan_records_to_scan(&self) -> Result { + let endpoint = self.endpoint_url("JSON/pscan/view/recordsToScan"); + let response = self + .http_client + .post(endpoint) + .form(&[("apikey", self.api_key.as_str())]) + .send() + .await?; + + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + return Err(ZapClientError::UnexpectedStatus { status, body }); + } + + let parsed_response = serde_json::from_str::(&body)?; + let records_to_scan = parsed_response + .records_to_scan + .parse::() + .map_err(|_| ZapClientError::UnexpectedContent { + field: "recordsToScan".to_string(), + content: parsed_response.records_to_scan.clone(), + })?; + + if records_to_scan < 0 { + return Err(ZapClientError::UnexpectedContent { + field: "recordsToScan".to_string(), + content: parsed_response.records_to_scan, + }); + } + + Ok(records_to_scan as u64) + } +} + +#[cfg(test)] +#[path = "pscan_tests.rs"] +mod pscan_tests; diff --git a/src/zapclient/pscan_tests.rs b/src/zapclient/pscan_tests.rs new file mode 100644 index 0000000..0b908fa --- /dev/null +++ b/src/zapclient/pscan_tests.rs @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use reqwest::StatusCode; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wiremock::{ + Mock, MockServer, Request, Respond, ResponseTemplate, + matchers::{body_string_contains, method, path}, +}; + +use super::{ZapClient, ZapClientError}; + +const API_KEY: &str = "test-api-key"; + +async fn mount_records_to_scan(server: &MockServer, response: ResponseTemplate) { + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .respond_with(response) + .expect(1) + .mount(server) + .await; +} + +#[derive(Debug)] +struct RecordsToScanBodySequenceResponder { + sequence: Vec<&'static str>, + fallback: &'static str, + cursor: AtomicUsize, +} + +impl RecordsToScanBodySequenceResponder { + fn new(sequence: Vec<&'static str>, fallback: &'static str) -> Self { + Self { + sequence, + fallback, + cursor: AtomicUsize::new(0), + } + } +} + +impl Respond for RecordsToScanBodySequenceResponder { + fn respond(&self, _request: &Request) -> ResponseTemplate { + let index = self.cursor.fetch_add(1, Ordering::SeqCst); + let body = self.sequence.get(index).copied().unwrap_or(self.fallback); + ResponseTemplate::new(200).set_body_string(body) + } +} + +async fn mount_records_to_scan_body_sequence( + server: &MockServer, + sequence: Vec<&'static str>, + fallback: &'static str, + expected_calls: u64, +) { + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .respond_with(RecordsToScanBodySequenceResponder::new(sequence, fallback)) + .expect(expected_calls) + .mount(server) + .await; +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_posts_to_zap_pscan_endpoint() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/JSON/pscan/view/recordsToScan")) + .and(body_string_contains(format!("apikey={API_KEY}"))) + .respond_with(ResponseTemplate::new(200).set_body_string("{\"recordsToScan\":\"42\"}")) + .expect(1) + .mount(&server) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let records_to_scan = client + .get_passive_scan_records_to_scan() + .await + .expect("recordsToScan should parse on success"); + + assert_eq!(records_to_scan, 42); +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_unexpected_status_on_http_error() { + let server = MockServer::start().await; + + mount_records_to_scan( + &server, + ResponseTemplate::new(503).set_body_string("zap unavailable"), + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("recordsToScan call should fail on non-success status"); + + match error { + ZapClientError::UnexpectedStatus { status, body } => { + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body, "zap unavailable"); + } + other => panic!("expected UnexpectedStatus error, got {other:?}"), + } +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_parse_error_for_invalid_schema() { + let server = MockServer::start().await; + + mount_records_to_scan( + &server, + ResponseTemplate::new(200).set_body_string("{\"records\":\"42\"}"), + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("recordsToScan call should fail when recordsToScan key is missing"); + + match error { + ZapClientError::ParseResponse(_) => {} + other => panic!("expected ParseResponse error, got {other:?}"), + } +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_unexpected_content_for_non_numeric_value() { + let server = MockServer::start().await; + + mount_records_to_scan( + &server, + ResponseTemplate::new(200).set_body_string("{\"recordsToScan\":\"abc\"}"), + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("recordsToScan call should fail for non-numeric content"); + + match error { + ZapClientError::UnexpectedContent { field, content } => { + assert_eq!(field, "recordsToScan"); + assert_eq!(content, "abc"); + } + other => panic!("expected UnexpectedContent error, got {other:?}"), + } +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_unexpected_content_for_negative_value() { + let server = MockServer::start().await; + + mount_records_to_scan( + &server, + ResponseTemplate::new(200).set_body_string("{\"recordsToScan\":\"-1\"}"), + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("recordsToScan call should fail for negative values"); + + match error { + ZapClientError::UnexpectedContent { field, content } => { + assert_eq!(field, "recordsToScan"); + assert_eq!(content, "-1"); + } + other => panic!("expected UnexpectedContent error, got {other:?}"), + } +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_values_in_sequence_order() { + let server = MockServer::start().await; + + mount_records_to_scan_body_sequence( + &server, + vec![ + r#"{"recordsToScan":"42"}"#, + r#"{"recordsToScan":"7"}"#, + r#"{"recordsToScan":"0"}"#, + ], + r#"{"recordsToScan":"0"}"#, + 3, + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let first = client + .get_passive_scan_records_to_scan() + .await + .expect("first sequence value should parse"); + let second = client + .get_passive_scan_records_to_scan() + .await + .expect("second sequence value should parse"); + let third = client + .get_passive_scan_records_to_scan() + .await + .expect("third sequence value should parse"); + + assert_eq!(first, 42); + assert_eq!(second, 7); + assert_eq!(third, 0); +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_returns_parse_error_after_valid_sequence_value() { + let server = MockServer::start().await; + + mount_records_to_scan_body_sequence( + &server, + vec![r#"{"recordsToScan":"10"}"#, r#"{"records":"10"}"#], + r#"{"records":"10"}"#, + 2, + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let first = client + .get_passive_scan_records_to_scan() + .await + .expect("first sequence value should parse"); + assert_eq!(first, 10); + + let error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("second sequence value should fail with schema error"); + + match error { + ZapClientError::ParseResponse(_) => {} + other => panic!("expected ParseResponse error, got {other:?}"), + } +} + +#[tokio::test] +async fn get_passive_scan_records_to_scan_surfaces_malformed_boundary_values_in_sequence() { + let server = MockServer::start().await; + + mount_records_to_scan_body_sequence( + &server, + vec![ + r#"{"recordsToScan":"1"}"#, + r#"{"recordsToScan":"abc"}"#, + r#"{"recordsToScan":"-1"}"#, + ], + r#"{"recordsToScan":"-1"}"#, + 3, + ) + .await; + + let client = + ZapClient::new(server.uri(), API_KEY.to_string()).expect("client should be constructed"); + + let first = client + .get_passive_scan_records_to_scan() + .await + .expect("first sequence value should parse"); + assert_eq!(first, 1); + + let non_numeric_error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("second sequence value should fail for non-numeric content"); + match non_numeric_error { + ZapClientError::UnexpectedContent { field, content } => { + assert_eq!(field, "recordsToScan"); + assert_eq!(content, "abc"); + } + other => panic!("expected UnexpectedContent error, got {other:?}"), + } + + let negative_error = client + .get_passive_scan_records_to_scan() + .await + .expect_err("third sequence value should fail for negative content"); + match negative_error { + ZapClientError::UnexpectedContent { field, content } => { + assert_eq!(field, "recordsToScan"); + assert_eq!(content, "-1"); + } + other => panic!("expected UnexpectedContent error, got {other:?}"), + } +}