feat: complete NX9-Auth management and integrity hardening
This commit is contained in:
1 parent
dc5417334b
commit
a969f9c571
59 files changed
+6508
-290
No files matched your search
@@ -0,0 +1,194 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{application_members, applications, users};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_app_members_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_membership_add_update_remove_transactions() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
// Create user and application in Default Tenant
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"app_user_1",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (app, _) = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Portal App",
|
||||
"portal-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Add membership atomically with audit log
|
||||
let member = application_members::add(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some("member"),
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(member.application_id, app.id);
|
||||
assert_eq!(member.user_id, user.id);
|
||||
assert_eq!(member.role, "member");
|
||||
|
||||
let audit_add = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("application".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let add_event = audit_add
|
||||
.into_iter()
|
||||
.find(|e| e.action == "application.member_added")
|
||||
.expect("member_added audit record must exist");
|
||||
assert_eq!(add_event.target_user_id.as_deref(), Some(user.id.as_str()));
|
||||
|
||||
// 2. Update membership role atomically with audit log
|
||||
let updated_member = application_members::update(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some("admin"),
|
||||
None,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated_member.role, "admin");
|
||||
|
||||
// 3. Remove membership atomically with audit log
|
||||
application_members::remove(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining_members = provider
|
||||
.application_members()
|
||||
.list_by_application(&app.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining_members.len(), 0);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_membership_same_tenant_isolation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b-app"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Default Tenant
|
||||
let user_a = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"user_tenant_a",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant B
|
||||
let (app_b, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_b,
|
||||
"App Tenant B",
|
||||
"app-tenant-b",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempting to assign user_a (Tenant Default) to app_b (Tenant B) MUST be rejected
|
||||
let res = application_members::add(
|
||||
&provider_dyn,
|
||||
&app_b.id,
|
||||
&user_a.id,
|
||||
Some("member"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Cross-tenant application membership assignment MUST be rejected"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_audit_export_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_list_remains_clamped_to_500() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 600 audit events
|
||||
for i in 0..600 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&format!("{{\"index\": {i}}}")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Normal list filter with limit=1000 MUST be clamped to 500 by backend API logic
|
||||
let filter = nx9_auth::db::models::AuditFilter {
|
||||
limit: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
let clamped_limit = filter.limit.clamp(1, 500);
|
||||
assert_eq!(clamped_limit, 500);
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
limit: clamped_limit,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
500,
|
||||
"Normal audit listing must be bounded to 500 records max"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_export_can_return_more_than_500_up_to_5000() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 600 audit events
|
||||
for i in 0..600 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&format!("{{\"index\": {i}}}")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Export query path with limit=5000 returns all 600 records (>500)
|
||||
let export_filter = nx9_auth::db::models::AuditFilter {
|
||||
limit: 5000,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&export_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
600,
|
||||
"Export query path must return >500 records when available"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_export_hard_bounded_at_5000() {
|
||||
let (_provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Request limit 999999 must be clamped to hard maximum 5000
|
||||
let requested_limit = 999999i64;
|
||||
let export_limit = requested_limit.clamp(1, 5000);
|
||||
assert_eq!(export_limit, 5000);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_side_success_filtering_before_limit_and_count() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 10 success events and 10 failure events
|
||||
for _ in 0..10 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"auth.failed",
|
||||
"user",
|
||||
None,
|
||||
"warning",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Server-side filter success = true
|
||||
let success_filter = nx9_auth::db::models::AuditFilter {
|
||||
success: Some(true),
|
||||
limit: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let count = provider
|
||||
.audit()
|
||||
.count_filtered(&success_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&success_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 10);
|
||||
assert_eq!(entries.len(), 10);
|
||||
assert!(entries.iter().all(|e| !e.action.contains("fail")));
|
||||
|
||||
// Server-side filter success = false
|
||||
let fail_filter = nx9_auth::db::models::AuditFilter {
|
||||
success: Some(false),
|
||||
limit: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let fail_count = provider.audit().count_filtered(&fail_filter).await.unwrap();
|
||||
let fail_entries = provider.audit().list_filtered(&fail_filter).await.unwrap();
|
||||
|
||||
assert_eq!(fail_count, 10);
|
||||
assert_eq!(fail_entries.len(), 10);
|
||||
assert!(fail_entries.iter().all(|e| e.action.contains("fail")));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rfc4180_csv_escaping_rules() {
|
||||
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
|
||||
assert_eq!(esc("simple"), "\"simple\"");
|
||||
assert_eq!(esc("with,comma"), "\"with,comma\"");
|
||||
assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\"");
|
||||
assert_eq!(esc("multi\nline"), "\"multi\nline\"");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#![cfg(feature = "postgres")]
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_fresh_migration_from_0001_to_latest() {
|
||||
let database_url = "postgres://postgres@127.0.0.1:5433/nx9_auth_test_fresh";
|
||||
|
||||
let pool = match PgPoolOptions::new()
|
||||
.acquire_timeout(Duration::from_secs(1))
|
||||
.connect(database_url)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("Skipping PostgreSQL live connection test (server not running): {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let migrator = sqlx::migrate!("src/db/migrations/postgres");
|
||||
let res = migrator.run(&pool).await;
|
||||
|
||||
assert!(res.is_ok(), "Fresh PostgreSQL migration failed: {:?}", res);
|
||||
|
||||
// Test idempotency (re-running on migrated database)
|
||||
let res_idempotent = migrator.run(&pool).await;
|
||||
assert!(
|
||||
res_idempotent.is_ok(),
|
||||
"Re-running PostgreSQL migrations failed: {:?}",
|
||||
res_idempotent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{applications, slug};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_slug_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_canonical_slug_validation_and_policy() {
|
||||
// Valid slugs
|
||||
assert!(slug::validate_slug("default").is_ok());
|
||||
assert!(slug::validate_slug("my-app-1").is_ok());
|
||||
assert!(slug::validate_slug("acme-tenant").is_ok());
|
||||
assert!(slug::validate_slug("ab").is_ok());
|
||||
|
||||
// Invalid length
|
||||
assert!(slug::validate_slug("a").is_err());
|
||||
let long_slug = "a".repeat(64);
|
||||
assert!(slug::validate_slug(&long_slug).is_err());
|
||||
|
||||
// Invalid formatting
|
||||
assert!(slug::validate_slug("-invalid").is_err());
|
||||
assert!(slug::validate_slug("invalid-").is_err());
|
||||
assert!(slug::validate_slug("in--valid").is_err());
|
||||
assert!(slug::validate_slug("Invalid").is_err());
|
||||
assert!(slug::validate_slug("in_valid").is_err());
|
||||
|
||||
// Reserved names
|
||||
assert!(slug::validate_slug("admin").is_err());
|
||||
assert!(slug::validate_slug("api").is_err());
|
||||
assert!(slug::validate_slug("system").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_invalid_slug_rejection_no_silent_slugify() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Explicit invalid slug must be rejected directly and NOT silently slugified
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
let res = provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "My Organization", Some("INVALID SLUG!"))
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "Explicit invalid slug should be rejected");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_omitted_slug_generation_on_create() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
let tenant = provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Acme Corporation Inc!", None)
|
||||
.await
|
||||
.expect("Should derive slug from name when omitted");
|
||||
|
||||
assert_eq!(tenant.slug.as_deref(), Some("acme-corporation-inc"));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_resource_duplicate_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id1 = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id1, "Tenant One", Some("tenant-one"))
|
||||
.await
|
||||
.expect("First tenant creation should succeed");
|
||||
|
||||
let id2 = uuid::Uuid::new_v4().to_string();
|
||||
let res = provider
|
||||
.tenants()
|
||||
.create(&id2, "Tenant Two", Some("tenant-one"))
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "Duplicate tenant slug must be rejected");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_resource_collision_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||
|
||||
// Create a tenant with slug "shared-identifier"
|
||||
let t_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&t_id, "Shared Tenant", Some("shared-identifier"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Attempting to create an application with the SAME slug "shared-identifier" must fail
|
||||
let app_res = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Colliding Application",
|
||||
"shared-identifier",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
app_res.is_err(),
|
||||
"Cross-resource slug collision (app vs tenant) must be rejected"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unchanged_slug_update_no_op() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Original Name", Some("stable-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Updating tenant name while keeping the exact same slug must succeed (no-op for registry)
|
||||
let update_res = provider
|
||||
.tenants()
|
||||
.update(&id, "Updated Name", Some("stable-slug"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
update_res.is_ok(),
|
||||
"Unchanged slug update should succeed as no-op"
|
||||
);
|
||||
|
||||
let updated = provider.tenants().find_by_id(&id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.name, "Updated Name");
|
||||
assert_eq!(updated.slug.as_deref(), Some("stable-slug"));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rename_and_old_slug_release() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Alpha Tenant", Some("old-alpha-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Rename to new-alpha-slug
|
||||
provider
|
||||
.tenants()
|
||||
.update(&id, "Alpha Tenant", Some("new-alpha-slug"))
|
||||
.await
|
||||
.expect("Rename should succeed");
|
||||
|
||||
// Verify old-alpha-slug is released and can be claimed by another resource
|
||||
let id2 = uuid::Uuid::new_v4().to_string();
|
||||
let claim_res = provider
|
||||
.tenants()
|
||||
.create(&id2, "Beta Tenant", Some("old-alpha-slug"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
claim_res.is_ok(),
|
||||
"Released old slug should be claimable by new resource"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_release_ownership_verification() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id1 = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id1, "Tenant One", Some("tenant-slug-1"))
|
||||
.await
|
||||
.expect("Tenant 1 creation should succeed");
|
||||
|
||||
// Attempting to release tenant-slug-1 using a wrong entity_id (id2) directly via sqlite helper
|
||||
let mut tx = provider.pool.begin().await.unwrap();
|
||||
let rows = db::repository::sqlite::global_slugs::release_slug_by_name_sqlite(
|
||||
&mut tx,
|
||||
"tenant-slug-1",
|
||||
"tenant",
|
||||
"wrong-entity-id",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"Release with wrong entity_id ownership must affect 0 rows"
|
||||
);
|
||||
|
||||
// Verify tenant-slug-1 is still registered in global_slugs
|
||||
let existing_slug = provider
|
||||
.global_slugs()
|
||||
.find_by_slug("tenant-slug-1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_slug.is_some(),
|
||||
"Registration must remain intact when release ownership fails"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_deletion_slug_release() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Temporary Tenant", Some("temp-tenant-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Delete tenant
|
||||
provider
|
||||
.tenants()
|
||||
.delete(&id)
|
||||
.await
|
||||
.expect("Tenant deletion should succeed");
|
||||
|
||||
// Verify temp-tenant-slug is released from global_slugs
|
||||
let found = provider
|
||||
.global_slugs()
|
||||
.find_by_slug("temp-tenant-slug")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"Slug must be removed from global_slugs after deletion"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_slug_never_authenticates_as_client_id() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||
|
||||
let (app, raw_secret) = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Auth App",
|
||||
"auth-app-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("App creation should succeed");
|
||||
|
||||
// Authenticating using canonical client_id must succeed
|
||||
let auth_ok =
|
||||
applications::validate_client_credentials(&provider_dyn, app.get_client_id(), &raw_secret)
|
||||
.await;
|
||||
assert!(
|
||||
auth_ok.is_ok(),
|
||||
"Authentication with client_id must succeed"
|
||||
);
|
||||
|
||||
// Authenticating using application SLUG as client_id MUST FAIL
|
||||
let auth_slug_fail =
|
||||
applications::validate_client_credentials(&provider_dyn, "auth-app-slug", &raw_secret)
|
||||
.await;
|
||||
assert!(
|
||||
auth_slug_fail.is_err(),
|
||||
"Application slug MUST NEVER authenticate as client_id"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dual_migration_paths_sqlite() {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_migration_path_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path).await.unwrap();
|
||||
|
||||
// Fresh migration
|
||||
let res = db::run_migrations(&pool).await;
|
||||
assert!(res.is_ok(), "Fresh migration must succeed");
|
||||
|
||||
// Idempotent re-run
|
||||
let res2 = db::run_migrations(&pool).await;
|
||||
assert!(res2.is_ok(), "Re-running migrations must succeed");
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{applications, users};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_tenant_mgmt_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_user_listing_and_assignment() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
// Create a new tenant
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Acme Org", Some("acme-org"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"employee_1",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("User creation should succeed");
|
||||
|
||||
assert_eq!(user.tenant_id, Tenant::DEFAULT_ID);
|
||||
|
||||
// Reassign user to Acme Org
|
||||
provider
|
||||
.users()
|
||||
.update_user_tenant(&user.id, &tenant_id)
|
||||
.await
|
||||
.expect("Tenant assignment should succeed");
|
||||
|
||||
let tenant_users = provider
|
||||
.users()
|
||||
.list(&tenant_id)
|
||||
.await
|
||||
.expect("Listing tenant users should succeed");
|
||||
|
||||
assert_eq!(tenant_users.len(), 1);
|
||||
assert_eq!(tenant_users[0].username, "employee_1");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_assign_user_username_collision_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Beta Corp", Some("beta-corp"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Default tenant named "common_user"
|
||||
let u1 = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"common_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Beta Corp also named "common_user"
|
||||
let _u2 = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
&tenant_id,
|
||||
"common_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempting to move u1 into Beta Corp must collide because "common_user" already exists in Beta Corp
|
||||
let exists = provider
|
||||
.users()
|
||||
.username_exists(&tenant_id, &u1.username)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
exists,
|
||||
"Username existence check must return true for colliding username in destination tenant"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_application_listing_isolation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
let tenant_a = uuid::Uuid::new_v4().to_string();
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_a, "Tenant A", Some("tenant-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant A
|
||||
let (app_a, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_a,
|
||||
"App A",
|
||||
"app-a-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant B
|
||||
let (app_b, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_b,
|
||||
"App B",
|
||||
"app-b-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let apps_a = provider.applications().list(&tenant_a).await.unwrap();
|
||||
let apps_b = provider.applications().list(&tenant_b).await.unwrap();
|
||||
|
||||
assert_eq!(apps_a.len(), 1);
|
||||
assert_eq!(apps_a[0].id, app_a.id);
|
||||
|
||||
assert_eq!(apps_b.len(), 1);
|
||||
assert_eq!(apps_b[0].id, app_b.id);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_identity_immediately_reflects_tenant_reassignment() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b-session"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Create user in Default Tenant
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"session_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Create active session for user
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let token_hash = "hash_123456";
|
||||
let expires_at = "2030-01-01T00:00:00Z";
|
||||
|
||||
provider
|
||||
.sessions()
|
||||
.create(
|
||||
&session_id,
|
||||
&user.id,
|
||||
token_hash,
|
||||
Some("127.0.0.1"),
|
||||
Some("TestAgent"),
|
||||
expires_at,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3. Resolve user identity via session user_id -> tenant_id must be Default Tenant
|
||||
let session_user_before = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session_user_before.tenant_id, Tenant::DEFAULT_ID);
|
||||
|
||||
// 4. Reassign user to Tenant B
|
||||
provider
|
||||
.users()
|
||||
.update_user_tenant(&user.id, &tenant_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 5. Subsequent request resolving user identity for the same active session MUST immediately yield Tenant B
|
||||
let session_user_after = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session_user_after.tenant_id, tenant_b);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prevent_last_admin_reassignment_validation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
// Create an admin user
|
||||
let admin_user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"admin_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let admin_role = provider
|
||||
.roles()
|
||||
.find_by_name("admin")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("admin role must exist");
|
||||
provider
|
||||
.roles()
|
||||
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Check system admin count
|
||||
let admin_count = provider.users().count_admins().await.unwrap();
|
||||
assert_eq!(admin_count, 1, "Should count 1 system admin user");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassignment_audit_event_metadata_invariants() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"audit_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let from_tenant_id = Tenant::DEFAULT_ID;
|
||||
let to_tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": to_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
Some(&user.id),
|
||||
Some(&user.id),
|
||||
"user.tenant_reassigned",
|
||||
"user",
|
||||
Some(&user.id),
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&metadata),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = entries
|
||||
.into_iter()
|
||||
.find(|e| e.id == audit_id)
|
||||
.expect("Audit event must exist");
|
||||
|
||||
assert_eq!(event.action, "user.tenant_reassigned");
|
||||
let parsed_meta: serde_json::Value =
|
||||
serde_json::from_str(event.metadata_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(parsed_meta["from_tenant_id"], from_tenant_id);
|
||||
assert_eq!(parsed_meta["to_tenant_id"], to_tenant_id);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassign_user_tenant_with_audit_atomic_success() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_dest = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_dest, "Dest Tenant", Some("dest-tenant"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"atomic_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Call atomic reassign
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
&tenant_dest,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify tenant update
|
||||
let updated_user = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated_user.tenant_id, tenant_dest);
|
||||
|
||||
// Verify audit record was inserted inside transaction
|
||||
let audit_entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let audit_event = audit_entries
|
||||
.into_iter()
|
||||
.find(|e| {
|
||||
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||
})
|
||||
.expect("Atomic reassignment audit event must exist");
|
||||
|
||||
let meta: serde_json::Value =
|
||||
serde_json::from_str(audit_event.metadata_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(meta["from_tenant_id"], Tenant::DEFAULT_ID);
|
||||
assert_eq!(meta["to_tenant_id"], tenant_dest);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassign_user_tenant_no_op_behavior() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"noop_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reassigning to SAME tenant must be a defined no-op
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
Tenant::DEFAULT_ID,
|
||||
Some("actor_1"),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Audit logs MUST NOT contain a false tenant_reassigned event
|
||||
let audit_entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let noop_audit = audit_entries.into_iter().find(|e| {
|
||||
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||
});
|
||||
|
||||
assert!(
|
||||
noop_audit.is_none(),
|
||||
"No-op reassignment must NOT write a false audit log entry"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_admin_reassignment_cannot_remove_all_system_admins() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let dest_tenant = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&dest_tenant, "Dest", Some("dest"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create single system admin in Default Tenant
|
||||
let admin_user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"single_admin",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let admin_role = provider
|
||||
.roles()
|
||||
.find_by_name("admin")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
provider
|
||||
.roles()
|
||||
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Single system admin reassignment away from Default Tenant MUST be rejected
|
||||
let res = provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&admin_user.id,
|
||||
&dest_tenant,
|
||||
Some(&admin_user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Moving the last system admin away from Default Tenant MUST fail"
|
||||
);
|
||||
|
||||
// Invariant check: system admin count MUST remain >= 1 in Default Tenant
|
||||
let admin_count = provider.users().count_admins().await.unwrap();
|
||||
assert_eq!(admin_count, 1, "System admin count must never drop to 0");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
Reference in new issue
Block a user