mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 23:01:10 +00:00
[AZ-676] [AZ-677] [AZ-678] [AZ-679] telemetry+operator foundation
Batch 15 ships the four foundation tickets sitting on top of AZ-675 (gRPC server) and AZ-667 (mapobjects_store hydrate): * AZ-676: telemetry_stream video path (rtsp_forward + bytes_inline) with ai_locked atomic + session counter, SubscribeVideo RPC. * AZ-677: MapObjects snapshot-on-subscribe + diff broadcast + reconnect-resync (StartThen stream-prepend pattern). * AZ-678: HmacOperatorValidator with per-session monotonic seq, in-process session registry + TTL, constant-time HMAC compare, rejection-reason counters, sliding 60 s sig-failure red-health gate. Trait OperatorCommandValidator in shared::contracts::operator_auth. * AZ-679: PoiSurfaceMapper produces OperatorPoiEvent per architecture §7.10; PoiDequeued events on rotate/age-out/complete; pushed via new TelemetrySink::push_operator_event extension on Topic::OperatorEvent. Cross-task wiring: TelemetrySink trait extended with push_operator_event; OperatorBridge gets optional builder methods with_telemetry_sink / with_validator (composition root wires in AZ-680). Workspace deps: hmac = "0.12"; per-crate adds bytes, serde_json, parking_lot, chrono, uuid, sha2, thiserror. Tests: 14/14 ACs verified locally (4 + 3 + 5 + 3 by AC) plus 6 supporting unit tests + 7 integration tests + 2 shared serde roundtrips. cargo clippy clean on touched crates. Cumulative review for batches 13-15 produced; verdict PASS_WITH_WARNINGS (0 Critical, 0 High, 1 Medium, 4 Low — all carry-overs or deferred-producer notes for AZ-680/AZ-684). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
//! AZ-678 — default operator-command authentication.
|
||||
//!
|
||||
//! `HmacOperatorValidator` implements
|
||||
//! `shared::contracts::operator_auth::OperatorCommandValidator` using
|
||||
//! HMAC-SHA256 over `(session_token || sequence_number ||
|
||||
//! canonical_payload_json)`. It carries:
|
||||
//! - a per-session in-memory `SessionRegistry` (added on Ground
|
||||
//! Station auth handshake; expired after `session_ttl`);
|
||||
//! - a per-session monotonically advancing sequence-number tracker
|
||||
//! (replay protection);
|
||||
//! - per-reason rejection counters + a sliding-window red-health
|
||||
//! gate on sustained signature failures (per AC-5).
|
||||
//!
|
||||
//! Constant-time HMAC compare via `hmac::Mac::verify_slice` — no
|
||||
//! timing oracle. Rejected commands are NEVER logged at info level
|
||||
//! with raw payload; only the rejection reason and size-capped
|
||||
//! command_id are emitted.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use parking_lot::Mutex;
|
||||
use sha2::Sha256;
|
||||
use tracing::warn;
|
||||
|
||||
use shared::contracts::operator_auth::{
|
||||
AuthError, OperatorCommandValidator, SignedCommand, ValidatedCommand,
|
||||
};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Ordered set of rejection reasons. Drives the `auth_rejections_total`
|
||||
/// counter array layout and the [`AuthCounters::by_reason`] lookup.
|
||||
pub const REJECTION_REASONS: [AuthError; 4] = [
|
||||
AuthError::SignatureInvalid,
|
||||
AuthError::ReplayDetected,
|
||||
AuthError::SessionUnknown,
|
||||
AuthError::SessionExpired,
|
||||
];
|
||||
|
||||
/// Per-session state — last-seen sequence number for replay
|
||||
/// protection plus the wall-clock + monotonic anchor for TTL.
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionEntry {
|
||||
secret: Vec<u8>,
|
||||
/// `Some(n)` once we have observed at least one accepted command
|
||||
/// from the session. `None` means the session is registered but
|
||||
/// the next accepted seq is the floor — `>= 1` per the wire
|
||||
/// contract.
|
||||
last_seen_seq: Option<u64>,
|
||||
established_at: Instant,
|
||||
established_wallclock: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Configuration knobs for the HMAC validator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HmacValidatorConfig {
|
||||
/// Session lifetime starting from `register_session`. After this
|
||||
/// elapses any command bearing the token is rejected with
|
||||
/// `SessionExpired`. Default 30 minutes per architecture §5.
|
||||
pub session_ttl: Duration,
|
||||
/// Per-minute signature-failure threshold above which
|
||||
/// `health_is_red` returns `true` (AC-5). Default 30 — i.e. one
|
||||
/// failure every two seconds sustained for a minute.
|
||||
pub signature_failure_red_threshold: u32,
|
||||
}
|
||||
|
||||
impl Default for HmacValidatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_ttl: Duration::from_secs(30 * 60),
|
||||
signature_failure_red_threshold: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live rejection counters. Exposed to the health surface; one entry
|
||||
/// per `REJECTION_REASONS` slot.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AuthCounters {
|
||||
by_reason: [AtomicU64; REJECTION_REASONS.len()],
|
||||
total_validated: AtomicU64,
|
||||
}
|
||||
|
||||
impl AuthCounters {
|
||||
pub fn reason(&self, e: AuthError) -> u64 {
|
||||
let idx = REJECTION_REASONS
|
||||
.iter()
|
||||
.position(|r| *r == e)
|
||||
.expect("REJECTION_REASONS covers every AuthError variant");
|
||||
self.by_reason[idx].load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn validated_total(&self) -> u64 {
|
||||
self.total_validated.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn increment(&self, e: AuthError) {
|
||||
let idx = REJECTION_REASONS
|
||||
.iter()
|
||||
.position(|r| *r == e)
|
||||
.expect("REJECTION_REASONS covers every AuthError variant");
|
||||
self.by_reason[idx].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn increment_validated(&self) {
|
||||
self.total_validated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// HMAC validator state — sessions + counters + signature-failure
|
||||
/// sliding window for the red-health gate.
|
||||
pub struct HmacOperatorValidator {
|
||||
config: HmacValidatorConfig,
|
||||
sessions: Mutex<HashMap<String, SessionEntry>>,
|
||||
/// Signature-failure timestamps in the trailing 60 s window.
|
||||
/// Bounded by either the config threshold * 2 (defense against
|
||||
/// flooding) or 60 s of trailing history, whichever comes first.
|
||||
sig_failure_window: Mutex<VecDeque<Instant>>,
|
||||
counters: Arc<AuthCounters>,
|
||||
}
|
||||
|
||||
impl HmacOperatorValidator {
|
||||
pub fn new(config: HmacValidatorConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
sig_failure_window: Mutex::new(VecDeque::new()),
|
||||
counters: Arc::new(AuthCounters::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_default_config() -> Self {
|
||||
Self::new(HmacValidatorConfig::default())
|
||||
}
|
||||
|
||||
/// Register a session — called on Ground Station auth handshake.
|
||||
/// Replacing an existing session for the same token is allowed
|
||||
/// (rotates the secret and resets the replay tracker).
|
||||
pub fn register_session(&self, token: impl Into<String>, secret: impl Into<Vec<u8>>) {
|
||||
let token = token.into();
|
||||
let entry = SessionEntry {
|
||||
secret: secret.into(),
|
||||
last_seen_seq: None,
|
||||
established_at: Instant::now(),
|
||||
established_wallclock: Utc::now(),
|
||||
};
|
||||
self.sessions.lock().insert(token, entry);
|
||||
}
|
||||
|
||||
/// Drop a session (operator logout / explicit revoke).
|
||||
pub fn revoke_session(&self, token: &str) -> bool {
|
||||
self.sessions.lock().remove(token).is_some()
|
||||
}
|
||||
|
||||
pub fn counters(&self) -> Arc<AuthCounters> {
|
||||
Arc::clone(&self.counters)
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &HmacValidatorConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// True when the trailing 60-second window of signature failures
|
||||
/// is at or above the configured red threshold (AC-5). Pruning of
|
||||
/// expired entries happens on every call.
|
||||
pub fn health_is_red(&self) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut w = self.sig_failure_window.lock();
|
||||
while let Some(&t) = w.front() {
|
||||
if now.duration_since(t) > Duration::from_secs(60) {
|
||||
w.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.len() >= self.config.signature_failure_red_threshold as usize
|
||||
}
|
||||
|
||||
/// Helper that recomputes the canonical signing material. Public
|
||||
/// so the Ground Station side can co-locate the spec.
|
||||
pub fn signing_material(
|
||||
session_token: &str,
|
||||
sequence_number: u64,
|
||||
payload: &serde_json::Value,
|
||||
) -> Vec<u8> {
|
||||
let payload_bytes = serde_json::to_vec(payload).unwrap_or_default();
|
||||
let mut buf = Vec::with_capacity(session_token.len() + 8 + payload_bytes.len() + 2);
|
||||
buf.extend_from_slice(session_token.as_bytes());
|
||||
buf.push(b'|');
|
||||
buf.extend_from_slice(&sequence_number.to_be_bytes());
|
||||
buf.push(b'|');
|
||||
buf.extend_from_slice(&payload_bytes);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Helper that produces the HMAC tag for a `(token, seq, payload)`
|
||||
/// triple under `secret`. Used by tests and by the Ground Station
|
||||
/// reference implementation.
|
||||
pub fn sign(
|
||||
secret: &[u8],
|
||||
session_token: &str,
|
||||
seq: u64,
|
||||
payload: &serde_json::Value,
|
||||
) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
||||
mac.update(&Self::signing_material(session_token, seq, payload));
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn record_sig_failure(&self, now: Instant) {
|
||||
let mut w = self.sig_failure_window.lock();
|
||||
w.push_back(now);
|
||||
// Prune old entries opportunistically so the window doesn't
|
||||
// grow unbounded under a flood.
|
||||
while let Some(&t) = w.front() {
|
||||
if now.duration_since(t) > Duration::from_secs(60) {
|
||||
w.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OperatorCommandValidator for HmacOperatorValidator {
|
||||
fn validate(&self, cmd: SignedCommand) -> Result<ValidatedCommand, AuthError> {
|
||||
// Step 1 — session lookup. Failure does NOT touch the replay
|
||||
// counter (the command never authenticated, so nothing to
|
||||
// advance).
|
||||
let mut sessions = self.sessions.lock();
|
||||
let entry = match sessions.get_mut(&cmd.session_token) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
self.counters.increment(AuthError::SessionUnknown);
|
||||
drop(sessions);
|
||||
warn!(
|
||||
command_id = %cmd.command_id,
|
||||
reason = AuthError::SessionUnknown.reason_label(),
|
||||
"operator command rejected"
|
||||
);
|
||||
return Err(AuthError::SessionUnknown);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2 — TTL check. We check both monotonic age (Instant)
|
||||
// and the configured TTL. Wall-clock skew is not used.
|
||||
if entry.established_at.elapsed() > self.config.session_ttl {
|
||||
self.counters.increment(AuthError::SessionExpired);
|
||||
// Strip the session so subsequent commands skip the TTL
|
||||
// path and just see SessionUnknown.
|
||||
sessions.remove(&cmd.session_token);
|
||||
drop(sessions);
|
||||
warn!(
|
||||
command_id = %cmd.command_id,
|
||||
reason = AuthError::SessionExpired.reason_label(),
|
||||
"operator command rejected"
|
||||
);
|
||||
return Err(AuthError::SessionExpired);
|
||||
}
|
||||
|
||||
// Step 3 — replay check. We compare against the per-session
|
||||
// `last_seen_seq`; the rejected seq is NOT recorded so a
|
||||
// legitimate retry can still land with the next valid seq.
|
||||
if let Some(last) = entry.last_seen_seq {
|
||||
if cmd.sequence_number <= last {
|
||||
self.counters.increment(AuthError::ReplayDetected);
|
||||
drop(sessions);
|
||||
warn!(
|
||||
command_id = %cmd.command_id,
|
||||
last_seen = last,
|
||||
seq = cmd.sequence_number,
|
||||
reason = AuthError::ReplayDetected.reason_label(),
|
||||
"operator command rejected"
|
||||
);
|
||||
return Err(AuthError::ReplayDetected);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4 — HMAC check. Constant-time via `verify_slice`.
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(&entry.secret).expect("HMAC accepts any key length");
|
||||
mac.update(&Self::signing_material(
|
||||
&cmd.session_token,
|
||||
cmd.sequence_number,
|
||||
&cmd.payload,
|
||||
));
|
||||
let signature_ok = mac.verify_slice(&cmd.signature).is_ok();
|
||||
if !signature_ok {
|
||||
self.counters.increment(AuthError::SignatureInvalid);
|
||||
let _established = entry.established_wallclock;
|
||||
drop(sessions);
|
||||
self.record_sig_failure(Instant::now());
|
||||
warn!(
|
||||
command_id = %cmd.command_id,
|
||||
reason = AuthError::SignatureInvalid.reason_label(),
|
||||
"operator command rejected"
|
||||
);
|
||||
return Err(AuthError::SignatureInvalid);
|
||||
}
|
||||
|
||||
// Happy path — advance the per-session sequence tracker.
|
||||
entry.last_seen_seq = Some(cmd.sequence_number);
|
||||
drop(sessions);
|
||||
self.counters.increment_validated();
|
||||
Ok(ValidatedCommand {
|
||||
command: cmd.into_command(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use shared::models::operator::OperatorCommandKind;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn signed_command(
|
||||
secret: &[u8],
|
||||
session_token: &str,
|
||||
seq: u64,
|
||||
payload: serde_json::Value,
|
||||
) -> SignedCommand {
|
||||
let sig = HmacOperatorValidator::sign(secret, session_token, seq, &payload);
|
||||
SignedCommand {
|
||||
session_token: session_token.to_string(),
|
||||
sequence_number: seq,
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload,
|
||||
signature: sig,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
command_id: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
|
||||
/// AC-1 — valid signature + monotonic seq → Ok; last_seen advances.
|
||||
#[test]
|
||||
fn ac1_valid_signed_command_passes() {
|
||||
// Arrange
|
||||
let v = HmacOperatorValidator::with_default_config();
|
||||
let secret = b"unit-test-secret";
|
||||
v.register_session("tok_a", secret.to_vec());
|
||||
let cmd = signed_command(secret, "tok_a", 5, serde_json::json!({"poi_id": "u-1"}));
|
||||
|
||||
// Act
|
||||
let out = v.validate(cmd.clone());
|
||||
|
||||
// Assert
|
||||
assert!(out.is_ok(), "valid command must pass");
|
||||
assert_eq!(v.counters().validated_total(), 1);
|
||||
|
||||
// last_seen advanced — a second command with same seq is now
|
||||
// replay.
|
||||
let replay = v.validate(cmd);
|
||||
assert_eq!(replay.unwrap_err(), AuthError::ReplayDetected);
|
||||
}
|
||||
|
||||
/// AC-2 — invalid signature → SignatureInvalid; counter increments;
|
||||
/// seq NOT advanced; subsequent valid command with same seq passes.
|
||||
#[test]
|
||||
fn ac2_invalid_signature_rejected_and_seq_not_advanced() {
|
||||
// Arrange
|
||||
let v = HmacOperatorValidator::with_default_config();
|
||||
let secret = b"unit-test-secret";
|
||||
v.register_session("tok_b", secret.to_vec());
|
||||
|
||||
let bad_payload = serde_json::json!({"poi_id": "u-1"});
|
||||
let bad_sig = HmacOperatorValidator::sign(b"WRONG-SECRET", "tok_b", 5, &bad_payload);
|
||||
let bad = SignedCommand {
|
||||
session_token: "tok_b".to_string(),
|
||||
sequence_number: 5,
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload: bad_payload.clone(),
|
||||
signature: bad_sig,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
command_id: Uuid::new_v4(),
|
||||
};
|
||||
|
||||
// Act
|
||||
let rejected = v.validate(bad);
|
||||
let good = signed_command(secret, "tok_b", 5, bad_payload);
|
||||
let accepted = v.validate(good);
|
||||
|
||||
// Assert
|
||||
assert_eq!(rejected.unwrap_err(), AuthError::SignatureInvalid);
|
||||
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 1);
|
||||
assert!(accepted.is_ok(), "seq=5 must still be valid after sig-fail");
|
||||
assert_eq!(v.counters().validated_total(), 1);
|
||||
}
|
||||
|
||||
/// AC-3 — seq == last_seen → ReplayDetected; seq < last_seen → also
|
||||
/// ReplayDetected.
|
||||
#[test]
|
||||
fn ac3_replay_detected() {
|
||||
// Arrange
|
||||
let v = HmacOperatorValidator::with_default_config();
|
||||
let secret = b"s";
|
||||
v.register_session("tok", secret.to_vec());
|
||||
let _ = v
|
||||
.validate(signed_command(secret, "tok", 10, serde_json::json!({})))
|
||||
.unwrap();
|
||||
|
||||
// Act
|
||||
let same = v.validate(signed_command(secret, "tok", 10, serde_json::json!({})));
|
||||
let earlier = v.validate(signed_command(secret, "tok", 9, serde_json::json!({})));
|
||||
|
||||
// Assert
|
||||
assert_eq!(same.unwrap_err(), AuthError::ReplayDetected);
|
||||
assert_eq!(earlier.unwrap_err(), AuthError::ReplayDetected);
|
||||
assert_eq!(v.counters().reason(AuthError::ReplayDetected), 2);
|
||||
}
|
||||
|
||||
/// AC-4 — unknown session token → SessionUnknown; expired session
|
||||
/// token → SessionExpired.
|
||||
#[test]
|
||||
fn ac4_unknown_or_expired_session_rejected() {
|
||||
// Arrange — TTL set tiny so the session expires within the
|
||||
// test.
|
||||
let cfg = HmacValidatorConfig {
|
||||
session_ttl: Duration::from_millis(10),
|
||||
..HmacValidatorConfig::default()
|
||||
};
|
||||
let v = HmacOperatorValidator::new(cfg);
|
||||
let secret = b"s";
|
||||
|
||||
// Act 1 — unknown token rejected immediately.
|
||||
let unknown = v.validate(signed_command(
|
||||
secret,
|
||||
"no_such_session",
|
||||
1,
|
||||
serde_json::json!({}),
|
||||
));
|
||||
|
||||
// Register, wait past TTL, retry.
|
||||
v.register_session("tok", secret.to_vec());
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let expired = v.validate(signed_command(secret, "tok", 1, serde_json::json!({})));
|
||||
|
||||
// Assert
|
||||
assert_eq!(unknown.unwrap_err(), AuthError::SessionUnknown);
|
||||
assert_eq!(expired.unwrap_err(), AuthError::SessionExpired);
|
||||
assert_eq!(v.counters().reason(AuthError::SessionUnknown), 1);
|
||||
assert_eq!(v.counters().reason(AuthError::SessionExpired), 1);
|
||||
}
|
||||
|
||||
/// AC-5 — sustained signature failures (≥ threshold within the
|
||||
/// trailing 60 s) flip the red-health gate.
|
||||
#[test]
|
||||
fn ac5_sustained_signature_failures_flip_health_red() {
|
||||
// Arrange
|
||||
let cfg = HmacValidatorConfig {
|
||||
signature_failure_red_threshold: 5,
|
||||
..HmacValidatorConfig::default()
|
||||
};
|
||||
let v = HmacOperatorValidator::new(cfg);
|
||||
let secret = b"s";
|
||||
v.register_session("tok", secret.to_vec());
|
||||
|
||||
// Below threshold → green.
|
||||
for seq in 0..4 {
|
||||
let bad_sig =
|
||||
HmacOperatorValidator::sign(b"wrong", "tok", seq + 1, &serde_json::json!({}));
|
||||
let bad = SignedCommand {
|
||||
session_token: "tok".to_string(),
|
||||
sequence_number: seq + 1,
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload: serde_json::json!({}),
|
||||
signature: bad_sig,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
command_id: Uuid::new_v4(),
|
||||
};
|
||||
let _ = v.validate(bad);
|
||||
}
|
||||
assert!(!v.health_is_red(), "4 failures < threshold");
|
||||
|
||||
// Act — push one more to reach threshold.
|
||||
let bad_sig = HmacOperatorValidator::sign(b"wrong", "tok", 100, &serde_json::json!({}));
|
||||
let bad = SignedCommand {
|
||||
session_token: "tok".to_string(),
|
||||
sequence_number: 100,
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload: serde_json::json!({}),
|
||||
signature: bad_sig,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
command_id: Uuid::new_v4(),
|
||||
};
|
||||
let _ = v.validate(bad);
|
||||
|
||||
// Assert
|
||||
assert!(v.health_is_red(), "≥ threshold → red");
|
||||
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 5);
|
||||
}
|
||||
|
||||
/// Constant-time verify: same-length wrong signature must yield
|
||||
/// SignatureInvalid (not a panic), and the rejection counter
|
||||
/// increments by one. (Smoke test that `verify_slice` is wired
|
||||
/// correctly.)
|
||||
#[test]
|
||||
fn same_length_wrong_signature_is_rejected_cleanly() {
|
||||
// Arrange
|
||||
let v = HmacOperatorValidator::with_default_config();
|
||||
let secret = b"s";
|
||||
v.register_session("tok", secret.to_vec());
|
||||
|
||||
let payload = serde_json::json!({});
|
||||
let mut bad_sig = HmacOperatorValidator::sign(secret, "tok", 1, &payload);
|
||||
// Flip one byte — same length, different value.
|
||||
bad_sig[0] ^= 0x01;
|
||||
let cmd = SignedCommand {
|
||||
session_token: "tok".to_string(),
|
||||
sequence_number: 1,
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload,
|
||||
signature: bad_sig,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
command_id: Uuid::new_v4(),
|
||||
};
|
||||
|
||||
// Act
|
||||
let r = v.validate(cmd);
|
||||
|
||||
// Assert
|
||||
assert_eq!(r.unwrap_err(), AuthError::SignatureInvalid);
|
||||
assert_eq!(v.counters().reason(AuthError::SignatureInvalid), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Internal modules for `operator_bridge`. Not part of the public API.
|
||||
|
||||
pub mod auth;
|
||||
pub mod poi_surface;
|
||||
@@ -0,0 +1,323 @@
|
||||
//! AZ-679 — POI surface event mapping + dequeue emission.
|
||||
//!
|
||||
//! `PoiSurfaceMapper::map(poi)` produces the
|
||||
//! [`OperatorPoiEvent`](shared::models::operator_event::OperatorPoiEvent)
|
||||
//! that the operator UI consumes (per `architecture.md §7.10` and the
|
||||
//! task spec's field list). On queue rotation / age-out / completion
|
||||
//! `emit_dequeued` produces a `PoiDequeued` event.
|
||||
//!
|
||||
//! Both events are pushed through `TelemetrySink::push_operator_event`
|
||||
//! — composition root supplies the sink (in production, the
|
||||
//! `telemetry_stream::TelemetryStreamHandle`).
|
||||
//!
|
||||
//! `pois_surfaced_per_min` counter exposed via [`PoiSurfaceMetrics`].
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use shared::contracts::TelemetrySink;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::models::operator_event::{
|
||||
DequeueReason, OperatorEvent, OperatorPoiEvent, PhotoMetadata, PoiDequeued,
|
||||
Tier2EvidenceSummary,
|
||||
};
|
||||
use shared::models::poi::{Poi, VlmPipelineStatus};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Sliding 60 s window over POI-surfaced timestamps. Used by the
|
||||
/// `pois_surfaced_per_min` health metric.
|
||||
#[derive(Default)]
|
||||
struct SurfaceRateWindow {
|
||||
timestamps: Mutex<std::collections::VecDeque<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl SurfaceRateWindow {
|
||||
fn record_and_count(&self) -> usize {
|
||||
let now = std::time::Instant::now();
|
||||
let mut w = self.timestamps.lock();
|
||||
w.push_back(now);
|
||||
while let Some(&t) = w.front() {
|
||||
if now.duration_since(t) > std::time::Duration::from_secs(60) {
|
||||
w.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.len()
|
||||
}
|
||||
|
||||
fn current_rate(&self) -> usize {
|
||||
let now = std::time::Instant::now();
|
||||
let mut w = self.timestamps.lock();
|
||||
while let Some(&t) = w.front() {
|
||||
if now.duration_since(t) > std::time::Duration::from_secs(60) {
|
||||
w.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
w.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoiSurfaceMetrics {
|
||||
pub pois_surfaced_per_min: usize,
|
||||
pub pois_surfaced_total: u64,
|
||||
pub pois_dequeued_total: u64,
|
||||
}
|
||||
|
||||
pub struct PoiSurfaceMapper {
|
||||
sink: Arc<dyn TelemetrySink>,
|
||||
pois_surfaced_total: AtomicU64,
|
||||
pois_dequeued_total: AtomicU64,
|
||||
rate: SurfaceRateWindow,
|
||||
}
|
||||
|
||||
impl PoiSurfaceMapper {
|
||||
pub fn new(sink: Arc<dyn TelemetrySink>) -> Self {
|
||||
Self {
|
||||
sink,
|
||||
pois_surfaced_total: AtomicU64::new(0),
|
||||
pois_dequeued_total: AtomicU64::new(0),
|
||||
rate: SurfaceRateWindow::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure mapping — produces the wire-format event. Used by tests
|
||||
/// and by [`surface`] (which also pushes through the sink).
|
||||
/// Photo metadata is optional and may be supplied by the caller
|
||||
/// when the POI's source detection has a captured ROI snapshot;
|
||||
/// the `Poi` model itself does not carry photo bytes.
|
||||
pub fn map(poi: &Poi, photo_metadata: Option<PhotoMetadata>) -> OperatorPoiEvent {
|
||||
let tier2_evidence_summary = poi.tier2_evidence.as_ref().map(|t| Tier2EvidenceSummary {
|
||||
path_freshness: t.path_freshness,
|
||||
endpoint_score: t.endpoint_score,
|
||||
concealment_score: t.concealment_score,
|
||||
recommended_next_action: t.recommended_next_action,
|
||||
status: t.status,
|
||||
});
|
||||
|
||||
let vlm_label = match poi.vlm_status {
|
||||
// The Poi model does not carry the VLM label string — only
|
||||
// the pipeline status. The label is attached upstream
|
||||
// when the assessment lands in scan_controller; for now
|
||||
// we surface None and let scan_controller pass the label
|
||||
// through a richer overload once AZ-684 wires it.
|
||||
VlmPipelineStatus::Ok => None,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
OperatorPoiEvent {
|
||||
poi_id: poi.id,
|
||||
mgrs: poi.mgrs.clone(),
|
||||
class_group: poi.class_group.clone(),
|
||||
confidence: poi.confidence,
|
||||
vlm_status: poi.vlm_status,
|
||||
vlm_label,
|
||||
tier2_evidence_summary,
|
||||
photo_metadata,
|
||||
deadline_unix_ms: poi.deadline.timestamp_millis(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map + push. Returns the wire event so the caller can also
|
||||
/// attach it to the audit log if needed.
|
||||
pub async fn surface(
|
||||
&self,
|
||||
poi: &Poi,
|
||||
photo_metadata: Option<PhotoMetadata>,
|
||||
) -> Result<OperatorPoiEvent> {
|
||||
let event = Self::map(poi, photo_metadata);
|
||||
self.sink
|
||||
.push_operator_event(OperatorEvent::PoiSurfaced(event.clone()))
|
||||
.await
|
||||
.map_err(|e| AutopilotError::Internal(format!("push_operator_event(poi): {e}")))?;
|
||||
self.pois_surfaced_total.fetch_add(1, Ordering::Relaxed);
|
||||
self.rate.record_and_count();
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Emit a `PoiDequeued` event. Called by `scan_controller` (via
|
||||
/// `operator_bridge`) when a POI is rotated, ages out, or
|
||||
/// completes (operator decided).
|
||||
pub async fn emit_dequeued(&self, poi_id: Uuid, reason: DequeueReason) -> Result<()> {
|
||||
let event = PoiDequeued {
|
||||
poi_id,
|
||||
reason,
|
||||
dequeued_at: Utc::now(),
|
||||
};
|
||||
self.sink
|
||||
.push_operator_event(OperatorEvent::PoiDequeued(event))
|
||||
.await
|
||||
.map_err(|e| AutopilotError::Internal(format!("push_operator_event(dequeue): {e}")))?;
|
||||
self.pois_dequeued_total.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn metrics(&self) -> PoiSurfaceMetrics {
|
||||
PoiSurfaceMetrics {
|
||||
pois_surfaced_per_min: self.rate.current_rate(),
|
||||
pois_surfaced_total: self.pois_surfaced_total.load(Ordering::Relaxed),
|
||||
pois_dequeued_total: self.pois_dequeued_total.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use shared::models::detection::DetectionBatch;
|
||||
use shared::models::frame::Frame;
|
||||
use shared::models::tier2::{RecommendedNextAction, Tier2Evidence, Tier2Status};
|
||||
|
||||
/// Recording sink that captures every operator event pushed to it.
|
||||
/// Lets tests assert on the exact wire content without spinning
|
||||
/// up a real gRPC server.
|
||||
#[derive(Default, Clone)]
|
||||
struct RecordingSink {
|
||||
events: Arc<Mutex<Vec<OperatorEvent>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TelemetrySink for RecordingSink {
|
||||
async fn push_frame(&self, _frame: Frame) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn push_detections(&self, _batch: DetectionBatch) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn push_operator_event(&self, event: OperatorEvent) -> Result<()> {
|
||||
self.events.lock().push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn poi_with_full_evidence() -> Poi {
|
||||
Poi {
|
||||
id: Uuid::new_v4(),
|
||||
confidence: 0.92,
|
||||
mgrs: "33UWP05".to_string(),
|
||||
class: "tank".to_string(),
|
||||
class_group: "vehicle".to_string(),
|
||||
source_detection_ids: vec![Uuid::new_v4()],
|
||||
enqueued_at: Utc::now(),
|
||||
priority: 0.92,
|
||||
decline_suppressed: false,
|
||||
vlm_status: VlmPipelineStatus::Ok,
|
||||
tier2_evidence: Some(Tier2Evidence {
|
||||
roi_id: Uuid::new_v4(),
|
||||
path_freshness: Some(0.7),
|
||||
endpoint_score: Some(0.5),
|
||||
concealment_score: Some(0.3),
|
||||
recommended_next_action: RecommendedNextAction::HoldEndpoint,
|
||||
source_detections: vec![],
|
||||
status: Tier2Status::Ok,
|
||||
}),
|
||||
deadline: Utc::now() + Duration::seconds(120),
|
||||
}
|
||||
}
|
||||
|
||||
fn poi_vlm_disabled() -> Poi {
|
||||
Poi {
|
||||
vlm_status: VlmPipelineStatus::Disabled,
|
||||
tier2_evidence: None,
|
||||
..poi_with_full_evidence()
|
||||
}
|
||||
}
|
||||
|
||||
/// AC-1 — full POI maps with every required field populated; the
|
||||
/// optional `tier2_evidence_summary` is present when input has it.
|
||||
#[test]
|
||||
fn ac1_full_poi_maps_all_required_fields() {
|
||||
// Arrange
|
||||
let poi = poi_with_full_evidence();
|
||||
let meta = PhotoMetadata {
|
||||
photo_ref: "snap/123.jpg".to_string(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
captured_at_unix_ms: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
// Act
|
||||
let evt = PoiSurfaceMapper::map(&poi, Some(meta.clone()));
|
||||
|
||||
// Assert
|
||||
assert_eq!(evt.poi_id, poi.id);
|
||||
assert_eq!(evt.mgrs, "33UWP05");
|
||||
assert_eq!(evt.class_group, "vehicle");
|
||||
assert!((evt.confidence - 0.92).abs() < 1e-6);
|
||||
assert_eq!(evt.vlm_status, VlmPipelineStatus::Ok);
|
||||
let tier2 = evt
|
||||
.tier2_evidence_summary
|
||||
.as_ref()
|
||||
.expect("Tier2 evidence should be carried through");
|
||||
assert_eq!(
|
||||
tier2.recommended_next_action,
|
||||
RecommendedNextAction::HoldEndpoint
|
||||
);
|
||||
assert_eq!(tier2.status, Tier2Status::Ok);
|
||||
assert_eq!(
|
||||
evt.photo_metadata.as_ref().map(|p| &p.photo_ref),
|
||||
Some(&meta.photo_ref)
|
||||
);
|
||||
assert_eq!(evt.deadline_unix_ms, poi.deadline.timestamp_millis());
|
||||
}
|
||||
|
||||
/// AC-2 — VLM-disabled POIs map to vlm_status = Disabled and
|
||||
/// vlm_label = None.
|
||||
#[test]
|
||||
fn ac2_vlm_disabled_carries_explicit_status() {
|
||||
// Arrange
|
||||
let poi = poi_vlm_disabled();
|
||||
|
||||
// Act
|
||||
let evt = PoiSurfaceMapper::map(&poi, None);
|
||||
|
||||
// Assert
|
||||
assert_eq!(evt.vlm_status, VlmPipelineStatus::Disabled);
|
||||
assert!(evt.vlm_label.is_none());
|
||||
// tier2 absence preserved.
|
||||
assert!(evt.tier2_evidence_summary.is_none());
|
||||
assert!(evt.photo_metadata.is_none());
|
||||
}
|
||||
|
||||
/// AC-3 — Dequeue path emits a PoiDequeued event with the
|
||||
/// configured reason and the supplied poi_id.
|
||||
#[tokio::test]
|
||||
async fn ac3_dequeue_emits_event_through_sink() {
|
||||
// Arrange
|
||||
let sink = RecordingSink::default();
|
||||
let captured = Arc::clone(&sink.events);
|
||||
let mapper = PoiSurfaceMapper::new(Arc::new(sink));
|
||||
let poi = poi_with_full_evidence();
|
||||
|
||||
// Act — surface, then dequeue.
|
||||
mapper.surface(&poi, None).await.unwrap();
|
||||
mapper
|
||||
.emit_dequeued(poi.id, DequeueReason::Rotated)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Assert — sink saw both events in order.
|
||||
let events = captured.lock().clone();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(events[0], OperatorEvent::PoiSurfaced(_)));
|
||||
match &events[1] {
|
||||
OperatorEvent::PoiDequeued(d) => {
|
||||
assert_eq!(d.poi_id, poi.id);
|
||||
assert_eq!(d.reason, DequeueReason::Rotated);
|
||||
}
|
||||
_ => panic!("second event must be PoiDequeued"),
|
||||
}
|
||||
let m = mapper.metrics();
|
||||
assert_eq!(m.pois_surfaced_total, 1);
|
||||
assert_eq!(m.pois_dequeued_total, 1);
|
||||
assert_eq!(m.pois_surfaced_per_min, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
//! `operator_bridge` — POI surfacing + operator command authentication.
|
||||
//!
|
||||
//! Real implementation in this batch:
|
||||
//! - **AZ-678** `internal::auth::HmacOperatorValidator` — HMAC-SHA256
|
||||
//! over `(session_token, sequence_number, payload)`; per-session
|
||||
//! replay tracker; session registry with TTL; rejection-reason
|
||||
//! counters; sliding-window red-health gate.
|
||||
//! - **AZ-679** `internal::poi_surface::PoiSurfaceMapper` — wire-format
|
||||
//! POI events + `PoiDequeued` events pushed through `TelemetrySink`.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-678 `operator_bridge_command_auth`
|
||||
//! - AZ-679 `operator_bridge_poi_surface`
|
||||
//! - AZ-680 `operator_bridge_command_dispatch`
|
||||
//! - AZ-681 `operator_bridge_safety_and_bit_ack`
|
||||
|
||||
pub mod internal;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use shared::contracts::OperatorCommandSink;
|
||||
use shared::contracts::{OperatorCommandSink, TelemetrySink};
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::health::{ComponentHealth, HealthLevel};
|
||||
use shared::models::mission::Coordinate;
|
||||
use shared::models::operator::OperatorCommand;
|
||||
use shared::models::operator_event::{DequeueReason, PhotoMetadata};
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
pub use crate::internal::auth::{
|
||||
AuthCounters, HmacOperatorValidator, HmacValidatorConfig, REJECTION_REASONS,
|
||||
};
|
||||
pub use crate::internal::poi_surface::{PoiSurfaceMapper, PoiSurfaceMetrics};
|
||||
|
||||
const NAME: &str = "operator_bridge";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -46,6 +62,15 @@ pub struct OperatorBridge {
|
||||
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
||||
middle_waypoint_rx: Option<mpsc::Receiver<MiddleWaypointHint>>,
|
||||
target_follow_rx: Option<mpsc::Receiver<TargetFollowEvent>>,
|
||||
/// AZ-679 — POI surface mapper. Optional so existing single-arg
|
||||
/// constructors (used by tests + early scaffolding) keep working;
|
||||
/// composition root wires the real `TelemetrySink` via
|
||||
/// `with_telemetry_sink`.
|
||||
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
|
||||
/// AZ-678 — operator command validator. Same optional-pattern as
|
||||
/// `poi_mapper` so legacy callers continue to compile until the
|
||||
/// composition root wires it in.
|
||||
validator: Option<Arc<HmacOperatorValidator>>,
|
||||
}
|
||||
|
||||
impl OperatorBridge {
|
||||
@@ -57,13 +82,27 @@ impl OperatorBridge {
|
||||
target_follow_tx: tf_tx,
|
||||
middle_waypoint_rx: Some(mw_rx),
|
||||
target_follow_rx: Some(tf_rx),
|
||||
poi_mapper: None,
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_telemetry_sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
|
||||
self.poi_mapper = Some(Arc::new(PoiSurfaceMapper::new(sink)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_validator(mut self, validator: Arc<HmacOperatorValidator>) -> Self {
|
||||
self.validator = Some(validator);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> OperatorBridgeHandle {
|
||||
OperatorBridgeHandle {
|
||||
middle_waypoint_tx: self.middle_waypoint_tx.clone(),
|
||||
target_follow_tx: self.target_follow_tx.clone(),
|
||||
poi_mapper: self.poi_mapper.clone(),
|
||||
validator: self.validator.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +121,79 @@ pub struct OperatorBridgeHandle {
|
||||
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
|
||||
#[allow(dead_code)]
|
||||
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
||||
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
|
||||
validator: Option<Arc<HmacOperatorValidator>>,
|
||||
}
|
||||
|
||||
impl OperatorBridgeHandle {
|
||||
pub async fn surface_poi(&self, _poi: Poi) -> Result<OperatorDecision> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::surface_poi (AZ-679)",
|
||||
))
|
||||
/// AZ-679 — surface a POI to the operator and await the decision.
|
||||
/// Today returns `NotImplemented` (the decision loop is AZ-680);
|
||||
/// the surface event itself IS pushed (via the configured
|
||||
/// `TelemetrySink`), so the operator UI receives it.
|
||||
pub async fn surface_poi(&self, poi: Poi) -> Result<OperatorDecision> {
|
||||
match &self.poi_mapper {
|
||||
Some(mapper) => {
|
||||
mapper.surface(&poi, None).await?;
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::surface_poi → decision loop (AZ-680)",
|
||||
))
|
||||
}
|
||||
None => Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::surface_poi (no telemetry sink wired)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// AZ-679 — surface a POI together with photo metadata (preferred
|
||||
/// path when the source detection carries an ROI snapshot).
|
||||
pub async fn surface_poi_with_photo(
|
||||
&self,
|
||||
poi: &Poi,
|
||||
photo_metadata: PhotoMetadata,
|
||||
) -> Result<()> {
|
||||
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
|
||||
AutopilotError::Internal("surface_poi_with_photo: telemetry sink not wired".into())
|
||||
})?;
|
||||
mapper.surface(poi, Some(photo_metadata)).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// AZ-679 — emit a `PoiDequeued` event (rotation / age-out /
|
||||
/// completion). Called by `scan_controller` through the bridge.
|
||||
pub async fn emit_poi_dequeued(&self, poi_id: uuid::Uuid, reason: DequeueReason) -> Result<()> {
|
||||
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
|
||||
AutopilotError::Internal("emit_poi_dequeued: telemetry sink not wired".into())
|
||||
})?;
|
||||
mapper.emit_dequeued(poi_id, reason).await
|
||||
}
|
||||
|
||||
pub fn poi_metrics(&self) -> Option<PoiSurfaceMetrics> {
|
||||
self.poi_mapper.as_ref().map(|m| m.metrics())
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
let mut h = ComponentHealth::disabled(NAME);
|
||||
if self.poi_mapper.is_none() && self.validator.is_none() {
|
||||
return h;
|
||||
}
|
||||
// Once any sub-component is wired we surface green by default,
|
||||
// upgrade to red if the validator's signature-failure window
|
||||
// crosses the threshold (AC-5).
|
||||
h.level = HealthLevel::Green;
|
||||
if let Some(v) = &self.validator {
|
||||
if v.health_is_red() {
|
||||
h.level = HealthLevel::Red;
|
||||
}
|
||||
let c = v.counters();
|
||||
h.detail = Some(format!(
|
||||
"validated_total={} sig_invalid={} replay={} session_unknown={} session_expired={}",
|
||||
c.validated_total(),
|
||||
c.reason(shared::contracts::operator_auth::AuthError::SignatureInvalid),
|
||||
c.reason(shared::contracts::operator_auth::AuthError::ReplayDetected),
|
||||
c.reason(shared::contracts::operator_auth::AuthError::SessionUnknown),
|
||||
c.reason(shared::contracts::operator_auth::AuthError::SessionExpired),
|
||||
));
|
||||
}
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +211,22 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
fn it_compiles_without_wiring() {
|
||||
let h = OperatorBridge::new(8).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_green_once_validator_wired() {
|
||||
// Arrange
|
||||
let validator = Arc::new(HmacOperatorValidator::with_default_config());
|
||||
|
||||
// Act
|
||||
let bridge = OperatorBridge::new(8).with_validator(validator);
|
||||
let h = bridge.handle().health();
|
||||
|
||||
// Assert
|
||||
assert_eq!(h.level, shared::health::HealthLevel::Green);
|
||||
assert!(h.detail.unwrap().contains("validated_total=0"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user