Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/tests/event_privacy_gating.rs
T
ruv 926c66f677 feat(adr-118/p4.1): BfldEvent privacy-gated output + JSON (102/102 GREEN)
Iter 13. Lands ADR-121 §2.1 (output event) + ADR-122 §2.1 (field-gating
policy). BfldEvent collapses the GateAction-driven sensing pipeline
into the canonical wire-format publishable on MQTT.

Added:
- serde (workspace, derive feature, optional) + serde_json (workspace, optional) deps
- New crate feature `serde-json` (default-on; requires `std`)
- src/event.rs (gated on `feature = "std"`):
  * BfldEvent struct with all sensing + identity-derived fields
  * with_privacy_gating(...) constructor that applies field-gating policy:
      class < Restricted (3): identity_risk_score + rf_signature_hash kept
      class >= Restricted (3): both nulled to None
  * apply_privacy_gating() — idempotent in-place masking
  * to_json() -> Result<String, serde_json::Error> (gated on serde-json)
  * Custom ser_privacy_class serializer emits lowercase names
    ("anonymous", "restricted", etc.) per the BFLD JSON spec
  * skip_serializing_if = "Option::is_none" on identity-derived fields so
    privacy-gated events are observationally indistinguishable from
    events that never had the field set
- pub use BfldEvent from lib.rs

tests/event_privacy_gating.rs (9 named tests, all green):
  anonymous_event_retains_identity_risk_and_hash
  restricted_event_strips_identity_fields (class 3 → None)
  apply_privacy_gating_is_idempotent
  event_type_is_always_bfld_update (parameterized over 3 classes)
  json::json_round_trip_emits_type_field_first_or_last_but_present
  json::anonymous_json_includes_identity_fields
  json::restricted_json_omits_identity_fields_entirely
    (asserts the JSON string does NOT contain identity_risk_score or
     rf_signature_hash, verifying skip_serializing_if works as intended)
  json::privacy_class_serializes_to_lowercase_name
  json::zone_id_none_is_omitted_from_json

ACs progressed:
- ADR-121 AC6 (identity_risk score absent at class 3) — structurally
  enforced by with_privacy_gating + skip_serializing_if combination.
- ADR-122 AC1 — JSON shape matches the HA-DISCO publishable event
  contract; identity fields can be reliably stripped by privacy_class.
- ADR-118 AC5 — privacy_mode = engaged maps to PrivacyClass::Restricted
  with no identity fields in the published event.

Test config:
- cargo test --no-default-features → 64 passed (unchanged; event cfg-out)
- cargo test                       → 102 passed (93 + 9)

Out of scope (next iter target):
- Emitter struct that wires GateAction + privacy class + sensing inputs
  into BfldEvent construction (ADR-118 §2.1 pipeline diagram).
- MQTT topic publisher (ADR-122 §2.2) — depends on a runtime (tokio).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 15:27:49 -04:00

117 lines
3.6 KiB
Rust

//! Acceptance tests for ADR-121 §2.1 / ADR-122 §2.1 — `BfldEvent` privacy gating.
#![cfg(feature = "std")]
use wifi_densepose_bfld::{BfldEvent, PrivacyClass};
fn sample_at(class: PrivacyClass) -> BfldEvent {
BfldEvent::with_privacy_gating(
"seed-01".to_string(),
1_700_000_000_000_000_000,
true,
0.72,
1,
0.91,
Some("living_room".to_string()),
class,
Some(0.84),
Some([0xAB; 32]),
)
}
#[test]
fn anonymous_event_retains_identity_risk_and_hash() {
let e = sample_at(PrivacyClass::Anonymous);
assert!(e.identity_risk_score.is_some());
assert!(e.rf_signature_hash.is_some());
}
#[test]
fn restricted_event_strips_identity_fields() {
let e = sample_at(PrivacyClass::Restricted);
assert!(e.identity_risk_score.is_none(), "risk score must be None at class 3");
assert!(e.rf_signature_hash.is_none(), "rf hash must be None at class 3");
// Sensing fields still present.
assert!(e.presence);
assert_eq!(e.person_count, 1);
assert_eq!(e.zone_id.as_deref(), Some("living_room"));
}
#[test]
fn apply_privacy_gating_is_idempotent() {
let mut e = sample_at(PrivacyClass::Restricted);
e.apply_privacy_gating();
e.apply_privacy_gating();
assert!(e.identity_risk_score.is_none());
}
#[test]
fn event_type_is_always_bfld_update() {
for c in [
PrivacyClass::Anonymous,
PrivacyClass::Restricted,
PrivacyClass::Derived,
] {
assert_eq!(sample_at(c).event_type, "bfld_update");
}
}
#[cfg(feature = "serde-json")]
mod json {
use super::sample_at;
use wifi_densepose_bfld::PrivacyClass;
#[test]
fn json_round_trip_emits_type_field_first_or_last_but_present() {
let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap();
assert!(json.contains(r#""type":"bfld_update""#), "JSON: {json}");
assert!(json.contains(r#""node_id":"seed-01""#));
assert!(json.contains(r#""presence":true"#));
assert!(json.contains(r#""privacy_class":"anonymous""#));
}
#[test]
fn anonymous_json_includes_identity_fields() {
let json = sample_at(PrivacyClass::Anonymous).to_json().unwrap();
assert!(json.contains("identity_risk_score"));
assert!(json.contains("rf_signature_hash"));
}
#[test]
fn restricted_json_omits_identity_fields_entirely() {
let json = sample_at(PrivacyClass::Restricted).to_json().unwrap();
assert!(
!json.contains("identity_risk_score"),
"JSON must omit identity_risk_score at class 3, got: {json}",
);
assert!(
!json.contains("rf_signature_hash"),
"JSON must omit rf_signature_hash at class 3, got: {json}",
);
// Sensing fields still emitted.
assert!(json.contains("presence"));
assert!(json.contains("motion"));
assert!(json.contains(r#""privacy_class":"restricted""#));
}
#[test]
fn privacy_class_serializes_to_lowercase_name() {
for (class, name) in [
(PrivacyClass::Anonymous, "anonymous"),
(PrivacyClass::Restricted, "restricted"),
] {
let json = sample_at(class).to_json().unwrap();
let needle = format!(r#""privacy_class":"{name}""#);
assert!(json.contains(&needle), "missing {needle} in: {json}");
}
}
#[test]
fn zone_id_none_is_omitted_from_json() {
let mut e = sample_at(PrivacyClass::Anonymous);
e.zone_id = None;
let json = e.to_json().unwrap();
assert!(!json.contains("zone_id"), "None zone_id must be omitted: {json}");
}
}