526 lines
17 KiB
Rust
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
|
|
);
|
|
}
|