501 lines
16 KiB
Rust
501 lines
16 KiB
Rust
//! Integration tests for M10: Community Policy Engine.
|
||
//!
|
||
//! Covers all 10 scenarios from the spec test matrix:
|
||
//! I1–I4: Signal write enforcement (allow, deny, not-in-allow-list, admin-no-restrictions)
|
||
//! I5–I6: Retrieve ranking with read suppression (with and without community context)
|
||
//! I7–I9: 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}"
|
||
);
|
||
}
|