Files
ironclaw/tests/ownership_integration.rs
Illia Polosukhin fb4fc829e1 refactor(ownership): collapse OwnerId+Identity into UserId with role variants (#2677)
* 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>
2026-04-20 12:31:37 +09:00

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"));
}
}