339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
//! ADR-153 sensing-by-proxy (SBP) acceptance tests — proxy lifecycle
|
|
//! (re-triggering + report relay), client flow, table-driven AP entry
|
|
//! point, and the single-validation-path status mapping. Other FSM tests
|
|
//! live in [`super::tests_fsm`]; type/serde/transport/bridge tests in
|
|
//! [`super::tests`]. All tests are hardware-free (simulation only).
|
|
|
|
use super::messages::*;
|
|
use super::session::{
|
|
Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState,
|
|
};
|
|
use super::table::SessionTable;
|
|
use super::testutil::{params, payload};
|
|
use super::transport::{action_to_frame, frame_to_event, SensingFrame};
|
|
use super::types::*;
|
|
use crate::csi_frame::Bandwidth;
|
|
|
|
fn sbp_request(id: u8) -> SbpRequest {
|
|
SbpRequest {
|
|
profile: SpecProfile::Ieee80211Bf2025,
|
|
proxy_setup_id: MeasurementSetupId::new(id).unwrap(),
|
|
params: params(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sbp_proxy_request_maps_to_standard_responder_path() {
|
|
// Proxy AP: accepts the SBP request and initiates an ordinary setup
|
|
// toward the sensing responder — no direct sensor coupling.
|
|
let mut proxy = SensingSession::new_responder(SessionConfig::default());
|
|
let actions = proxy
|
|
.handle(SessionEvent::SbpRequestReceived(sbp_request(11)))
|
|
.unwrap();
|
|
let forwarded = match &actions[..] {
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::Accepted,
|
|
..
|
|
}), Action::SendSetupRequest(req)] => req.clone(),
|
|
other => panic!("expected SBP accept + setup request, got {other:?}"),
|
|
};
|
|
assert_eq!(proxy.state(), SessionState::SetupNegotiating);
|
|
assert_eq!(forwarded.setup_id.value(), 11);
|
|
|
|
// The forwarded request drives a *normal* responder session.
|
|
let mut responder = SensingSession::new_responder(SessionConfig::default());
|
|
let actions = responder
|
|
.handle(SessionEvent::SetupRequestReceived(forwarded))
|
|
.unwrap();
|
|
let resp = match &actions[..] {
|
|
[Action::SendSetupResponse(r)] => *r,
|
|
other => panic!("expected accept, got {other:?}"),
|
|
};
|
|
assert_eq!(resp.status, SetupStatus::Accepted);
|
|
proxy
|
|
.handle(SessionEvent::SetupResponseReceived(resp))
|
|
.unwrap();
|
|
assert_eq!(proxy.state(), SessionState::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn sbp_client_flow_and_rejections() {
|
|
let mut client = SensingSession::new_initiator(SessionConfig::default());
|
|
let sbp = sbp_request(12);
|
|
let actions = client.handle(SessionEvent::StartSbp(sbp.clone())).unwrap();
|
|
assert!(matches!(actions[..], [Action::SendSbpRequest(_)]));
|
|
let accept = SbpResponse {
|
|
proxy_setup_id: sbp.proxy_setup_id,
|
|
status: SbpStatus::Accepted,
|
|
};
|
|
client
|
|
.handle(SessionEvent::SbpResponseReceived(accept))
|
|
.unwrap();
|
|
assert_eq!(client.state(), SessionState::Active);
|
|
// Proxied report is delivered to the local consumer.
|
|
let report = SensingMeasurementReport {
|
|
setup_id: sbp.proxy_setup_id,
|
|
instance_id: MeasurementInstanceId::new(0),
|
|
payload: payload(1.0),
|
|
};
|
|
let actions = client.handle(SessionEvent::ReportReceived(report)).unwrap();
|
|
assert!(matches!(actions[..], [Action::DeliverReport(_)]));
|
|
|
|
// A proxy without SBP capability rejects.
|
|
let mut cfg = SessionConfig::default();
|
|
cfg.capabilities.sensing_by_proxy = false;
|
|
let mut no_sbp = SensingSession::new_responder(cfg);
|
|
let actions = no_sbp
|
|
.handle(SessionEvent::SbpRequestReceived(sbp))
|
|
.unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedNotSupported,
|
|
..
|
|
})]
|
|
));
|
|
assert_eq!(no_sbp.state(), SessionState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn sbp_proxy_full_lifecycle_retriggers_and_relays() {
|
|
// Review finding 1: the SBP proxy is a first-class mode — after the
|
|
// proxied setup is accepted it keeps driving measurement instances on
|
|
// InstanceElapsed (like an initiator) and relays every received report
|
|
// to the SBP client in addition to local delivery.
|
|
let mut proxy = SensingSession::new_responder(SessionConfig::default());
|
|
|
|
// Accept: SBP response to the client + proxied setup to the responder.
|
|
let actions = proxy
|
|
.handle(SessionEvent::SbpRequestReceived(sbp_request(21)))
|
|
.unwrap();
|
|
let forwarded = match &actions[..] {
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::Accepted,
|
|
..
|
|
}), Action::SendSetupRequest(req)] => req.clone(),
|
|
other => panic!("expected SBP accept + setup request, got {other:?}"),
|
|
};
|
|
assert!(proxy.is_sbp_proxy());
|
|
|
|
// Responder accepts → proxy Active, instance 0 triggered.
|
|
let actions = proxy
|
|
.handle(SessionEvent::SetupResponseReceived(
|
|
SensingMeasurementSetupResponse {
|
|
setup_id: forwarded.setup_id,
|
|
status: SetupStatus::Accepted,
|
|
},
|
|
))
|
|
.unwrap();
|
|
assert_eq!(proxy.state(), SessionState::Active);
|
|
match &actions[..] {
|
|
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 0),
|
|
other => panic!("expected instance 0 trigger, got {other:?}"),
|
|
}
|
|
|
|
// InstanceElapsed re-triggers instance 1+ (proxy drives the schedule).
|
|
let actions = proxy.handle(SessionEvent::InstanceElapsed).unwrap();
|
|
match &actions[..] {
|
|
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 1),
|
|
other => panic!("expected instance 1 trigger, got {other:?}"),
|
|
}
|
|
|
|
// A report from the sensing responder is delivered locally AND relayed.
|
|
let report = SensingMeasurementReport {
|
|
setup_id: forwarded.setup_id,
|
|
instance_id: MeasurementInstanceId::new(1),
|
|
payload: payload(5.0),
|
|
};
|
|
let actions = proxy
|
|
.handle(SessionEvent::ReportReceived(report.clone()))
|
|
.unwrap();
|
|
assert_eq!(
|
|
actions,
|
|
vec![
|
|
Action::DeliverReport(report.clone()),
|
|
Action::RelaySbpReport(report.clone()),
|
|
]
|
|
);
|
|
// The relay action maps to a frame toward the SBP client, which
|
|
// consumes it through the standard report path.
|
|
let frame = action_to_frame(&Action::RelaySbpReport(report.clone())).unwrap();
|
|
assert_eq!(frame, SensingFrame::SbpReport(report.clone()));
|
|
assert_eq!(
|
|
frame_to_event(frame),
|
|
Some(SessionEvent::ReportReceived(report))
|
|
);
|
|
|
|
// Terminate cleanly: notify the responder, quiesce back to Idle.
|
|
let actions = proxy
|
|
.handle(SessionEvent::Terminate(
|
|
TerminationReason::InitiatorRequested,
|
|
))
|
|
.unwrap();
|
|
assert!(matches!(actions[..], [Action::SendTermination(_)]));
|
|
assert_eq!(proxy.state(), SessionState::Terminating);
|
|
let actions = proxy.handle(SessionEvent::Timeout).unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SessionClosed(CloseReason::Completed)]
|
|
));
|
|
assert_eq!(proxy.state(), SessionState::Idle);
|
|
assert!(!proxy.is_sbp_proxy());
|
|
}
|
|
|
|
#[test]
|
|
fn session_table_routes_sbp_end_to_end() {
|
|
// Review finding 3: the table has a first-class SBP entry point with
|
|
// the same collision/capacity guards as direct setups — a table-driven
|
|
// AP accepts SBP instead of silently dropping it.
|
|
let mut table = SessionTable::new(SessionConfig::default());
|
|
let actions = table.handle_sbp_request(sbp_request(31)).unwrap();
|
|
let forwarded = match &actions[..] {
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::Accepted,
|
|
..
|
|
}), Action::SendSetupRequest(req)] => req.clone(),
|
|
other => panic!("expected SBP accept + setup request, got {other:?}"),
|
|
};
|
|
let setup_id = forwarded.setup_id;
|
|
assert_eq!(table.active_setups(), 1);
|
|
assert!(table.session(setup_id).unwrap().is_sbp_proxy());
|
|
|
|
// Proxy-setup-ID collision while the first proxy is live.
|
|
let actions = table.handle_sbp_request(sbp_request(31)).unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedSetupIdCollision,
|
|
..
|
|
})]
|
|
));
|
|
|
|
// Drive the proxied negotiation to Active through the table.
|
|
let actions = table
|
|
.handle_for(
|
|
setup_id,
|
|
SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse {
|
|
setup_id,
|
|
status: SetupStatus::Accepted,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(actions[..], [Action::TriggerInstance(_)]));
|
|
assert_eq!(
|
|
table.session(setup_id).unwrap().state(),
|
|
SessionState::Active
|
|
);
|
|
|
|
// Reports relay to the SBP client through the table-owned proxy.
|
|
let report = SensingMeasurementReport {
|
|
setup_id,
|
|
instance_id: MeasurementInstanceId::new(0),
|
|
payload: payload(2.0),
|
|
};
|
|
let actions = table
|
|
.handle_for(setup_id, SessionEvent::ReportReceived(report.clone()))
|
|
.unwrap();
|
|
assert!(actions.contains(&Action::RelaySbpReport(report)));
|
|
|
|
// Capacity guard mirrors the direct-setup path.
|
|
let mut cfg = SessionConfig::default();
|
|
cfg.capabilities.max_active_setups = 1;
|
|
let mut small = SessionTable::new(cfg);
|
|
small.handle_sbp_request(sbp_request(1)).unwrap();
|
|
let actions = small.handle_sbp_request(sbp_request(2)).unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedCapacity,
|
|
..
|
|
})]
|
|
));
|
|
|
|
// Unknown-setup drops are observable, not silent (finding 3).
|
|
assert_eq!(table.unknown_setup_drops(), 0);
|
|
let actions = table
|
|
.handle_for(MeasurementSetupId::new(99).unwrap(), SessionEvent::Timeout)
|
|
.unwrap();
|
|
assert!(actions.is_empty());
|
|
assert_eq!(table.unknown_setup_drops(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
|
|
// Review finding 5: SBP requests are validated by building the proxied
|
|
// setup request first and running it through the single evaluate_setup
|
|
// chain — statuses map 1:1, so no rejection class is folded away and no
|
|
// setup policy can be bypassed via SBP.
|
|
|
|
// Incompatible profile now surfaces as its own status (the old
|
|
// duplicated SBP chain folded it into RejectedUnsupportedParams).
|
|
let mut cfg = SessionConfig::default();
|
|
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
|
let mut proxy = SensingSession::new_responder(cfg);
|
|
let actions = proxy
|
|
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
|
|
.unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedIncompatibleProfile,
|
|
..
|
|
})]
|
|
));
|
|
|
|
// Consent policy rejection passes through unchanged.
|
|
let mut proxy = SensingSession::new_responder(SessionConfig::default());
|
|
let mut sbp = sbp_request(42);
|
|
sbp.params.consent = ConsentMode::Disabled;
|
|
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedByPolicy,
|
|
..
|
|
})]
|
|
));
|
|
|
|
// Capability rejection (bandwidth beyond the advertised maximum).
|
|
let mut cfg = SessionConfig::default();
|
|
cfg.capabilities.max_bandwidth_mhz = 40;
|
|
let mut proxy = SensingSession::new_responder(cfg);
|
|
let mut sbp = sbp_request(43);
|
|
sbp.params.bandwidth = Bandwidth::Bw80;
|
|
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
|
|
assert!(matches!(
|
|
actions[..],
|
|
[Action::SendSbpResponse(SbpResponse {
|
|
status: SbpStatus::RejectedUnsupportedParams,
|
|
..
|
|
})]
|
|
));
|
|
|
|
// The status translation itself is exhaustive and 1:1.
|
|
let pairs = [
|
|
(SetupStatus::Accepted, SbpStatus::Accepted),
|
|
(
|
|
SetupStatus::RejectedNotSupported,
|
|
SbpStatus::RejectedNotSupported,
|
|
),
|
|
(
|
|
SetupStatus::RejectedUnsupportedParams,
|
|
SbpStatus::RejectedUnsupportedParams,
|
|
),
|
|
(
|
|
SetupStatus::RejectedSetupIdCollision,
|
|
SbpStatus::RejectedSetupIdCollision,
|
|
),
|
|
(
|
|
SetupStatus::RejectedIncompatibleProfile,
|
|
SbpStatus::RejectedIncompatibleProfile,
|
|
),
|
|
(SetupStatus::RejectedByPolicy, SbpStatus::RejectedByPolicy),
|
|
(SetupStatus::RejectedCapacity, SbpStatus::RejectedCapacity),
|
|
];
|
|
for (setup, sbp) in pairs {
|
|
assert_eq!(SbpStatus::from(setup), sbp);
|
|
}
|
|
}
|