tidaldb/tidal/tests/m10_agent_capability.rs

526 lines
17 KiB
Rust

//! 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<PolicyViolationKind> {
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
);
}