//! Integration tests for M10: Agent Capability Boundaries. //! //! Covers all acceptance criteria from the spec: //! 1. Signal read allow list: view → Ok, like → ReadNotAllowed //! 2. Signal read deny list: hide → ReadDenied //! 3. Non-session read: unrestricted //! 4. User attribute read allow list: locale → Ok, age_range → AttributeReadNotAllowed //! 5. User attribute read deny list: email → AttributeReadDenied //! 6. Profile override allowed: search → Ok //! 7. Profile override disallowed: for_you → ProfileOverrideNotAllowed //! 8. overrides_rejected incremented after one disallowed override //! 9. signals_rejected incremented after read denial //! 10. Audit log records read denials //! 11. Empty-policy regression: all reads Ok, existing behavior unchanged //! 12. Schema build failure: unknown signal in allowed_read_signals //! 13. Schema build failure: same signal in allow and deny //! 14. Schema build failure: unknown profile in allowed_profile_overrides //! 15. Sentinel expansion: ["*"] → all profiles allowed #![allow(clippy::unwrap_used, clippy::float_cmp)] use std::collections::HashMap; use std::time::Duration; use tidaldb::query::retrieve::Retrieve; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; use tidaldb::{AgentPolicy, AuditKind, PolicyViolationKind, TidalDb, TidalError}; // ── Schema helpers ──────────────────────────────────────────────────────────── fn make_schema_with_policies() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); // Three signals for testing let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::AllTime]) .velocity(false) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(14 * 24 * 3600), }, ) .windows(&[Window::AllTime]) .velocity(false) .add(); let _ = builder .signal("hide", EntityKind::Item, DecaySpec::Permanent) .add(); // Policy: restricted read (only view is readable, hide is denied) builder.session_policy( "read_restricted", AgentPolicy { allowed_read_signals: vec!["view".to_string()], denied_read_signals: vec!["hide".to_string()], ..AgentPolicy::default() }, ); // Policy: restricted user attributes builder.session_policy( "attr_restricted", AgentPolicy { allowed_user_attributes: vec!["locale".to_string()], denied_user_attributes: vec!["email".to_string()], ..AgentPolicy::default() }, ); // Policy: profile override allowed for "trending" and "search" (builtins) builder.session_policy( "profile_restricted", AgentPolicy { allowed_profile_overrides: vec!["search".to_string(), "trending".to_string()], ..AgentPolicy::default() }, ); // Policy: unrestricted (all new fields empty — regression) builder.session_policy("unrestricted", AgentPolicy::default()); // Policy: wildcard profile override builder .declare_profile_names(["trending", "search", "for_you", "hot", "new"]) .session_policy( "star_overrides", AgentPolicy { allowed_profile_overrides: vec!["*".to_string()], ..AgentPolicy::default() }, ); builder.build().expect("schema must be valid") } fn open_db() -> TidalDb { TidalDb::builder() .ephemeral() .with_schema(make_schema_with_policies()) .open() .unwrap() } // ── Helper to extract PolicyViolationKind from a read error ────────────────── fn violation_kind_from_err(err: &TidalError) -> Option { match err { TidalError::PolicyViolation { reason, .. } => { // Determine kind from reason string content. if reason.contains("denied_read_signals") { Some(PolicyViolationKind::ReadDenied) } else if reason.contains("allowed_read_signals") { Some(PolicyViolationKind::ReadNotAllowed) } else if reason.contains("denied_user_attributes") { Some(PolicyViolationKind::AttributeReadDenied) } else if reason.contains("allowed_user_attributes") { Some(PolicyViolationKind::AttributeReadNotAllowed) } else if reason.contains("profile override") { Some(PolicyViolationKind::ProfileOverrideNotAllowed) } else { None } } _ => None, } } // ── Test 1: Signal read allow list ─────────────────────────────────────────── #[test] fn read_allowed_signal_succeeds() { let db = open_db(); // Write a signal first so there's something to read. db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); let handle = db .start_session(1, "agent", "read_restricted", HashMap::new()) .unwrap(); let result = db.read_decay_score_for_session(handle.id, EntityId::new(1), "view", 0); assert!(result.is_ok(), "view should be readable: {:?}", result); } #[test] fn read_disallowed_signal_fails_not_allowed() { let db = open_db(); db.signal("like", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); let handle = db .start_session(1, "agent", "read_restricted", HashMap::new()) .unwrap(); let err = db .read_decay_score_for_session(handle.id, EntityId::new(1), "like", 0) .unwrap_err(); assert!( matches!(&err, TidalError::PolicyViolation { .. }), "expected PolicyViolation, got: {err:?}" ); assert_eq!( violation_kind_from_err(&err), Some(PolicyViolationKind::ReadNotAllowed), "expected ReadNotAllowed, got: {err:?}" ); } // ── Test 2: Signal read deny list ──────────────────────────────────────────── #[test] fn read_denied_signal_fails_denied() { let db = open_db(); // hide is Permanent, so we don't need to write it to test the policy. let handle = db .start_session(1, "agent", "read_restricted", HashMap::new()) .unwrap(); let err = db .read_decay_score_for_session(handle.id, EntityId::new(1), "hide", 0) .unwrap_err(); assert!(matches!(&err, TidalError::PolicyViolation { .. })); assert_eq!( violation_kind_from_err(&err), Some(PolicyViolationKind::ReadDenied), "expected ReadDenied, got: {err:?}" ); } // ── Test 3: Non-session read is unrestricted ────────────────────────────────── #[test] fn read_without_session_unrestricted() { let db = open_db(); // Reading hide without a session is always allowed. let result = db.read_decay_score(EntityId::new(1), "hide", 0); assert!( result.is_ok(), "non-session read must not be gated: {:?}", result ); } // ── Test 4: User attribute read allow list ──────────────────────────────────── #[test] fn attribute_read_allowed_succeeds() { let db = open_db(); // Write user metadata with "locale". let user_id = EntityId::new(42); let mut meta = HashMap::new(); meta.insert("locale".to_string(), "en-US".to_string()); db.write_user(user_id, &meta).unwrap(); let handle = db .start_session(42, "agent", "attr_restricted", HashMap::new()) .unwrap(); let result = db.read_user_attribute_for_session(handle.id, user_id, "locale"); assert!(result.is_ok(), "locale should be readable: {:?}", result); assert_eq!(result.unwrap(), Some("en-US".to_string())); } #[test] fn attribute_read_disallowed_fails() { let db = open_db(); let user_id = EntityId::new(43); let mut meta = HashMap::new(); meta.insert("age_range".to_string(), "25-34".to_string()); db.write_user(user_id, &meta).unwrap(); let handle = db .start_session(43, "agent", "attr_restricted", HashMap::new()) .unwrap(); let err = db .read_user_attribute_for_session(handle.id, user_id, "age_range") .unwrap_err(); assert!(matches!(&err, TidalError::PolicyViolation { .. })); assert_eq!( violation_kind_from_err(&err), Some(PolicyViolationKind::AttributeReadNotAllowed), "expected AttributeReadNotAllowed, got: {err:?}" ); } // ── Test 5: User attribute deny list ───────────────────────────────────────── #[test] fn attribute_read_denied_by_deny_list() { let db = open_db(); let user_id = EntityId::new(44); let mut meta = HashMap::new(); meta.insert("email".to_string(), "user@example.com".to_string()); db.write_user(user_id, &meta).unwrap(); let handle = db .start_session(44, "agent", "attr_restricted", HashMap::new()) .unwrap(); let err = db .read_user_attribute_for_session(handle.id, user_id, "email") .unwrap_err(); assert!(matches!(&err, TidalError::PolicyViolation { .. })); assert_eq!( violation_kind_from_err(&err), Some(PolicyViolationKind::AttributeReadDenied), "expected AttributeReadDenied, got: {err:?}" ); } // ── Test 6: Profile override allowed ───────────────────────────────────────── #[test] fn profile_override_allowed_proceeds() { let db = open_db(); // Write an item so retrieve has something to return. db.write_item(EntityId::new(1), &HashMap::new()).unwrap(); let handle = db .start_session(1, "agent", "profile_restricted", HashMap::new()) .unwrap(); let query = Retrieve::builder() .profile("search") .for_session(handle.id) .limit(10) .build() .unwrap(); let result = db.retrieve(&query); assert!( result.is_ok(), "allowed profile override must succeed: {:?}", result ); } // ── Test 7: Profile override disallowed ────────────────────────────────────── #[test] fn profile_override_disallowed_fails() { let db = open_db(); let handle = db .start_session(1, "agent", "profile_restricted", HashMap::new()) .unwrap(); let query = Retrieve::builder() .profile("for_you") .for_session(handle.id) .limit(10) .build() .unwrap(); let err = db.retrieve(&query).unwrap_err(); assert!(matches!(&err, TidalError::PolicyViolation { .. })); assert_eq!( violation_kind_from_err(&err), Some(PolicyViolationKind::ProfileOverrideNotAllowed), "expected ProfileOverrideNotAllowed, got: {err:?}" ); } // ── Test 8: overrides_rejected counter incremented ─────────────────────────── #[test] fn overrides_rejected_incremented() { let db = open_db(); let handle = db .start_session(1, "agent", "profile_restricted", HashMap::new()) .unwrap(); let session_id = handle.id; let query = Retrieve::builder() .profile("for_you") .for_session(session_id) .limit(10) .build() .unwrap(); let _ = db.retrieve(&query); // intentional violation let snap = db.session_snapshot(session_id).unwrap(); assert_eq!( snap.overrides_rejected, 1, "overrides_rejected must be 1 after one violation" ); } // ── Test 9: signals_rejected incremented on read denial ────────────────────── #[test] fn signals_rejected_incremented_on_read_denial() { let db = open_db(); let handle = db .start_session(1, "agent", "read_restricted", HashMap::new()) .unwrap(); let session_id = handle.id; // Trigger a ReadNotAllowed violation. let _ = db.read_decay_score_for_session(session_id, EntityId::new(1), "like", 0); let snap = db.session_snapshot(session_id).unwrap(); assert!( snap.signals_rejected >= 1, "signals_rejected must be incremented" ); } // ── Test 10: Audit log records read denials ─────────────────────────────────── #[test] fn audit_log_records_read_denial() { let db = open_db(); let handle = db .start_session(1, "agent", "read_restricted", HashMap::new()) .unwrap(); let session_id = handle.id; let _ = db.read_decay_score_for_session(session_id, EntityId::new(1), "hide", 0); let audit = db.session_audit(session_id).unwrap(); let denial = audit .iter() .find(|e| e.kind == AuditKind::ReadDenied) .expect("audit log must contain a ReadDenied entry"); assert!(!denial.accepted); assert_eq!(denial.signal_type, "hide"); } // ── Test 11: Empty-policy regression ───────────────────────────────────────── #[test] fn empty_policy_no_regression() { let db = open_db(); db.signal("like", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); let handle = db .start_session(1, "agent", "unrestricted", HashMap::new()) .unwrap(); // All reads succeed for an unrestricted policy. assert!( db.read_decay_score_for_session(handle.id, EntityId::new(1), "view", 0) .is_ok() ); assert!( db.read_decay_score_for_session(handle.id, EntityId::new(1), "like", 0) .is_ok() ); assert!( db.read_decay_score_for_session(handle.id, EntityId::new(1), "hide", 0) .is_ok() ); } // ── Test 12: Schema build failure: unknown signal in allowed_read_signals ───── #[test] fn schema_build_fails_unknown_read_signal() { let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.session_policy( "bad_policy", AgentPolicy { allowed_read_signals: vec!["nonexistent".to_string()], ..AgentPolicy::default() }, ); let result = builder.build(); assert!( result.is_err(), "build must fail for unknown signal in allowed_read_signals" ); } // ── Test 13: Schema build failure: conflict ─────────────────────────────────── #[test] fn schema_build_fails_read_signal_conflict() { let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder.session_policy( "bad_policy", AgentPolicy { allowed_read_signals: vec!["view".to_string()], denied_read_signals: vec!["view".to_string()], ..AgentPolicy::default() }, ); let result = builder.build(); assert!( result.is_err(), "build must fail when signal is in both allow and deny read lists" ); } // ── Test 14: Schema build failure: unknown profile override ─────────────────── #[test] fn schema_build_fails_unknown_profile_override() { let mut builder = SchemaBuilder::new(); let _ = builder .signal("view", EntityKind::Item, DecaySpec::Permanent) .add(); builder .declare_profile_names(["trending", "search"]) .session_policy( "bad_policy", AgentPolicy { allowed_profile_overrides: vec!["made_up_profile".to_string()], ..AgentPolicy::default() }, ); let result = builder.build(); assert!( result.is_err(), "build must fail for unknown profile in allowed_profile_overrides" ); } // ── Test 15: Sentinel "*" expansion ────────────────────────────────────────── #[test] fn schema_sentinel_star_allows_all_profiles() { let db = open_db(); db.write_item(EntityId::new(1), &HashMap::new()).unwrap(); let handle = db .start_session(1, "agent", "star_overrides", HashMap::new()) .unwrap(); // Any profile in the known set should work. let query = Retrieve::builder() .profile("for_you") .for_session(handle.id) .limit(10) .build() .unwrap(); let result = db.retrieve(&query); assert!( result.is_ok(), "sentinel '*' should expand to allow all known profiles: {:?}", result ); }