mirror of
https://github.com/nearai/ironclaw.git
synced 2026-05-06 13:40:51 +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>
236 lines
7.5 KiB
Rust
236 lines
7.5 KiB
Rust
//! Integration tests for the DB-backed DM pairing flow.
|
|
//!
|
|
//! Verifies the full pairing lifecycle: upsert → list → approve → resolve_identity.
|
|
//! Uses libSQL file-backed tempdir for isolation.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use ironclaw::cli::{PairingCommand, run_pairing_command_with_store};
|
|
use ironclaw::db::libsql::LibSqlBackend;
|
|
use ironclaw::db::{Database, UserRecord};
|
|
use ironclaw::ownership::{OwnershipCache, UserId, UserRole};
|
|
use ironclaw::pairing::PairingStore;
|
|
|
|
async fn setup_db() -> (Arc<dyn Database>, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db_path = dir.path().join("pairing_test.db");
|
|
let db = LibSqlBackend::new_local(&db_path).await.unwrap();
|
|
db.run_migrations().await.unwrap();
|
|
(Arc::new(db), dir)
|
|
}
|
|
|
|
async fn setup_db_with_user(user_id: &str) -> (Arc<dyn Database>, tempfile::TempDir) {
|
|
let (db, dir) = setup_db().await;
|
|
db.get_or_create_user(UserRecord {
|
|
id: user_id.to_string(),
|
|
role: "member".to_string(),
|
|
display_name: user_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
|
|
.unwrap();
|
|
(db, dir)
|
|
}
|
|
|
|
fn make_store(db: Arc<dyn Database>) -> PairingStore {
|
|
let cache = Arc::new(OwnershipCache::new());
|
|
PairingStore::new(db, cache)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_flow_unknown_user_to_approved() {
|
|
let (db, _dir) = setup_db_with_user("owner_1").await;
|
|
let store = make_store(Arc::clone(&db));
|
|
let channel = "telegram";
|
|
let owner_id = UserId::from_trusted("owner_1".into(), UserRole::Regular);
|
|
|
|
// 1. Unknown user sends first message -> upsert creates request
|
|
let r1 = store
|
|
.upsert_request(
|
|
channel,
|
|
"user_12345",
|
|
Some(serde_json::json!({
|
|
"chat_id": 999,
|
|
"username": "alice"
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(!r1.code.is_empty());
|
|
assert_eq!(r1.code.len(), 8);
|
|
|
|
// 2. List pending shows the request
|
|
let pending = store.list_pending(channel).await.unwrap();
|
|
assert_eq!(pending.len(), 1);
|
|
assert_eq!(pending[0].external_id, "user_12345");
|
|
assert_eq!(pending[0].code, r1.code);
|
|
|
|
// 3. User identity is not resolved yet
|
|
assert!(
|
|
store
|
|
.resolve_identity(channel, "user_12345")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
|
|
// 4. Approve via code
|
|
store.approve(channel, &r1.code, &owner_id).await.unwrap();
|
|
|
|
// 5. User identity now resolves
|
|
let identity = store.resolve_identity(channel, "user_12345").await.unwrap();
|
|
assert!(identity.is_some());
|
|
assert_eq!(identity.unwrap().as_str(), "owner_1");
|
|
|
|
// 6. Pending list is empty
|
|
let pending_after = store.list_pending(channel).await.unwrap();
|
|
assert!(pending_after.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_flow_cli_approve() {
|
|
let (db, _dir) = setup_db_with_user("owner_cli").await;
|
|
let store = make_store(Arc::clone(&db));
|
|
let owner_id = UserId::from_trusted("owner_cli".into(), UserRole::Regular);
|
|
|
|
store
|
|
.upsert_request("telegram", "user_999", None)
|
|
.await
|
|
.unwrap();
|
|
let pending = store.list_pending("telegram").await.unwrap();
|
|
let code = pending[0].code.clone();
|
|
|
|
let result = run_pairing_command_with_store(
|
|
&store,
|
|
&owner_id,
|
|
PairingCommand::Approve {
|
|
channel: "telegram".to_string(),
|
|
code,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(result.is_ok());
|
|
|
|
let identity = store
|
|
.resolve_identity("telegram", "user_999")
|
|
.await
|
|
.unwrap();
|
|
assert!(identity.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_reject_invalid_code() {
|
|
let (db, _dir) = setup_db_with_user("owner_reject").await;
|
|
let store = make_store(Arc::clone(&db));
|
|
let owner_id = UserId::from_trusted("owner_reject".into(), UserRole::Regular);
|
|
|
|
store
|
|
.upsert_request("telegram", "user_1", None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = store.approve("telegram", "INVALID1", &owner_id).await;
|
|
assert!(result.is_err());
|
|
|
|
let result = run_pairing_command_with_store(
|
|
&store,
|
|
&owner_id,
|
|
PairingCommand::Approve {
|
|
channel: "telegram".to_string(),
|
|
code: "BADCODE1".to_string(),
|
|
},
|
|
)
|
|
.await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_multiple_channels_isolated() {
|
|
let (db, _dir) = setup_db_with_user("owner_multi").await;
|
|
let store = make_store(Arc::clone(&db));
|
|
let owner_id = UserId::from_trusted("owner_multi".into(), UserRole::Regular);
|
|
|
|
let r_telegram = store
|
|
.upsert_request("telegram", "user_a", None)
|
|
.await
|
|
.unwrap();
|
|
let r_slack = store.upsert_request("slack", "user_b", None).await.unwrap();
|
|
|
|
// Each channel has its own pending
|
|
assert_eq!(store.list_pending("telegram").await.unwrap().len(), 1);
|
|
assert_eq!(store.list_pending("slack").await.unwrap().len(), 1);
|
|
|
|
// Approve in one channel doesn't affect the other
|
|
store
|
|
.approve("telegram", &r_telegram.code, &owner_id)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
store
|
|
.resolve_identity("telegram", "user_a")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.resolve_identity("slack", "user_a")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
|
|
store
|
|
.approve("slack", &r_slack.code, &owner_id)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
store
|
|
.resolve_identity("slack", "user_b")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pairing_store_normalizes_channel_keys_for_cache_and_db() {
|
|
let (db, _dir) = setup_db_with_user("owner_case").await;
|
|
let store = make_store(Arc::clone(&db));
|
|
let owner_id = UserId::from_trusted("owner_case".into(), UserRole::Regular);
|
|
|
|
let req = store
|
|
.upsert_request("TeleGram", "user_case", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(req.channel, "telegram");
|
|
|
|
store
|
|
.approve("telegram", &req.code, &owner_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let first = store
|
|
.resolve_identity("TELEGRAM", "user_case")
|
|
.await
|
|
.unwrap()
|
|
.expect("identity should resolve");
|
|
let second = store
|
|
.resolve_identity("telegram", "user_case")
|
|
.await
|
|
.unwrap()
|
|
.expect("identity should resolve on normalized cache key");
|
|
|
|
assert_eq!(first.as_str(), "owner_case");
|
|
assert_eq!(second.as_str(), "owner_case");
|
|
}
|
|
}
|