mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* refactor(ownership): collapse OwnerId+Identity into UserId with role variants
- Expand UserRole to {Owner, Admin, Regular}
- UserId carries role; methods is_owner()/is_admin()/is_regular()
- Remove From<String>/From<&str> impls (enforces types.md rule)
- Validated construction via new(); from_trusted() for DB-sourced values
Addresses bug pattern from #2561, #2620, #2349 where owner_id silently
round-tripped as String.
* refactor(ownership): address review feedback — id-only equality, persist owner role, doc fixes
- UserId PartialEq/Eq/Hash now compare only `id`, not `role`. Role is
metadata that travels with the identity; two UserIds with the same id
but different roles must be interchangeable as HashMap/HashSet keys
and cache lookup targets. Added a regression test that builds a
HashSet keyed on UserId and asserts cross-role `.contains()`
membership, plus a hash-equality check.
- CLI pairing path now persists the "owner" role string (via
UserRole::Owner.as_db_role()) instead of the hardcoded "admin", so
a reload through UserRole::from_db_role stays Owner rather than
being silently downgraded to Admin.
- Update the feature/pairing approve handler to mirror the refactor:
build UserId via from_trusted + UserRole::from_db_role(&user.role)
instead of the removed OwnerId::from.
- AdminScope doc comment now reflects that Owner also passes
is_admin().
- AdminUser extractor error message now reads "Admin privileges
required (admin or owner)" so the forbidden response matches the
actual gate.
---------
Co-authored-by: Henry Park <henrypark133@gmail.com>
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
//! Integration tests for the ownership model.
|
|
//!
|
|
//! Tests get_or_create_user, migrate_default_owner, tenant isolation, and ChannelPairingStore.
|
|
//! Uses libSQL file-backed tempdir — no PostgreSQL required.
|
|
//!
|
|
//! Note: `new_memory()` does NOT share schema across separate `connect()` calls
|
|
//! in libsql (each call gets its own connection and a new in-memory DB). All
|
|
//! tests here use `new_local` with a `tempfile::TempDir` so all connections
|
|
//! within the same test share the migrated schema.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use ironclaw::db::libsql::LibSqlBackend;
|
|
use ironclaw::db::{ChannelPairingStore, Database, UserRecord, UserStore};
|
|
use ironclaw::ownership::{UserId, UserRole};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Create a file-backed test DB with migrations applied.
|
|
async fn setup_db() -> (LibSqlBackend, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create temp dir");
|
|
let db_path = dir.path().join("ownership_test.db");
|
|
let db = LibSqlBackend::new_local(&db_path)
|
|
.await
|
|
.expect("test DB creation failed");
|
|
db.run_migrations().await.expect("run migrations");
|
|
(db, dir)
|
|
}
|
|
|
|
async fn create_user(db: &LibSqlBackend, id: &str, role: &str) {
|
|
db.get_or_create_user(UserRecord {
|
|
id: id.to_string(),
|
|
role: role.to_string(),
|
|
display_name: id.to_string(),
|
|
status: "active".to_string(),
|
|
email: None,
|
|
last_login_at: None,
|
|
created_by: None,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
metadata: serde_json::Value::Null,
|
|
})
|
|
.await
|
|
.expect("user creation failed");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Bootstrap tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn test_bootstrap_creates_owner_user() {
|
|
let (db, _dir) = setup_db().await;
|
|
|
|
// Owner does not exist yet
|
|
assert!(db.get_user("henry").await.unwrap().is_none());
|
|
|
|
// Create the owner via get_or_create_user (atomic upsert)
|
|
create_user(&db, "henry", "admin").await;
|
|
|
|
let user = db
|
|
.get_user("henry")
|
|
.await
|
|
.unwrap()
|
|
.expect("owner should exist");
|
|
assert_eq!(user.id, "henry");
|
|
assert_eq!(user.role, "admin");
|
|
assert_eq!(user.status, "active");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bootstrap_get_or_create_is_idempotent() {
|
|
let (db, _dir) = setup_db().await;
|
|
|
|
// Call twice — should not error or duplicate
|
|
create_user(&db, "henry", "admin").await;
|
|
create_user(&db, "henry", "admin").await;
|
|
|
|
// Exactly one row
|
|
let user = db
|
|
.get_user("henry")
|
|
.await
|
|
.unwrap()
|
|
.expect("owner should exist");
|
|
assert_eq!(user.id, "henry");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bootstrap_rewrites_default_user_id() {
|
|
let (db, _dir) = setup_db().await;
|
|
|
|
// Create a 'default' user (the pre-ownership placeholder)
|
|
create_user(&db, "default", "member").await;
|
|
|
|
// Insert a settings row with user_id = 'default'
|
|
{
|
|
let conn = db.connect().await.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO settings (user_id, key, value, updated_at) \
|
|
VALUES ('default', 'test_migration_key', '\"test_value\"', \
|
|
strftime('%Y-%m-%dT%H:%M:%fZ','now'))",
|
|
(),
|
|
)
|
|
.await
|
|
.expect("insert settings row");
|
|
}
|
|
|
|
// Create the real owner
|
|
create_user(&db, "henry", "admin").await;
|
|
|
|
// Run migrate_default_owner
|
|
db.migrate_default_owner("henry").await.unwrap();
|
|
|
|
// The settings row should now be under 'henry'
|
|
let conn = db.connect().await.unwrap();
|
|
let mut rows = conn
|
|
.query(
|
|
"SELECT user_id FROM settings WHERE key = 'test_migration_key'",
|
|
(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let row = rows.next().await.unwrap().expect("row should exist");
|
|
let user_id: String = row.get(0).unwrap();
|
|
assert_eq!(
|
|
user_id, "henry",
|
|
"migrate_default_owner should rewrite 'default' to real owner"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migrate_default_owner_is_idempotent() {
|
|
let (db, _dir) = setup_db().await;
|
|
create_user(&db, "henry", "admin").await;
|
|
|
|
// Run twice — should not error
|
|
db.migrate_default_owner("owner-bootstrap-test")
|
|
.await
|
|
.unwrap();
|
|
db.migrate_default_owner("henry").await.unwrap();
|
|
|
|
// Still exactly one henry row
|
|
let user = db.get_user("henry").await.unwrap();
|
|
assert!(user.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migrate_default_owner_no_default_rows() {
|
|
let (db, _dir) = setup_db().await;
|
|
create_user(&db, "henry", "admin").await;
|
|
|
|
// No 'default' rows to migrate — should succeed without error
|
|
db.migrate_default_owner("henry").await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migrate_default_owner_succeeds_on_fresh_migrated_db() {
|
|
let (db, _dir) = setup_db().await;
|
|
|
|
// Fresh installs include ownerless tables like `dynamic_tools`; the
|
|
// bootstrap rewrite should still succeed without assuming every table
|
|
// in the schema carries a `user_id` column.
|
|
db.migrate_default_owner("henry").await.unwrap();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// TenantScope isolation tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn test_tenant_scope_isolates_settings_by_user() {
|
|
let (backend, _dir) = setup_db().await;
|
|
let db: Arc<dyn Database> = Arc::new(backend);
|
|
|
|
// Use TenantScope::new (the legacy bridge, avoids needing LibSqlBackend directly)
|
|
// libSQL FK enforcement is off by default, so users table rows are not required here.
|
|
let alice_scope = ironclaw::tenant::TenantScope::new("alice", Arc::clone(&db));
|
|
alice_scope
|
|
.set_setting("theme", &serde_json::json!("dark"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Bob's scope should not see Alice's setting
|
|
let bob_scope = ironclaw::tenant::TenantScope::new("bob", Arc::clone(&db));
|
|
let bobs_theme = bob_scope.get_setting("theme").await.unwrap();
|
|
assert!(
|
|
bobs_theme.is_none(),
|
|
"Bob should not see Alice's settings, got: {bobs_theme:?}"
|
|
);
|
|
|
|
// Alice can read her own setting
|
|
let alices_theme = alice_scope.get_setting("theme").await.unwrap();
|
|
assert_eq!(alices_theme, Some(serde_json::json!("dark")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tenant_scope_with_identity() {
|
|
let (backend, _dir) = setup_db().await;
|
|
let db: Arc<dyn Database> = Arc::new(backend);
|
|
|
|
let alice_identity = UserId::from_trusted("alice".into(), UserRole::Regular);
|
|
let alice_scope =
|
|
ironclaw::tenant::TenantScope::with_identity(alice_identity, Arc::clone(&db));
|
|
alice_scope
|
|
.set_setting("lang", &serde_json::json!("en"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Bob uses UserId with Admin role — still cannot see Alice's setting
|
|
let bob_identity = UserId::from_trusted("bob".into(), UserRole::Admin);
|
|
let bob_scope = ironclaw::tenant::TenantScope::with_identity(bob_identity, Arc::clone(&db));
|
|
let result = bob_scope.get_setting("lang").await.unwrap();
|
|
assert!(
|
|
result.is_none(),
|
|
"Admin role must not bypass per-user setting isolation"
|
|
);
|
|
|
|
// Alice sees her own setting
|
|
let alice_identity2 = UserId::from_trusted("alice".into(), UserRole::Regular);
|
|
let alice_scope2 =
|
|
ironclaw::tenant::TenantScope::with_identity(alice_identity2, Arc::clone(&db));
|
|
let alices = alice_scope2.get_setting("lang").await.unwrap();
|
|
assert_eq!(alices, Some(serde_json::json!("en")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tenant_scope_user_id_accessor() {
|
|
let (backend, _dir) = setup_db().await;
|
|
let db: Arc<dyn Database> = Arc::new(backend);
|
|
|
|
let identity = UserId::from_trusted("henry".into(), UserRole::Admin);
|
|
let scope = ironclaw::tenant::TenantScope::with_identity(identity, db);
|
|
assert_eq!(scope.user_id(), "henry");
|
|
assert_eq!(scope.identity().as_str(), "henry");
|
|
assert_eq!(scope.identity().role(), UserRole::Admin);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// ChannelPairingStore tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_different_users_are_independent() {
|
|
let (db, _dir) = setup_db().await;
|
|
create_user(&db, "alice", "member").await;
|
|
create_user(&db, "bob", "member").await;
|
|
|
|
let req_a = db
|
|
.upsert_pairing_request("telegram", "tg-alice", None)
|
|
.await
|
|
.unwrap();
|
|
let req_b = db
|
|
.upsert_pairing_request("telegram", "tg-bob", None)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.approve_pairing("telegram", &req_a.code, "alice")
|
|
.await
|
|
.unwrap();
|
|
db.approve_pairing("telegram", &req_b.code, "bob")
|
|
.await
|
|
.unwrap();
|
|
|
|
let alice_id = db
|
|
.resolve_channel_identity("telegram", "tg-alice")
|
|
.await
|
|
.unwrap()
|
|
.expect("alice should be linked");
|
|
let bob_id = db
|
|
.resolve_channel_identity("telegram", "tg-bob")
|
|
.await
|
|
.unwrap()
|
|
.expect("bob should be linked");
|
|
|
|
assert_eq!(alice_id.as_str(), "alice");
|
|
assert_eq!(bob_id.as_str(), "bob");
|
|
assert_ne!(alice_id, bob_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_channels_are_isolated() {
|
|
let (db, _dir) = setup_db().await;
|
|
create_user(&db, "alice", "member").await;
|
|
|
|
// Same external_id across two different channels
|
|
let req_telegram = db
|
|
.upsert_pairing_request("telegram", "user-999", None)
|
|
.await
|
|
.unwrap();
|
|
let _req_slack = db
|
|
.upsert_pairing_request("slack", "user-999", None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Approve only telegram
|
|
db.approve_pairing("telegram", &req_telegram.code, "alice")
|
|
.await
|
|
.unwrap();
|
|
|
|
// telegram resolves; slack does not
|
|
assert!(
|
|
db.resolve_channel_identity("telegram", "user-999")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
db.resolve_channel_identity("slack", "user-999")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// UserId unit-level sanity
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_user_id_equality_and_display() {
|
|
let a = UserId::from_trusted("alice".into(), UserRole::Regular);
|
|
let b = UserId::from_trusted("alice".into(), UserRole::Regular);
|
|
let c = UserId::from_trusted("bob".into(), UserRole::Regular);
|
|
|
|
assert_eq!(a, b);
|
|
assert_ne!(a, c);
|
|
assert_eq!(a.as_str(), "alice");
|
|
assert_eq!(a.to_string(), "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_id_new_validates() {
|
|
assert!(UserId::new("", UserRole::Regular).is_err());
|
|
assert!(UserId::new(" ", UserRole::Regular).is_err());
|
|
assert!(UserId::new("alice", UserRole::Owner).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_role_from_db_role_covers_owner_admin_regular() {
|
|
assert_eq!(UserRole::from_db_role("owner"), UserRole::Owner);
|
|
assert_eq!(UserRole::from_db_role("admin"), UserRole::Admin);
|
|
assert_eq!(UserRole::from_db_role("regular"), UserRole::Regular);
|
|
// Legacy value from the old two-variant enum must keep deserializing.
|
|
assert_eq!(UserRole::from_db_role("member"), UserRole::Regular);
|
|
}
|
|
|
|
#[test]
|
|
fn test_owner_is_admin_regular_is_not() {
|
|
let owner = UserId::from_trusted("root".into(), UserRole::Owner);
|
|
assert!(owner.is_admin(), "Owner must satisfy is_admin()");
|
|
assert!(owner.is_owner());
|
|
assert!(!owner.is_regular());
|
|
|
|
let reg = UserId::from_trusted("alice".into(), UserRole::Regular);
|
|
assert!(!reg.is_admin());
|
|
assert!(!reg.is_owner());
|
|
assert!(reg.is_regular());
|
|
}
|
|
|
|
#[test]
|
|
fn test_owned_trait_is_owned_by() {
|
|
use ironclaw::ownership::Owned;
|
|
|
|
struct TestResource {
|
|
user_id: String,
|
|
}
|
|
impl Owned for TestResource {
|
|
fn owner_user_id(&self) -> &str {
|
|
&self.user_id
|
|
}
|
|
}
|
|
|
|
let r = TestResource {
|
|
user_id: "alice".to_string(),
|
|
};
|
|
assert!(r.is_owned_by("alice"));
|
|
assert!(!r.is_owned_by("bob"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_owned_sandbox_job_record() {
|
|
use ironclaw::history::SandboxJobRecord;
|
|
use ironclaw::ownership::Owned;
|
|
|
|
let job = SandboxJobRecord {
|
|
id: uuid::Uuid::new_v4(),
|
|
task: "test".to_string(),
|
|
status: "running".to_string(),
|
|
user_id: "alice".to_string(),
|
|
project_dir: "/tmp/test".to_string(),
|
|
success: None,
|
|
failure_reason: None,
|
|
created_at: chrono::Utc::now(),
|
|
started_at: None,
|
|
completed_at: None,
|
|
credential_grants_json: "[]".to_string(),
|
|
mcp_servers: None,
|
|
max_iterations: None,
|
|
};
|
|
assert_eq!(job.owner_user_id(), "alice");
|
|
assert!(job.is_owned_by("alice"));
|
|
assert!(!job.is_owned_by("bob"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_owned_agent_job_record() {
|
|
use ironclaw::history::AgentJobRecord;
|
|
use ironclaw::ownership::Owned;
|
|
|
|
let job = AgentJobRecord {
|
|
id: uuid::Uuid::new_v4(),
|
|
title: "test job".to_string(),
|
|
status: "pending".to_string(),
|
|
user_id: "henry".to_string(),
|
|
created_at: chrono::Utc::now(),
|
|
started_at: None,
|
|
completed_at: None,
|
|
failure_reason: None,
|
|
};
|
|
assert_eq!(job.owner_user_id(), "henry");
|
|
assert!(job.is_owned_by("henry"));
|
|
assert!(!job.is_owned_by("alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_owned_routine() {
|
|
use ironclaw::agent::routine::{
|
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
|
};
|
|
use ironclaw::ownership::Owned;
|
|
|
|
let routine = Routine {
|
|
id: uuid::Uuid::new_v4(),
|
|
name: "test-routine".to_string(),
|
|
description: String::new(),
|
|
user_id: "bob".to_string(),
|
|
enabled: true,
|
|
trigger: Trigger::Manual,
|
|
action: RoutineAction::Lightweight {
|
|
prompt: "test prompt".to_string(),
|
|
context_paths: Vec::new(),
|
|
max_tokens: 4096,
|
|
use_tools: false,
|
|
max_tool_rounds: 3,
|
|
},
|
|
guardrails: RoutineGuardrails::default(),
|
|
notify: NotifyConfig::default(),
|
|
last_run_at: None,
|
|
next_fire_at: None,
|
|
run_count: 0,
|
|
consecutive_failures: 0,
|
|
state: serde_json::json!({}),
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
};
|
|
assert_eq!(routine.owner_user_id(), "bob");
|
|
assert!(routine.is_owned_by("bob"));
|
|
assert!(!routine.is_owned_by("alice"));
|
|
}
|
|
}
|