diff --git a/apps/daemon/src/intelligence/upgrade.rs b/apps/daemon/src/intelligence/upgrade.rs index 1e56071..d49b3d4 100644 --- a/apps/daemon/src/intelligence/upgrade.rs +++ b/apps/daemon/src/intelligence/upgrade.rs @@ -133,123 +133,6 @@ fn provider_for_model(_model: &str) -> String { // ─── Tests ──────────────────────────────────────────────────────────────────── +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use crate::config::ModelIntelligenceConfig; - - fn runner_ok(model: &str) -> RunnerOutput { - RunnerOutput { - content: "Here is the implementation you requested.".to_string(), - tool_call_error: false, - output_truncated: false, - model_id: model.to_string(), - input_tokens: 100, - output_tokens: 200, - } - } - - fn runner_empty() -> RunnerOutput { - RunnerOutput { - content: String::new(), - tool_call_error: false, - output_truncated: false, - model_id: "claude-haiku-4-5".to_string(), - input_tokens: 0, - output_tokens: 0, - } - } - - fn runner_refusal() -> RunnerOutput { - RunnerOutput { - content: "I'm unable to complete this task as an AI.".to_string(), - tool_call_error: false, - output_truncated: false, - model_id: "claude-haiku-4-5".to_string(), - input_tokens: 50, - output_tokens: 20, - } - } - - fn haiku_selection() -> ModelSelection { - ModelSelection { - model_id: "claude-haiku-4-5".to_string(), - provider: "claude".to_string(), - reason: "auto_select:Simple".to_string(), - } - } - - fn sonnet_selection() -> ModelSelection { - ModelSelection { - model_id: "claude-sonnet-4-6".to_string(), - provider: "claude".to_string(), - reason: "auto_select:Moderate".to_string(), - } - } - - #[test] - fn ok_response_no_upgrade() { - let q = evaluate_response(&runner_ok("claude-haiku-4-5")); - assert_eq!(q, ResponseQuality::Ok); - } - - #[test] - fn empty_response_is_poor() { - let q = evaluate_response(&runner_empty()); - assert_eq!(q, ResponseQuality::Poor(PoorReason::EmptyResponse)); - } - - #[test] - fn refusal_is_poor() { - let q = evaluate_response(&runner_refusal()); - assert_eq!(q, ResponseQuality::Poor(PoorReason::ModelRefusal)); - } - - #[test] - fn tool_call_error_is_poor() { - let mut out = runner_ok("claude-haiku-4-5"); - out.tool_call_error = true; - let q = evaluate_response(&out); - assert_eq!(q, ResponseQuality::Poor(PoorReason::ToolCallError)); - } - - #[test] - fn haiku_upgrades_to_sonnet() { - let cfg = ModelIntelligenceConfig::default(); - let sel = upgrade_model(&haiku_selection(), &cfg, 0); - assert!(sel.is_some()); - let sel = sel.unwrap(); - assert!(sel.model_id.contains("sonnet"), "got: {}", sel.model_id); - } - - #[test] - fn max_one_upgrade_per_message() { - let cfg = ModelIntelligenceConfig::default(); - let sel = upgrade_model(&haiku_selection(), &cfg, 1); - assert!(sel.is_none(), "upgrade_count=1 should prevent upgrade"); - } - - #[test] - fn sonnet_upgrades_to_opus_when_allowed() { - let cfg = ModelIntelligenceConfig { - max_model: "opus".to_string(), - ..Default::default() - }; - let sel = upgrade_model(&sonnet_selection(), &cfg, 0); - assert!(sel.is_some()); - assert!(sel.unwrap().model_id.contains("opus")); - } - - #[test] - fn sonnet_cannot_upgrade_when_capped_at_sonnet() { - let cfg = ModelIntelligenceConfig { - max_model: "sonnet".to_string(), - ..Default::default() - }; - let sel = upgrade_model(&sonnet_selection(), &cfg, 0); - assert!( - sel.is_none(), - "sonnet capped at sonnet should block upgrade" - ); - } -} +mod tests; diff --git a/apps/daemon/src/intelligence/upgrade/tests.rs b/apps/daemon/src/intelligence/upgrade/tests.rs new file mode 100644 index 0000000..8a67916 --- /dev/null +++ b/apps/daemon/src/intelligence/upgrade/tests.rs @@ -0,0 +1,197 @@ +//! Tests for the model-upgrade helpers. +//! +//! Written against the surviving-mutant list from the mutation gate. + +use super::*; + +// ─── model_tier ────────────────────────────────────────────────────────────── + +/// Kills the `3` -> `0` and `3` -> `1` return-value mutations, and the +/// `contains("opus") || lower == "opus"` -> `&&` pairing. +/// +/// A real model id contains "opus" without equalling it, so the `&&` pairing +/// drops opus to the fallback tier — and the cap check in `upgrade_model` +/// then stops rejecting upgrades that exceed the configured maximum. +#[test] +fn opus_models_are_tier_three_however_they_are_named() { + assert_eq!(model_tier("opus"), 3); + assert_eq!(model_tier("claude-3-opus-20240229"), 3); + assert_eq!(model_tier("CLAUDE-OPUS-4"), 3); +} + +/// The same shape for the sonnet arm. +#[test] +fn sonnet_models_are_tier_two_however_they_are_named() { + assert_eq!(model_tier("sonnet"), 2); + assert_eq!(model_tier("claude-sonnet-4-6"), 2); + assert_eq!(model_tier("CLAUDE-SONNET-4"), 2); +} + +/// Kills both `==` -> `!=` mutations. +/// +/// With `lower != "opus"` in place of `==`, every model that is not the +/// literal string "opus" reports as tier 3 — haiku included — which would make +/// the cap check wave through every upgrade. Asserting the LOW tier is what +/// catches it; asserting only the high tiers would not. +#[test] +fn anything_below_sonnet_is_tier_one() { + assert_eq!(model_tier("haiku"), 1); + assert_eq!(model_tier("claude-3-5-haiku-20241022"), 1); + assert_eq!(model_tier(""), 1); + assert_eq!(model_tier("gpt-5"), 1); +} + +/// Strict ordering is the property the cap check actually relies on. +#[test] +fn tiers_are_strictly_ordered() { + assert!(model_tier("haiku") < model_tier("sonnet")); + assert!(model_tier("sonnet") < model_tier("opus")); +} + +// NOTE: the `|| lower == "opus"` clause (and its sonnet twin) is redundant — +// any string equal to "opus" also contains it, so the equality can never be +// the deciding test. Left alone here because this change is test-only, but it +// is dead weight, and a reader could reasonably think the function matches +// exact names as well as substrings. + +// ─── provider_for_model ────────────────────────────────────────────────────── + +/// Kills the `String::new()` and `"xyzzy".into()` body replacements. +/// +/// Note what this pins: the function ignores its argument and answers "claude" +/// for everything, which is why the parameter is named `_model`. That is +/// correct only because every model in the upgrade chain is a Claude model — +/// if a non-Claude model ever enters the chain, this silently mislabels it. +#[test] +fn every_upgrade_target_is_attributed_to_claude() { + assert_eq!(provider_for_model("claude-sonnet-4-6"), "claude"); + assert_eq!(provider_for_model("opus"), "claude"); + assert_eq!(provider_for_model(""), "claude"); +} + +// NOTE — equivalent mutant, deliberately not chased: in `upgrade_model`, +// `config.max_model == "sonnet" || config.max_model == "haiku"` -> `&&`. +// The early `return None` it guards is redundant with the tier check below it: +// with the cap at "sonnet" or "haiku" the only upgrade target from sonnet is +// opus (tier 3), and `next_tier > max_tier` already rejects that. Both the +// real code and the mutant return None for every input, so nothing can +// distinguish them. + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use crate::config::ModelIntelligenceConfig; + +fn runner_ok(model: &str) -> RunnerOutput { + RunnerOutput { + content: "Here is the implementation you requested.".to_string(), + tool_call_error: false, + output_truncated: false, + model_id: model.to_string(), + input_tokens: 100, + output_tokens: 200, + } +} + +fn runner_empty() -> RunnerOutput { + RunnerOutput { + content: String::new(), + tool_call_error: false, + output_truncated: false, + model_id: "claude-haiku-4-5".to_string(), + input_tokens: 0, + output_tokens: 0, + } +} + +fn runner_refusal() -> RunnerOutput { + RunnerOutput { + content: "I'm unable to complete this task as an AI.".to_string(), + tool_call_error: false, + output_truncated: false, + model_id: "claude-haiku-4-5".to_string(), + input_tokens: 50, + output_tokens: 20, + } +} + +fn haiku_selection() -> ModelSelection { + ModelSelection { + model_id: "claude-haiku-4-5".to_string(), + provider: "claude".to_string(), + reason: "auto_select:Simple".to_string(), + } +} + +fn sonnet_selection() -> ModelSelection { + ModelSelection { + model_id: "claude-sonnet-4-6".to_string(), + provider: "claude".to_string(), + reason: "auto_select:Moderate".to_string(), + } +} + +#[test] +fn ok_response_no_upgrade() { + let q = evaluate_response(&runner_ok("claude-haiku-4-5")); + assert_eq!(q, ResponseQuality::Ok); +} + +#[test] +fn empty_response_is_poor() { + let q = evaluate_response(&runner_empty()); + assert_eq!(q, ResponseQuality::Poor(PoorReason::EmptyResponse)); +} + +#[test] +fn refusal_is_poor() { + let q = evaluate_response(&runner_refusal()); + assert_eq!(q, ResponseQuality::Poor(PoorReason::ModelRefusal)); +} + +#[test] +fn tool_call_error_is_poor() { + let mut out = runner_ok("claude-haiku-4-5"); + out.tool_call_error = true; + let q = evaluate_response(&out); + assert_eq!(q, ResponseQuality::Poor(PoorReason::ToolCallError)); +} + +#[test] +fn haiku_upgrades_to_sonnet() { + let cfg = ModelIntelligenceConfig::default(); + let sel = upgrade_model(&haiku_selection(), &cfg, 0); + assert!(sel.is_some()); + let sel = sel.unwrap(); + assert!(sel.model_id.contains("sonnet"), "got: {}", sel.model_id); +} + +#[test] +fn max_one_upgrade_per_message() { + let cfg = ModelIntelligenceConfig::default(); + let sel = upgrade_model(&haiku_selection(), &cfg, 1); + assert!(sel.is_none(), "upgrade_count=1 should prevent upgrade"); +} + +#[test] +fn sonnet_upgrades_to_opus_when_allowed() { + let cfg = ModelIntelligenceConfig { + max_model: "opus".to_string(), + ..Default::default() + }; + let sel = upgrade_model(&sonnet_selection(), &cfg, 0); + assert!(sel.is_some()); + assert!(sel.unwrap().model_id.contains("opus")); +} + +#[test] +fn sonnet_cannot_upgrade_when_capped_at_sonnet() { + let cfg = ModelIntelligenceConfig { + max_model: "sonnet".to_string(), + ..Default::default() + }; + let sel = upgrade_model(&sonnet_selection(), &cfg, 0); + assert!( + sel.is_none(), + "sonnet capped at sonnet should block upgrade" + ); +} diff --git a/apps/daemon/src/license/mod.rs b/apps/daemon/src/license/mod.rs index fe7882a..e125db8 100644 --- a/apps/daemon/src/license/mod.rs +++ b/apps/daemon/src/license/mod.rs @@ -238,259 +238,6 @@ async fn read_cache_grace(storage: &Storage) -> LicenseInfo { } } +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use crate::storage::LicenseCacheRow; - - fn make_features(relay: bool, auto_switch: bool, clawde_plus: bool) -> Features { - Features { - relay, - auto_switch, - clawde_plus, - } - } - - fn make_row( - tier: &str, - features: &str, - cached_at: &str, - valid_until: &str, - hmac: Option, - ) -> LicenseCacheRow { - LicenseCacheRow { - id: 1, - tier: tier.to_string(), - features: features.to_string(), - cached_at: cached_at.to_string(), - valid_until: valid_until.to_string(), - hmac, - } - } - - fn independent_hmac( - tier: &str, - features_json: &str, - cached_at: &str, - valid_until: &str, - ) -> String { - use sha2::Digest; - - let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); - let key = sha2::Sha256::digest(seed.as_bytes()).to_vec(); - let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length"); - mac.update(tier.as_bytes()); - mac.update(b"|"); - mac.update(features_json.as_bytes()); - mac.update(b"|"); - mac.update(cached_at.as_bytes()); - mac.update(b"|"); - mac.update(valid_until.as_bytes()); - mac.finalize() - .into_bytes() - .iter() - .map(|b| format!("{b:02x}")) - .collect() - } - - #[test] - fn license_info_free_has_exact_defaults() { - let info = LicenseInfo::free(); - - assert_eq!(info.tier, "free"); - assert!(!info.features.relay); - assert!(!info.features.auto_switch); - assert!(!info.features.clawde_plus); - assert_eq!(info.grace_days_remaining, None); - assert!(!info.is_relay_enabled()); - assert!(!info.is_auto_switch_enabled()); - assert!(!info.is_clawde_plus()); - } - - #[test] - fn license_feature_accessors_return_their_own_flags() { - let relay_only = LicenseInfo { - tier: "personal_remote".to_string(), - features: make_features(true, false, false), - grace_days_remaining: Some(2), - }; - assert!(relay_only.is_relay_enabled()); - assert!(!relay_only.is_auto_switch_enabled()); - assert!(!relay_only.is_clawde_plus()); - - let auto_switch_only = LicenseInfo { - tier: "cloud_pro".to_string(), - features: make_features(false, true, false), - grace_days_remaining: None, - }; - assert!(!auto_switch_only.is_relay_enabled()); - assert!(auto_switch_only.is_auto_switch_enabled()); - assert!(!auto_switch_only.is_clawde_plus()); - - let clawde_plus_only = LicenseInfo { - tier: "clawde_plus".to_string(), - features: make_features(false, false, true), - grace_days_remaining: None, - }; - assert!(!clawde_plus_only.is_relay_enabled()); - assert!(!clawde_plus_only.is_auto_switch_enabled()); - assert!(clawde_plus_only.is_clawde_plus()); - } - - #[test] - fn verify_response_deserializes_camel_case_and_exact_grace_days() { - let body = serde_json::json!({ - "tier": "cloud_pro", - "features": { - "relay": true, - "autoSwitch": true, - "clawdePlus": false - }, - "gracePeriod": { - "daysRemaining": 7 - } - }); - - let response: VerifyResponse = serde_json::from_value(body).unwrap(); - - assert_eq!(response.tier, "cloud_pro"); - assert!(response.features.relay); - assert!(response.features.auto_switch); - assert!(!response.features.clawde_plus); - let grace = response.grace_period.unwrap(); - assert_eq!(grace.days_remaining, 7); - } - - #[test] - fn features_deserialization_defaults_missing_clawde_plus_to_false() { - let body = serde_json::json!({ - "relay": true, - "autoSwitch": false - }); - - let features: Features = serde_json::from_value(body).unwrap(); - - assert!(features.relay); - assert!(!features.auto_switch); - assert!(!features.clawde_plus); - } - - #[test] - fn to_hex_uses_lowercase_and_zero_padding_for_each_byte() { - let bytes = [0x00, 0x01, 0x0a, 0x0f, 0x10, 0xab, 0xff]; - - assert_eq!(to_hex(&bytes), "00010a0f10abff"); - } - - #[test] - fn hmac_key_is_sha256_of_versioned_license_cache_seed() { - use sha2::Digest; - - let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); - let expected = sha2::Sha256::digest(seed.as_bytes()).to_vec(); - - assert_eq!(hmac_key(), expected); - } - - #[test] - fn compute_hmac_matches_exact_payload_contract() { - let tier = "cloud_pro"; - let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; - let cached_at = "2026-03-01T00:00:00+00:00"; - let valid_until = "2026-03-02T00:00:00+00:00"; - - let expected = independent_hmac(tier, features_json, cached_at, valid_until); - - assert_eq!( - compute_hmac(tier, features_json, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac("free", features_json, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac(tier, r#"{"relay":false}"#, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac( - tier, - features_json, - "2026-03-01T00:00:01+00:00", - valid_until - ), - expected - ); - assert_ne!( - compute_hmac(tier, features_json, cached_at, "2026-03-02T00:00:01+00:00"), - expected - ); - } - - #[test] - fn verify_hmac_accepts_only_matching_cached_payload() { - let tier = "cloud_pro"; - let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; - let cached_at = "2026-03-01T00:00:00+00:00"; - let valid_until = "2026-03-02T00:00:00+00:00"; - let hmac = compute_hmac(tier, features_json, cached_at, valid_until); - - let valid = make_row( - tier, - features_json, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(verify_hmac(&valid)); - - let missing_hmac = make_row(tier, features_json, cached_at, valid_until, None); - assert!(!verify_hmac(&missing_hmac)); - - let wrong_hmac = make_row( - tier, - features_json, - cached_at, - valid_until, - Some(format!("0{}", &hmac[1..])), - ); - assert!(!verify_hmac(&wrong_hmac)); - - let changed_tier = make_row( - "free", - features_json, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_tier)); - - let changed_features = make_row( - tier, - r#"{"relay":false}"#, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_features)); - - let changed_cached_at = make_row( - tier, - features_json, - "2026-03-01T00:00:01+00:00", - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_cached_at)); - - let changed_valid_until = make_row( - tier, - features_json, - cached_at, - "2026-03-02T00:00:01+00:00", - Some(hmac), - ); - assert!(!verify_hmac(&changed_valid_until)); - } -} +mod tests; diff --git a/apps/daemon/src/license/tests.rs b/apps/daemon/src/license/tests.rs new file mode 100644 index 0000000..df3c9a4 --- /dev/null +++ b/apps/daemon/src/license/tests.rs @@ -0,0 +1,441 @@ +//! Tests for the license cache and its grace window. +//! +//! Written against the surviving-mutant list from the mutation gate. This is +//! the code that decides whether a cached paid tier is still honoured while +//! the licence server is unreachable, so these mutations are not cosmetic: +//! they either hand out paid features from an expired cache, or revoke them +//! from a valid one. + +use super::*; + +async fn storage() -> (tempfile::TempDir, Storage) { + let dir = tempfile::tempdir().unwrap(); + let storage = Storage::new(dir.path()).await.expect("open storage"); + (dir, storage) +} + +fn paid() -> LicenseInfo { + LicenseInfo { + tier: "clawde_plus".to_string(), + features: Features::default(), + grace_days_remaining: None, + } +} + +/// Write a cache row directly, with a correct HMAC, at an arbitrary validity. +async fn seed_cache(storage: &Storage, tier: &str, valid_until: DateTime) { + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let cached_at = (valid_until - Duration::hours(24)).to_rfc3339(); + let valid_until_str = valid_until.to_rfc3339(); + let hmac = compute_hmac(tier, &features_json, &cached_at, &valid_until_str); + storage + .set_license_cache( + tier, + &features_json, + &cached_at, + &valid_until_str, + Some(&hmac), + ) + .await + .unwrap(); +} + +// ─── write_cache ───────────────────────────────────────────────────────────── + +/// Kills the `Ok(())` body replacement and the `now + Duration::hours(24)` +/// -> `-` mutation. +/// +/// The body replacement is the classic `async fn -> Result<()>` mutation: it +/// reports success without writing anything, so the only way to catch it is to +/// read the row back rather than trust the return value. Flipping the `+` to +/// `-` writes a row that expired 24 hours before it was created, making every +/// cached licence useless the instant it is stored. +#[tokio::test] +async fn write_cache_stores_a_row_valid_for_twenty_four_hours() { + let (_dir, storage) = storage().await; + write_cache(&storage, &paid()).await.unwrap(); + + let row = storage + .get_license_cache() + .await + .unwrap() + .expect("a row must actually have been written"); + assert_eq!(row.tier, "clawde_plus"); + + let valid_until = DateTime::parse_from_rfc3339(&row.valid_until) + .unwrap() + .with_timezone(&Utc); + let remaining = valid_until - Utc::now(); + assert!( + remaining > Duration::hours(23) && remaining <= Duration::hours(24), + "expected ~24h of validity, got {remaining}" + ); + + // The row must carry an HMAC that verifies, or read_cache_grace drops it. + assert!(verify_hmac(&row)); +} + +// ─── read_cache_grace ──────────────────────────────────────────────────────── + +/// Kills `Utc::now() < valid_until` -> `>`, `==` and constant `false`, plus +/// the `Default::default()` body replacement. +/// +/// A cache written moments ago is inside its window and must be honoured. +/// Every one of those mutations drops the paid tier on the floor and silently +/// downgrades a paying user to free while their licence is still valid. +#[tokio::test] +async fn a_cache_inside_the_grace_window_is_honoured() { + let (_dir, storage) = storage().await; + write_cache(&storage, &paid()).await.unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "clawde_plus"); +} + +/// Kills `Utc::now() < valid_until` -> constant `true` and -> `>`. +/// +/// This is the direction that matters for the paywall: once the grace window +/// has closed the cached tier must not be served. Both mutations keep +/// honouring a cache that expired a day ago, turning a 24-hour grace period +/// into an unbounded one. +#[tokio::test] +async fn a_cache_past_the_grace_window_is_refused() { + let (_dir, storage) = storage().await; + seed_cache(&storage, "clawde_plus", Utc::now() - Duration::hours(24)).await; + + assert_eq!( + read_cache_grace(&storage).await.tier, + "free", + "an expired cache must not keep granting paid features" + ); +} + +/// Kills the `delete !` mutation on `!verify_hmac(&row)`. +/// +/// A row whose HMAC does not match has been tampered with — someone editing +/// the local database to grant themselves a tier. Dropping the `!` inverts the +/// check, so exactly the forged rows become the trusted ones. +#[tokio::test] +async fn a_cache_row_with_a_bad_hmac_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let valid_until = (Utc::now() + Duration::hours(24)).to_rfc3339(); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &Utc::now().to_rfc3339(), + &valid_until, + Some("deadbeef"), + ) + .await + .unwrap(); + + assert_eq!( + read_cache_grace(&storage).await.tier, + "free", + "a forged cache row must not be trusted" + ); +} + +/// A row with no HMAC at all is treated the same as a forged one. +#[tokio::test] +async fn a_cache_row_with_no_hmac_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let valid_until = (Utc::now() + Duration::hours(24)).to_rfc3339(); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &Utc::now().to_rfc3339(), + &valid_until, + None, + ) + .await + .unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +/// An unparseable expiry must fail closed, not open. +#[tokio::test] +async fn a_cache_row_with_an_unparseable_expiry_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let cached_at = Utc::now().to_rfc3339(); + let hmac = compute_hmac("clawde_plus", &features_json, &cached_at, "not-a-date"); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &cached_at, + "not-a-date", + Some(&hmac), + ) + .await + .unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +/// With no cached row at all the answer is the free tier. +#[tokio::test] +async fn an_empty_cache_yields_the_free_tier() { + let (_dir, storage) = storage().await; + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +// NOTE — equivalent mutant, deliberately not chased: `Utc::now() < valid_until` +// -> `<=`. The two differ only when the current instant equals the stored +// expiry to the nanosecond, which no test can arrange deterministically and no +// real run will hit. It is unkillable rather than uncovered. + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use crate::storage::LicenseCacheRow; + +fn make_features(relay: bool, auto_switch: bool, clawde_plus: bool) -> Features { + Features { + relay, + auto_switch, + clawde_plus, + } +} + +fn make_row( + tier: &str, + features: &str, + cached_at: &str, + valid_until: &str, + hmac: Option, +) -> LicenseCacheRow { + LicenseCacheRow { + id: 1, + tier: tier.to_string(), + features: features.to_string(), + cached_at: cached_at.to_string(), + valid_until: valid_until.to_string(), + hmac, + } +} + +fn independent_hmac(tier: &str, features_json: &str, cached_at: &str, valid_until: &str) -> String { + use sha2::Digest; + + let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); + let key = sha2::Sha256::digest(seed.as_bytes()).to_vec(); + let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length"); + mac.update(tier.as_bytes()); + mac.update(b"|"); + mac.update(features_json.as_bytes()); + mac.update(b"|"); + mac.update(cached_at.as_bytes()); + mac.update(b"|"); + mac.update(valid_until.as_bytes()); + mac.finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +#[test] +fn license_info_free_has_exact_defaults() { + let info = LicenseInfo::free(); + + assert_eq!(info.tier, "free"); + assert!(!info.features.relay); + assert!(!info.features.auto_switch); + assert!(!info.features.clawde_plus); + assert_eq!(info.grace_days_remaining, None); + assert!(!info.is_relay_enabled()); + assert!(!info.is_auto_switch_enabled()); + assert!(!info.is_clawde_plus()); +} + +#[test] +fn license_feature_accessors_return_their_own_flags() { + let relay_only = LicenseInfo { + tier: "personal_remote".to_string(), + features: make_features(true, false, false), + grace_days_remaining: Some(2), + }; + assert!(relay_only.is_relay_enabled()); + assert!(!relay_only.is_auto_switch_enabled()); + assert!(!relay_only.is_clawde_plus()); + + let auto_switch_only = LicenseInfo { + tier: "cloud_pro".to_string(), + features: make_features(false, true, false), + grace_days_remaining: None, + }; + assert!(!auto_switch_only.is_relay_enabled()); + assert!(auto_switch_only.is_auto_switch_enabled()); + assert!(!auto_switch_only.is_clawde_plus()); + + let clawde_plus_only = LicenseInfo { + tier: "clawde_plus".to_string(), + features: make_features(false, false, true), + grace_days_remaining: None, + }; + assert!(!clawde_plus_only.is_relay_enabled()); + assert!(!clawde_plus_only.is_auto_switch_enabled()); + assert!(clawde_plus_only.is_clawde_plus()); +} + +#[test] +fn verify_response_deserializes_camel_case_and_exact_grace_days() { + let body = serde_json::json!({ + "tier": "cloud_pro", + "features": { + "relay": true, + "autoSwitch": true, + "clawdePlus": false + }, + "gracePeriod": { + "daysRemaining": 7 + } + }); + + let response: VerifyResponse = serde_json::from_value(body).unwrap(); + + assert_eq!(response.tier, "cloud_pro"); + assert!(response.features.relay); + assert!(response.features.auto_switch); + assert!(!response.features.clawde_plus); + let grace = response.grace_period.unwrap(); + assert_eq!(grace.days_remaining, 7); +} + +#[test] +fn features_deserialization_defaults_missing_clawde_plus_to_false() { + let body = serde_json::json!({ + "relay": true, + "autoSwitch": false + }); + + let features: Features = serde_json::from_value(body).unwrap(); + + assert!(features.relay); + assert!(!features.auto_switch); + assert!(!features.clawde_plus); +} + +#[test] +fn to_hex_uses_lowercase_and_zero_padding_for_each_byte() { + let bytes = [0x00, 0x01, 0x0a, 0x0f, 0x10, 0xab, 0xff]; + + assert_eq!(to_hex(&bytes), "00010a0f10abff"); +} + +#[test] +fn hmac_key_is_sha256_of_versioned_license_cache_seed() { + use sha2::Digest; + + let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); + let expected = sha2::Sha256::digest(seed.as_bytes()).to_vec(); + + assert_eq!(hmac_key(), expected); +} + +#[test] +fn compute_hmac_matches_exact_payload_contract() { + let tier = "cloud_pro"; + let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; + let cached_at = "2026-03-01T00:00:00+00:00"; + let valid_until = "2026-03-02T00:00:00+00:00"; + + let expected = independent_hmac(tier, features_json, cached_at, valid_until); + + assert_eq!( + compute_hmac(tier, features_json, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac("free", features_json, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac(tier, r#"{"relay":false}"#, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac( + tier, + features_json, + "2026-03-01T00:00:01+00:00", + valid_until + ), + expected + ); + assert_ne!( + compute_hmac(tier, features_json, cached_at, "2026-03-02T00:00:01+00:00"), + expected + ); +} + +#[test] +fn verify_hmac_accepts_only_matching_cached_payload() { + let tier = "cloud_pro"; + let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; + let cached_at = "2026-03-01T00:00:00+00:00"; + let valid_until = "2026-03-02T00:00:00+00:00"; + let hmac = compute_hmac(tier, features_json, cached_at, valid_until); + + let valid = make_row( + tier, + features_json, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(verify_hmac(&valid)); + + let missing_hmac = make_row(tier, features_json, cached_at, valid_until, None); + assert!(!verify_hmac(&missing_hmac)); + + let wrong_hmac = make_row( + tier, + features_json, + cached_at, + valid_until, + Some(format!("0{}", &hmac[1..])), + ); + assert!(!verify_hmac(&wrong_hmac)); + + let changed_tier = make_row( + "free", + features_json, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_tier)); + + let changed_features = make_row( + tier, + r#"{"relay":false}"#, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_features)); + + let changed_cached_at = make_row( + tier, + features_json, + "2026-03-01T00:00:01+00:00", + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_cached_at)); + + let changed_valid_until = make_row( + tier, + features_json, + cached_at, + "2026-03-02T00:00:01+00:00", + Some(hmac), + ); + assert!(!verify_hmac(&changed_valid_until)); +} diff --git a/apps/daemon/src/policy/secrets.rs b/apps/daemon/src/policy/secrets.rs index 9209a9f..9d3ed27 100644 --- a/apps/daemon/src/policy/secrets.rs +++ b/apps/daemon/src/policy/secrets.rs @@ -120,43 +120,6 @@ fn check_string(_tool: &str, s: &str, path: &str) -> Result<(), PolicyViolation> Ok(()) } +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn clean_args_pass() { - let args = json!({ "path": "src/main.rs", "content": "fn main() {}" }); - assert!(check_tool_args("read_file", &args).is_ok()); - } - - #[test] - fn openai_key_in_args_blocked() { - let args = json!({ "key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" }); - let result = check_tool_args("apply_patch", &args); - assert!(result.is_err()); - assert!(matches!( - result, - Err(PolicyViolation::SecretDetected { .. }) - )); - } - - #[test] - fn nested_secret_blocked() { - let args = json!({ - "config": { - "api_key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" - } - }); - let result = check_tool_args("apply_patch", &args); - assert!(result.is_err()); - } - - #[test] - fn aws_key_blocked() { - let args = json!({ "credentials": "AKIAIOSFODNN7EXAMPLE1234" }); - let result = check_tool_args("run_tests", &args); - assert!(result.is_err()); - } -} +mod tests; diff --git a/apps/daemon/src/policy/secrets/tests.rs b/apps/daemon/src/policy/secrets/tests.rs new file mode 100644 index 0000000..08f0d99 --- /dev/null +++ b/apps/daemon/src/policy/secrets/tests.rs @@ -0,0 +1,183 @@ +//! Tests for the tool-argument secret scanner. +//! +//! Written against the surviving-mutant list from the mutation gate. The +//! high-entropy branch needs care: the threshold is 4.5 bits, and a +//! 20-character string carries at most log2(20) = 4.32 bits even when every +//! character is distinct. So the shortest string that can possibly trip that +//! branch is 23 characters. The fixtures below use 32 distinct characters +//! (exactly 5.0 bits) with padding sized to move the measurement across the +//! threshold in a controlled direction. + +use super::*; + +/// 32 distinct alphanumerics — 5.0 bits of entropy, over the 4.5 threshold, +/// and matching none of the six NEVER_EXPOSE_PATTERNS. +const HIGH_ENTROPY: &str = "abcdefghijklmnopqrstuvwxyz012345"; + +fn detected(v: serde_json::Value) -> bool { + check_tool_args("bash", &v).is_err() +} + +// ─── the high-entropy branch ───────────────────────────────────────────────── + +/// Kills `token.len() >= 20` -> `<`. +/// +/// `is_high_entropy` returns false below 20 characters, so `len < 20 && ...` +/// can never be true and the entropy branch stops firing entirely — every +/// credential that is not one of the six literal patterns walks through. +#[test] +fn a_bare_high_entropy_token_is_detected() { + assert!(detected(serde_json::json!({ "arg": HIGH_ENTROPY }))); +} + +/// Kills `>= 20 && is_high_entropy(..)` -> `||`. +/// +/// With `||`, any token of 20+ characters is flagged regardless of entropy, so +/// every long ordinary word becomes a false positive and legitimate tool calls +/// start getting rejected. +#[test] +fn a_long_low_entropy_token_is_not_detected() { + assert!(!detected(serde_json::json!({ "arg": "a".repeat(30) }))); + assert!(!detected( + serde_json::json!({ "arg": "aaaabbbbccccddddeeeeffffgggg" }) + )); +} + +// ─── the trim rule ─────────────────────────────────────────────────────────── + +/// Kills four mutations of the `trim_matches` predicate at once: the +/// `delete !`, both `!=` -> `==` comparisons, and the first `&&` -> `||`. +/// +/// The real predicate trims surrounding punctuation while keeping the base64 +/// alphabet. Wrapped in 40 dots either side, the untrimmed word measures 2.29 +/// bits and would be waved through; trimmed, it is the 5.0-bit token it +/// actually contains. +/// +/// * dropping the `!` trims alphanumerics instead, so the scan stops at the +/// first dot and the token stays padded; +/// * `c == '+'` or `c == '/'` narrows the trim to that one character, so the +/// dots are never removed; +/// * the first `&&` -> `||` makes the predicate true for alphanumerics too, so +/// the token is trimmed away to nothing. +/// +/// All four leave a real credential sitting in the tool arguments. +#[test] +fn a_token_buried_in_punctuation_is_still_detected() { + let padded = format!("{}{}{}", ".".repeat(40), HIGH_ENTROPY, ".".repeat(40)); + assert!(detected(serde_json::json!({ "arg": padded }))); +} + +/// Kills the second `&&` -> `||` in the same predicate. +/// +/// `+` and `/` are deliberately KEPT by the trim: they are part of the base64 +/// alphabet and belong to the token rather than around it. Sixteen leading `+` +/// therefore count towards the measurement and pull it to 4.25 bits, below the +/// threshold. The mutation trims them off, leaving the bare 5.0-bit token, and +/// the scan fires. +/// +/// So this asserts a NEGATIVE, and pins the documented trim rule rather than +/// an ideal outcome. A padded token slipping under the entropy threshold is a +/// real limitation of the heuristic; this test records it, it does not bless +/// it. +#[test] +fn the_base64_alphabet_is_kept_by_the_trim() { + let plus_padded = format!("{}{}", "+".repeat(16), HIGH_ENTROPY); + assert!(!detected(serde_json::json!({ "arg": plus_padded }))); +} + +// ─── recursion into containers ─────────────────────────────────────────────── + +/// Kills the deletion of the `Value::Array` match arm — with it gone, arrays +/// fall through to `_ => {}` and anything inside one is never scanned. +#[test] +fn secrets_inside_arrays_are_detected() { + assert!(detected( + serde_json::json!({ "args": ["ok", HIGH_ENTROPY] }) + )); + // Nested deeper, so the arm must recurse rather than peek one level. + assert!(detected( + serde_json::json!([["ok"], [{ "k": HIGH_ENTROPY }]]) + )); +} + +#[test] +fn secrets_inside_nested_objects_are_detected() { + assert!(detected( + serde_json::json!({ "outer": { "inner": HIGH_ENTROPY } }) + )); +} + +/// The negative direction for the container walk: ordinary structured +/// arguments must pass untouched. +#[test] +fn clean_arguments_of_every_shape_are_allowed() { + assert!(!detected(serde_json::json!({ "command": "cargo test" }))); + assert!(!detected(serde_json::json!({ "files": ["a.rs", "b.rs"] }))); + assert!(!detected( + serde_json::json!({ "n": 42, "ok": true, "x": null }) + )); + assert!(!detected(serde_json::json!({}))); +} + +// ─── the pattern list ──────────────────────────────────────────────────────── + +/// Each NEVER_EXPOSE pattern is pinned separately, so none can be dropped from +/// the list unnoticed. +#[test] +fn every_never_expose_pattern_is_matched() { + let ghp = format!("ghp_{}", "A".repeat(36)); + let pat = format!("github_pat_{}", "A".repeat(82)); + let cases = [ + "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAA", + ghp.as_str(), + pat.as_str(), + "AKIAIOSFODNN7EXAMPLE", + "-----BEGIN RSA PRIVATE KEY-----", + "password: hunter2hunter2", + ]; + for c in cases { + assert!( + detected(serde_json::json!({ "arg": c })), + "should have detected {c:?}" + ); + } +} + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use serde_json::json; + +#[test] +fn clean_args_pass() { + let args = json!({ "path": "src/main.rs", "content": "fn main() {}" }); + assert!(check_tool_args("read_file", &args).is_ok()); +} + +#[test] +fn openai_key_in_args_blocked() { + let args = json!({ "key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" }); + let result = check_tool_args("apply_patch", &args); + assert!(result.is_err()); + assert!(matches!( + result, + Err(PolicyViolation::SecretDetected { .. }) + )); +} + +#[test] +fn nested_secret_blocked() { + let args = json!({ + "config": { + "api_key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" + } + }); + let result = check_tool_args("apply_patch", &args); + assert!(result.is_err()); +} + +#[test] +fn aws_key_blocked() { + let args = json!({ "credentials": "AKIAIOSFODNN7EXAMPLE1234" }); + let result = check_tool_args("run_tests", &args); + assert!(result.is_err()); +}