mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test: Phase 1 P0 — encryption core + event-bus panic isolation (re-verified backlog) (#4497)
This commit is contained in:
@@ -363,12 +363,32 @@ Dimensions the suite (and this audit) currently have **zero** coverage of:
|
|||||||
- [x] Resolve the Release CI Gate Playwright `continue-on-error` bypass (§5bis item 1, #3615):
|
- [x] Resolve the Release CI Gate Playwright `continue-on-error` bypass (§5bis item 1, #3615):
|
||||||
`playwright-e2e` excluded from the gate's `needs`/results with an explanatory comment.
|
`playwright-e2e` excluded from the gate's `needs`/results with an explanatory comment.
|
||||||
|
|
||||||
**Phase 1 — P0 coverage (1–2 weeks)**
|
**Phase 1 — P0 coverage** — ⏳ partial (PR: `test/phase1-p0-coverage`). Each item was
|
||||||
- Security gate-matrix suite (command_checks/path_checks).
|
**re-verified against current source first** (the §4 backlog was never skeptic-verified); three
|
||||||
- Encryption core round-trip + tamper suite; RPC-integrated variant.
|
of the five "ZERO tests" claims turned out **already covered**, so only the genuine gaps were filled.
|
||||||
- `delete_source_rpc` cascade suite.
|
- [x] **Encryption core round-trip + tamper suite** — GENUINE GAP (verified 0 prior tests in
|
||||||
- Event-bus panic isolation; webhook flood behavior (or file the product gap).
|
`encryption/core.rs`). Added 11 tests: bytes/string round-trip, KDF determinism (behavioural —
|
||||||
- `stop_core_process` debug command + crash-recovery E2E; RPC auth-failure E2E.
|
key_bytes is private), wrong-password / different-salt rejection, tampered-ciphertext and
|
||||||
|
tampered-nonce GCM auth failure, fresh-random-nonce-per-call, salt length+randomness,
|
||||||
|
malformed-JSON and empty-plaintext edge cases.
|
||||||
|
- [x] **Event-bus panic isolation** — GENUINE GAP (verified: `catch_unwind` in `bus.rs` had no
|
||||||
|
test; only `publish_without_subscribers`). Added a two-subscriber test: one handler panics on its
|
||||||
|
first event then recovers; asserts the panicked handler's own loop survives AND a peer keeps
|
||||||
|
receiving every event.
|
||||||
|
- [~] **Security gate-matrix (command_checks/path_checks)** — ALREADY COVERED. `policy_tests.rs`
|
||||||
|
comprehensively tests `classify_command` (unknown⇒Write, redirect-lifts, highest-segment-wins,
|
||||||
|
all classes), `gate_decision` (all tiers × classes + Install), `is_always_forbidden`, and
|
||||||
|
`is_workspace_internal_path`. Plan's "ZERO tests" was inaccurate; no suite added (would duplicate).
|
||||||
|
- [~] **`delete_source_rpc` cascade** — ALREADY COVERED. `memory_store/chunks/store_tests.rs` has
|
||||||
|
`delete_source_rpc_purges_document_source_fully`, `_unknown_id_is_idempotent`,
|
||||||
|
`_rejects_empty_source_id`, `_cleans_legacy_partial_delete`, plus the cascade (shared-tree
|
||||||
|
preserved while referenced, orphan cascade, escape-path/symlink rejection, per-owner scoping).
|
||||||
|
- [~] **Webhook flood** — PRODUCT GAP (verified: no rate-limit/backpressure/throttle anywhere in
|
||||||
|
`webhooks/router.rs`; it is registration/routing/logging only). Filed as a product gap, not a test.
|
||||||
|
- [ ] **core-process crash/recovery E2E** and **RPC bearer-auth-failure E2E** — deferred: both need
|
||||||
|
the Tauri crate (blocked locally by missing system `glib-2.0`) and/or the WDIO desktop harness.
|
||||||
|
`tests/json_rpc_e2e.rs` already covers RPC bearer auth/401s at the transport layer; the remaining
|
||||||
|
gap is the frontend `core_rpc_relay` E2E. To be done in the CI-capable environment.
|
||||||
|
|
||||||
**Phase 2 — deletions & rewrites (parallel with Phase 1, low risk)** — ✅ landed (PR: `test/phase2-drops-rewrites`)
|
**Phase 2 — deletions & rewrites (parallel with Phase 1, low risk)** — ✅ landed (PR: `test/phase2-drops-rewrites`)
|
||||||
- [x] §2.1 deletions applied (⚠️ items re-verified against source before deleting):
|
- [x] §2.1 deletions applied (⚠️ items re-verified against source before deleting):
|
||||||
|
|||||||
@@ -320,6 +320,67 @@ mod tests {
|
|||||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn panicking_subscriber_does_not_stop_the_bus_or_starve_peers() {
|
||||||
|
// The per-subscriber `catch_unwind` (bus.rs) exists precisely so one
|
||||||
|
// handler panicking cannot kill its own dispatch loop or starve other
|
||||||
|
// subscribers. Nothing exercised it before (plan.md §4 P0 #4).
|
||||||
|
let bus = EventBus::create(16);
|
||||||
|
|
||||||
|
// Subscriber A panics on its FIRST event, then processes normally.
|
||||||
|
let a_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let a_success = Arc::new(AtomicUsize::new(0));
|
||||||
|
let a_calls_c = Arc::clone(&a_calls);
|
||||||
|
let a_success_c = Arc::clone(&a_success);
|
||||||
|
let _ha = bus.on("panicky", move |_| {
|
||||||
|
let calls = Arc::clone(&a_calls_c);
|
||||||
|
let success = Arc::clone(&a_success_c);
|
||||||
|
Box::pin(async move {
|
||||||
|
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
panic!("boom in handler");
|
||||||
|
}
|
||||||
|
success.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscriber B is a well-behaved peer that just counts.
|
||||||
|
let b_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let b_c = Arc::clone(&b_count);
|
||||||
|
let _hb = bus.on("steady", move |_| {
|
||||||
|
let c = Arc::clone(&b_c);
|
||||||
|
Box::pin(async move {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event 1: A panics (must be caught), B counts.
|
||||||
|
bus.publish(DomainEvent::SystemStartup {
|
||||||
|
component: "e1".into(),
|
||||||
|
});
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
// Event 2: A must recover and process; B counts again.
|
||||||
|
bus.publish(DomainEvent::SystemStartup {
|
||||||
|
component: "e2".into(),
|
||||||
|
});
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
a_calls.load(Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"the panicked handler's own loop must survive and be invoked for the next event"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
a_success.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"the handler must process the event that follows the one it panicked on"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
b_count.load(Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"a co-subscriber must keep receiving every event despite a peer panicking"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn domain_filtering_works() {
|
async fn domain_filtering_works() {
|
||||||
use super::super::subscriber::EventHandler;
|
use super::super::subscriber::EventHandler;
|
||||||
|
|||||||
@@ -180,3 +180,138 @@ pub async fn ai_decrypt(password: String, encrypted: String) -> Result<String, S
|
|||||||
let key = EncryptionKey::derive(&password, &key_file.salt)?;
|
let key = EncryptionKey::derive(&password, &key_file.salt)?;
|
||||||
key.decrypt_string(&encrypted)
|
key.decrypt_string(&encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Round-trip + tamper coverage for the Argon2id + AES-256-GCM primitives
|
||||||
|
//! (plan.md §4 P0 #2 — previously zero unit tests). `key_bytes` is private,
|
||||||
|
//! so key equality is asserted *behaviourally*: a key derived twice from the
|
||||||
|
//! same (password, salt) must decrypt the other's ciphertext, and any change
|
||||||
|
//! to password, salt, ciphertext, or nonce must make decryption fail.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key(password: &str, salt: &[u8]) -> EncryptionKey {
|
||||||
|
EncryptionKey::derive(password, salt).expect("derive")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_bytes_round_trip() {
|
||||||
|
let k = key(
|
||||||
|
"correct horse battery staple",
|
||||||
|
&EncryptionKey::generate_salt(),
|
||||||
|
);
|
||||||
|
let plaintext = b"the launch codes are 0000".to_vec();
|
||||||
|
let payload = k.encrypt(&plaintext).expect("encrypt");
|
||||||
|
assert_ne!(
|
||||||
|
payload.ciphertext, plaintext,
|
||||||
|
"ciphertext must not be plaintext"
|
||||||
|
);
|
||||||
|
assert_eq!(k.decrypt(&payload).expect("decrypt"), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_string_round_trip() {
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
let secret = "sk-live-🔐-multibyte";
|
||||||
|
let json = k.encrypt_string(secret).expect("encrypt_string");
|
||||||
|
assert_eq!(k.decrypt_string(&json).expect("decrypt_string"), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kdf_is_deterministic_for_same_password_and_salt() {
|
||||||
|
// Two independent derivations from the same (password, salt) must yield
|
||||||
|
// the same key: key_a encrypts, key_b decrypts.
|
||||||
|
let salt = EncryptionKey::generate_salt();
|
||||||
|
let key_a = key("hunter2", &salt);
|
||||||
|
let key_b = key("hunter2", &salt);
|
||||||
|
let payload = key_a.encrypt(b"cross-key").expect("encrypt");
|
||||||
|
assert_eq!(key_b.decrypt(&payload).expect("decrypt"), b"cross-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_password_cannot_decrypt() {
|
||||||
|
let salt = EncryptionKey::generate_salt();
|
||||||
|
let good = key("right-password", &salt);
|
||||||
|
let bad = key("wrong-password", &salt);
|
||||||
|
let payload = good.encrypt(b"top secret").expect("encrypt");
|
||||||
|
assert!(
|
||||||
|
bad.decrypt(&payload).is_err(),
|
||||||
|
"a key from a different password must not decrypt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_salt_derives_a_different_key() {
|
||||||
|
let a = key("same-password", &EncryptionKey::generate_salt());
|
||||||
|
let b = key("same-password", &EncryptionKey::generate_salt());
|
||||||
|
let payload = a.encrypt(b"salted").expect("encrypt");
|
||||||
|
assert!(
|
||||||
|
b.decrypt(&payload).is_err(),
|
||||||
|
"same password + different salt must yield a non-interchangeable key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_ciphertext_is_rejected_by_gcm_auth() {
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
let mut payload = k.encrypt(b"authentic bytes").expect("encrypt");
|
||||||
|
payload.ciphertext[0] ^= 0xFF; // flip a bit in the ciphertext/tag
|
||||||
|
assert!(
|
||||||
|
k.decrypt(&payload).is_err(),
|
||||||
|
"AES-GCM must reject a tampered ciphertext (auth failure)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_nonce_is_rejected() {
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
let mut payload = k.encrypt(b"authentic bytes").expect("encrypt");
|
||||||
|
payload.nonce[0] ^= 0xFF; // wrong nonce → auth tag no longer verifies
|
||||||
|
assert!(
|
||||||
|
k.decrypt(&payload).is_err(),
|
||||||
|
"decrypting under a mutated nonce must fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_encryption_uses_a_fresh_random_nonce() {
|
||||||
|
// Nonce reuse under a fixed key is catastrophic for GCM. Encrypting the
|
||||||
|
// same plaintext twice must produce distinct nonces (and, therefore,
|
||||||
|
// distinct ciphertexts) — the nonce is drawn from the CSPRNG per call.
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
let p1 = k.encrypt(b"identical plaintext").expect("encrypt");
|
||||||
|
let p2 = k.encrypt(b"identical plaintext").expect("encrypt");
|
||||||
|
assert_ne!(
|
||||||
|
p1.nonce, p2.nonce,
|
||||||
|
"each encryption must draw a fresh nonce"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
p1.ciphertext, p2.ciphertext,
|
||||||
|
"a fresh nonce must produce different ciphertext for the same plaintext"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_salt_is_correct_length_and_random() {
|
||||||
|
let s1 = EncryptionKey::generate_salt();
|
||||||
|
let s2 = EncryptionKey::generate_salt();
|
||||||
|
assert_eq!(s1.len(), SALT_LENGTH, "salt must be {SALT_LENGTH} bytes");
|
||||||
|
assert_ne!(s1, s2, "two generated salts must differ (CSPRNG)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_string_rejects_malformed_json() {
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
assert!(
|
||||||
|
k.decrypt_string("not-json").is_err(),
|
||||||
|
"non-JSON payload must be a clean Err, not a panic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_plaintext_round_trips() {
|
||||||
|
let k = key("pw", &EncryptionKey::generate_salt());
|
||||||
|
let payload = k.encrypt(b"").expect("encrypt empty");
|
||||||
|
assert_eq!(k.decrypt(&payload).expect("decrypt empty"), b"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user