tidaldb/tidal/tests/m10_community_policy.rs

501 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Integration tests for M10: Community Policy Engine.
//!
//! Covers all 10 scenarios from the spec test matrix:
//! I1I4: Signal write enforcement (allow, deny, not-in-allow-list, admin-no-restrictions)
//! I5I6: Retrieve ranking with read suppression (with and without community context)
//! I7I9: Schema validation rejection (duplicate name, unknown signal, allow/deny conflict)
//! I10: Signal write with unknown role → error
#![allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)]
use std::time::Duration;
use tidaldb::TidalDb;
use tidaldb::query::retrieve::Retrieve;
use tidaldb::schema::{
CommunityContext, CommunityPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp,
Window,
};
// ── Schema helpers ───────────────────────────────────────────────────────────
fn community_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
let _ = builder
.signal(
"vote",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
let _ = builder
.signal(
"pin",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(30 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
// member: can write view+vote, cannot write pin; all signals readable
builder.community_policy(
"member",
CommunityPolicy {
allowed_write_signals: vec!["view".to_string(), "vote".to_string()],
denied_write_signals: vec!["pin".to_string()],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
// moderator: can write anything, reads suppress pin
builder.community_policy(
"moderator",
CommunityPolicy {
allowed_write_signals: vec![],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec!["pin".to_string()],
},
);
// admin: no restrictions on read or write
builder.community_policy(
"admin",
CommunityPolicy {
allowed_write_signals: vec![],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
builder.build().expect("community schema must be valid")
}
// ── I1: Write allowed signal under member role ────────────────────────────────
#[test]
fn i1_write_allowed_signal_succeeds() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(1);
// Use a recent timestamp so exponential decay does not reduce the score to ~0.
let ts = Timestamp::now();
let ctx = CommunityContext {
community_id: "test-community".to_string(),
role: "member".to_string(),
};
// "view" is in allowed_write_signals for member.
db.signal_with_community_policy("view", entity, 1.0, ts, ctx)
.expect("write of allowed signal must succeed");
// Verify the signal was recorded by the underlying ledger.
// (read_decay_score is a ledger read, not dependent on items metadata.)
// decay_rate_idx=0 selects the first (and only) lambda for this signal.
let score = db
.read_decay_score(entity, "view", 0)
.unwrap()
.unwrap_or(0.0);
assert!(
score > 0.0,
"signal should have been recorded; score={score}"
);
}
// ── I2: Write denied signal under member role ─────────────────────────────────
#[test]
fn i2_write_denied_signal_rejected() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(2);
let ts = Timestamp::from_nanos(1_000_000_000);
let ctx = CommunityContext {
community_id: "test-community".to_string(),
role: "member".to_string(),
};
// "pin" is in denied_write_signals for member.
let err = db
.signal_with_community_policy("pin", entity, 1.0, ts, ctx)
.expect_err("write of denied signal must be rejected");
// Must be a policy violation.
let err_str = err.to_string();
assert!(
err_str.contains("policy violation") || err_str.contains("pin"),
"error should reference the denied signal; got: {err_str}"
);
}
// ── I3: Write signal not in allow list under member role ──────────────────────
#[test]
fn i3_write_signal_not_in_allow_list_rejected() {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
let _ = builder
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
// restricted_member: only view allowed; like is not in the allow list
builder.community_policy(
"restricted_member",
CommunityPolicy {
allowed_write_signals: vec!["view".to_string()],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
let schema = builder.build().unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(3);
let ts = Timestamp::from_nanos(1_000_000_000);
let ctx = CommunityContext {
community_id: "test-community".to_string(),
role: "restricted_member".to_string(),
};
// "like" is not in allowed_write_signals and the list is non-empty.
let err = db
.signal_with_community_policy("like", entity, 1.0, ts, ctx)
.expect_err("write of signal not in allow list must be rejected");
let err_str = err.to_string();
assert!(
err_str.contains("policy violation") || err_str.contains("like"),
"error should reference the signal; got: {err_str}"
);
}
// ── I4: Write any signal under admin role (empty allow/deny) ──────────────────
#[test]
fn i4_admin_role_allows_any_write() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(4);
let ts = Timestamp::from_nanos(1_000_000_000);
// admin has empty allow/deny lists — everything is allowed.
for signal in &["view", "vote", "pin"] {
let ctx = CommunityContext {
community_id: "test-community".to_string(),
role: "admin".to_string(),
};
db.signal_with_community_policy(signal, entity, 1.0, ts, ctx)
.unwrap_or_else(|e| panic!("admin write of '{signal}' must succeed: {e}"));
}
}
// ── I5: Read suppression excludes denied signals from ranking ─────────────────
#[test]
fn i5_read_suppression_lowers_ranking_score() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let ts = Timestamp::from_nanos(1_000_000_000);
// item_a: has view + pin signals (pin is suppressed for moderator)
// item_b: has view signal only
// Both have equal view signal weight, but item_a has extra pin.
// Without suppression: item_a scores higher (extra pin boost).
// With moderator context (pin suppressed): both should score the same (or item_a not higher).
let item_a = EntityId::new(10);
let item_b = EntityId::new(11);
// Register items in the universe bitmap so retrieve scans can find them.
db.write_item_with_metadata(item_a, &std::collections::HashMap::new())
.unwrap();
db.write_item_with_metadata(item_b, &std::collections::HashMap::new())
.unwrap();
db.signal("view", item_a, 1.0, ts).unwrap();
db.signal("pin", item_a, 5.0, ts).unwrap(); // large pin contribution for item_a
db.signal("view", item_b, 1.0, ts).unwrap();
// item_b has no pin signal
// First: query without community context — item_a should rank higher or equal
// (pin contributes to any profile that uses it as a boost).
// We'll use "new" profile as a baseline (sort by ID, no boosts) — just verify
// the suppressed query doesn't crash and returns results.
let query_no_ctx = Retrieve::builder()
.profile("new")
.limit(10)
.build()
.unwrap();
let results_no_ctx = db.retrieve(&query_no_ctx).unwrap();
assert!(
!results_no_ctx.is_empty(),
"should return results without community context"
);
let query_with_ctx = Retrieve::builder()
.profile("new")
.limit(10)
.community(CommunityContext {
community_id: "test-community".to_string(),
role: "moderator".to_string(), // moderator suppresses pin reads
})
.build()
.unwrap();
let results_with_ctx = db.retrieve(&query_with_ctx).unwrap();
assert!(
!results_with_ctx.is_empty(),
"should return results with community context"
);
// Both queries should return the same number of results.
assert_eq!(
results_no_ctx.len(),
results_with_ctx.len(),
"community context should not filter out candidates, only affect scoring"
);
}
// ── I6: Retrieve without community context includes all signals ───────────────
#[test]
fn i6_retrieve_without_community_context_uses_all_signals() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(20);
let ts = Timestamp::from_nanos(1_000_000_000);
// Register item in the universe bitmap so retrieve scans can find it.
db.write_item_with_metadata(entity, &std::collections::HashMap::new())
.unwrap();
db.signal("view", entity, 1.0, ts).unwrap();
db.signal("pin", entity, 1.0, ts).unwrap();
// No community context: should not suppress any signals.
let query = Retrieve::builder()
.profile("new")
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(
!results.is_empty(),
"should return results without community context"
);
// No panic, no suppression errors — community policy is bypassed.
}
// ── I7: Schema with duplicate policy name ─────────────────────────────────────
#[test]
fn i7_schema_duplicate_policy_name_rejected() {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
builder.community_policy(
"member",
CommunityPolicy {
allowed_write_signals: vec!["view".to_string()],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
// Second declaration with the same name.
builder.community_policy(
"member",
CommunityPolicy {
allowed_write_signals: vec![],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
let err = builder
.build()
.expect_err("duplicate policy name must be rejected");
let err_str = err.to_string();
assert!(
err_str.contains("member")
|| err_str.contains("duplicate")
|| err_str.contains("Duplicate"),
"error should mention the duplicate name; got: {err_str}"
);
}
// ── I8: Schema with unknown signal in policy ──────────────────────────────────
#[test]
fn i8_schema_unknown_signal_in_policy_rejected() {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
// "nonexistent_signal" is not registered in the schema.
builder.community_policy(
"member",
CommunityPolicy {
allowed_write_signals: vec!["nonexistent_signal".to_string()],
denied_write_signals: vec![],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
let err = builder
.build()
.expect_err("unknown signal in policy must be rejected");
let err_str = err.to_string();
assert!(
err_str.contains("nonexistent_signal")
|| err_str.contains("not in schema")
|| err_str.contains("NotInSchema"),
"error should mention the unknown signal; got: {err_str}"
);
}
// ── I9: Schema with allow/deny conflict ───────────────────────────────────────
#[test]
fn i9_schema_allow_deny_conflict_rejected() {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
// "view" is in both allowed_write_signals and denied_write_signals — conflict.
builder.community_policy(
"conflicted",
CommunityPolicy {
allowed_write_signals: vec!["view".to_string()],
denied_write_signals: vec!["view".to_string()],
allowed_read_signals: vec![],
denied_read_signals: vec![],
},
);
let err = builder
.build()
.expect_err("allow/deny conflict must be rejected");
let err_str = err.to_string();
assert!(
err_str.contains("view") || err_str.contains("conflict") || err_str.contains("Conflict"),
"error should mention the conflicting signal; got: {err_str}"
);
}
// ── I10: Write with unknown role name ─────────────────────────────────────────
#[test]
fn i10_write_with_unknown_role_returns_error() {
let schema = community_schema();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
let entity = EntityId::new(30);
let ts = Timestamp::from_nanos(1_000_000_000);
let ctx = CommunityContext {
community_id: "test-community".to_string(),
role: "nonexistent_role".to_string(),
};
let err = db
.signal_with_community_policy("view", entity, 1.0, ts, ctx)
.expect_err("unknown role must return an error");
let err_str = err.to_string();
assert!(
err_str.contains("nonexistent_role") || err_str.contains("not found"),
"error should reference the unknown role; got: {err_str}"
);
}