mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
Merge branch 'master' into next
# Conflicts: # src/node/tests/discovery.rs # testing/ci-local.sh
This commit is contained in:
@@ -1105,3 +1105,373 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
|
||||
_ => super::protocol::Response::error(format!("unknown command: {}", command)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Schema-stability snapshot tests for all 18 control-socket query
|
||||
//! handlers.
|
||||
//!
|
||||
//! Each handler is invoked against a deterministically-constructed
|
||||
//! `Node` (fixed identity seed, empty peer/link/transport/cache
|
||||
//! state). The resulting JSON is normalized — fields whose values
|
||||
//! depend on wall-clock, PID, build environment, or filesystem
|
||||
//! layout are replaced with the literal string `"<redacted>"` —
|
||||
//! and compared against versioned fixtures under
|
||||
//! `src/control/snapshots/`.
|
||||
//!
|
||||
//! The point is to catch accidental schema drift (renames, type
|
||||
//! changes, dropped fields) in the operator-facing wire format.
|
||||
//! Empty-state snapshots are sufficient because every top-level
|
||||
//! key still appears, and per-element shapes inside `[]` arrays
|
||||
//! are covered by the dispatcher contract test plus serde
|
||||
//! derives elsewhere.
|
||||
//!
|
||||
//! ## Updating snapshots
|
||||
//!
|
||||
//! When a schema change is intentional, regenerate fixtures by
|
||||
//! deleting the relevant `.json` files (or the whole
|
||||
//! `snapshots/` directory) and re-running this test. Missing
|
||||
//! fixtures are written from the current output rather than
|
||||
//! failing — the next run then enforces the new shape. Review
|
||||
//! the resulting diff before committing.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! The `Node` is built via `Node::with_identity` from a fixed
|
||||
//! 32-byte seed (`[0xAB; 32]`), so `npub`, `node_addr`, and
|
||||
//! `ipv6_addr` are stable across runs and machines.
|
||||
//! Time-dependent scalars are redacted in `normalize_value` —
|
||||
//! see the `VOLATILE_KEYS` list there for the exact set.
|
||||
//! Empty arrays/maps are intrinsically stable and need no
|
||||
//! redaction.
|
||||
//!
|
||||
//! Schnorr signatures are non-deterministic, but the only
|
||||
//! signature surfaced by these handlers is `declaration_signed:
|
||||
//! bool` (a flag, not the signature itself), so no redaction is
|
||||
//! needed for that.
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::identity::Identity;
|
||||
use crate::node::Node;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 32-byte seed for the deterministic test identity.
|
||||
/// Any non-zero secret-key-shaped value works; 0xAB-fill is just
|
||||
/// readable in hex.
|
||||
const TEST_SEED: [u8; 32] = [0xAB; 32];
|
||||
|
||||
/// Fields whose value is environment-, time-, or build-dependent
|
||||
/// and therefore must be redacted before comparison. Matched by
|
||||
/// JSON key name anywhere in the document.
|
||||
const VOLATILE_KEYS: &[&str] = &[
|
||||
// Process / build environment
|
||||
"version",
|
||||
"pid",
|
||||
"exe_path",
|
||||
"control_socket",
|
||||
"tun_name",
|
||||
// Filesystem layout (ACL, hosts, etc.)
|
||||
"allow_file",
|
||||
"deny_file",
|
||||
// Wall-clock derived
|
||||
"uptime_secs",
|
||||
"started_at_ms",
|
||||
"session_start_ms",
|
||||
"authenticated_at_ms",
|
||||
"last_seen_ms",
|
||||
"last_activity_ms",
|
||||
"last_recv_ms",
|
||||
"created_at_ms",
|
||||
"initiated_ms",
|
||||
"last_sent_ms",
|
||||
"age_ms",
|
||||
"last_used_ms",
|
||||
"idle_ms",
|
||||
"first_seen_secs_ago",
|
||||
"last_contact_secs_ago",
|
||||
];
|
||||
|
||||
/// Build a Node with a fixed identity, default config, and empty
|
||||
/// runtime state (no peers, links, sessions, transports, or cache
|
||||
/// entries). This keeps every per-element list empty and every
|
||||
/// scalar deterministic modulo `VOLATILE_KEYS`.
|
||||
fn build_test_node() -> Node {
|
||||
let identity =
|
||||
Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key");
|
||||
let config = Config::new();
|
||||
Node::with_identity(identity, config).expect("default config is valid")
|
||||
}
|
||||
|
||||
/// Recursively walk a JSON value, replacing the value of any key
|
||||
/// listed in `VOLATILE_KEYS` with the literal string
|
||||
/// `"<redacted>"`. Array elements are recursed into.
|
||||
fn normalize_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for (key, v) in map.iter_mut() {
|
||||
if VOLATILE_KEYS.contains(&key.as_str()) {
|
||||
*v = Value::String("<redacted>".to_string());
|
||||
} else {
|
||||
normalize_value(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items.iter_mut() {
|
||||
normalize_value(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a handler value in the on-the-wire `Response` envelope so
|
||||
/// the snapshot reflects exactly what a control-socket client
|
||||
/// receives. Pretty-printed and sorted-keyed for readable diffs.
|
||||
fn render(value: Value) -> String {
|
||||
let mut wrapped = json!({ "status": "ok", "data": value });
|
||||
normalize_value(&mut wrapped);
|
||||
let sorted = sort_object_keys(&wrapped);
|
||||
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
|
||||
}
|
||||
|
||||
/// Same as `render` but takes a `Response` directly (for handlers
|
||||
/// that return `Response`, not `Value`).
|
||||
fn render_response(resp: super::super::protocol::Response) -> String {
|
||||
let value = serde_json::to_value(&resp).expect("response always serializes");
|
||||
let mut value = value;
|
||||
normalize_value(&mut value);
|
||||
let sorted = sort_object_keys(&value);
|
||||
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
|
||||
}
|
||||
|
||||
/// Recursively sort object keys for stable diff-friendly output.
|
||||
/// `serde_json::Value` preserves insertion order; handlers don't
|
||||
/// guarantee any particular emit order, so normalize here.
|
||||
fn sort_object_keys(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut sorted: Map<String, Value> = Map::new();
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
sorted.insert(key.clone(), sort_object_keys(&map[key]));
|
||||
}
|
||||
Value::Object(sorted)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(sort_object_keys).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.join("control")
|
||||
.join("snapshots")
|
||||
}
|
||||
|
||||
/// Compare `actual` against the on-disk fixture for `name`. If the
|
||||
/// fixture does not exist, write it (first-run convention) and
|
||||
/// pass. Any subsequent mismatch fails with an inline diff hint.
|
||||
fn assert_snapshot(name: &str, actual: &str) {
|
||||
let path = snapshot_dir().join(format!("{name}.json"));
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path.parent().unwrap())
|
||||
.expect("failed to create snapshots dir");
|
||||
std::fs::write(&path, actual).expect("failed to write new snapshot");
|
||||
// Newly written: nothing to compare. Subsequent runs enforce.
|
||||
return;
|
||||
}
|
||||
let expected = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("failed to read snapshot {}: {e}", path.display()));
|
||||
// Normalize line endings: Windows checkouts with core.autocrlf=true
|
||||
// convert fixture files to CRLF; the in-memory JSON output is LF.
|
||||
let expected = expected.replace("\r\n", "\n");
|
||||
// Tolerate trailing newline differences from editors.
|
||||
if expected.trim_end() != actual.trim_end() {
|
||||
panic!(
|
||||
"snapshot mismatch for {name}\n\
|
||||
fixture: {}\n\
|
||||
-- expected --\n{expected}\n\
|
||||
-- actual --\n{actual}\n\
|
||||
-- end --\n\
|
||||
If the schema change is intentional, delete the fixture \
|
||||
and re-run to regenerate.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 18 handler snapshot tests --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_status() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_status", &render(show_status(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_acl() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_acl", &render(show_acl(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_peers() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_peers", &render(show_peers(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_links() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_links", &render(show_links(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_tree() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_tree", &render(show_tree(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_sessions() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_sessions", &render(show_sessions(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_bloom() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_bloom", &render(show_bloom(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_mmp() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_mmp", &render(show_mmp(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_cache() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_cache", &render(show_cache(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_connections() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_connections", &render(show_connections(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_transports() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_transports", &render(show_transports(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_routing() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_routing", &render(show_routing(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_identity_cache() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_identity_cache", &render(show_identity_cache(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_stats_list() {
|
||||
// Static — no Node needed.
|
||||
assert_snapshot("show_stats_list", &render(show_stats_list()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_stats_history() {
|
||||
let node = build_test_node();
|
||||
// Pin the empty-history series shape for one node-level metric.
|
||||
let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
|
||||
let resp = show_stats_history(&node, Some(¶ms));
|
||||
assert_snapshot("show_stats_history", &render_response(resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_stats_all_history() {
|
||||
let node = build_test_node();
|
||||
// Empty-history all-node series; small window keeps the
|
||||
// per-series `values` arrays short and stable.
|
||||
let params = json!({ "window": "10s", "granularity": "1s" });
|
||||
let resp = show_stats_all_history(&node, Some(¶ms));
|
||||
assert_snapshot("show_stats_all_history", &render_response(resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_stats_peers() {
|
||||
let node = build_test_node();
|
||||
assert_snapshot("show_stats_peers", &render(show_stats_peers(&node)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_show_stats_history_all_peers() {
|
||||
let node = build_test_node();
|
||||
// No peers tracked → empty `peers: []` envelope. Per-peer
|
||||
// `values` shape is exercised once a real peer is wired in;
|
||||
// here we only pin the envelope.
|
||||
let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
|
||||
let resp = show_stats_history_all_peers(&node, Some(¶ms));
|
||||
assert_snapshot("show_stats_history_all_peers", &render_response(resp));
|
||||
}
|
||||
|
||||
/// Sanity check: every handler advertised in `dispatch` is also
|
||||
/// covered by a snapshot test above. If a new handler is added
|
||||
/// without a matching snapshot, this test fails.
|
||||
#[test]
|
||||
fn dispatch_covers_all_snapshotted_handlers() {
|
||||
let expected = [
|
||||
"show_status",
|
||||
"show_acl",
|
||||
"show_peers",
|
||||
"show_links",
|
||||
"show_tree",
|
||||
"show_sessions",
|
||||
"show_bloom",
|
||||
"show_mmp",
|
||||
"show_cache",
|
||||
"show_connections",
|
||||
"show_transports",
|
||||
"show_routing",
|
||||
"show_identity_cache",
|
||||
"show_stats_list",
|
||||
"show_stats_history",
|
||||
"show_stats_all_history",
|
||||
"show_stats_peers",
|
||||
"show_stats_history_all_peers",
|
||||
];
|
||||
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers");
|
||||
let node = build_test_node();
|
||||
for cmd in expected {
|
||||
// Each must dispatch successfully (status == "ok") with
|
||||
// minimal params. Handlers requiring params get them.
|
||||
let params = match cmd {
|
||||
"show_stats_history" => Some(json!({
|
||||
"metric": "mesh_size", "window": "10s", "granularity": "1s"
|
||||
})),
|
||||
"show_stats_all_history" => Some(json!({ "window": "10s", "granularity": "1s" })),
|
||||
"show_stats_history_all_peers" => Some(json!({
|
||||
"metric": "srtt_ms", "window": "10s", "granularity": "1s"
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
let resp = dispatch(&node, cmd, params.as_ref());
|
||||
assert_eq!(
|
||||
resp.status, "ok",
|
||||
"dispatch({cmd}) returned status={} message={:?}",
|
||||
resp.status, resp.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user