refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
@@ -20,6 +20,15 @@ async fn test_admin_user_listing() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let _ = bzod::cli::create_admin::run(
|
||||
Some("admin".to_string()),
|
||||
Some("adminpass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("usera".to_string()),
|
||||
Some("password123".to_string()),
|
||||
@@ -32,9 +41,9 @@ async fn test_admin_user_listing() {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let users = bzod::db::users::list_users(&conn).unwrap();
|
||||
|
||||
// Verify list contains legacy_admin and usera
|
||||
// Verify list contains admin and usera
|
||||
assert!(users.len() >= 2);
|
||||
assert!(users.iter().any(|u| u.username == "legacy_admin"));
|
||||
assert!(users.iter().any(|u| u.username == "admin"));
|
||||
assert!(users.iter().any(|u| u.username == "usera"));
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -33,8 +33,6 @@ fn build_state(config: Config) -> (Db, AppState) {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
|
||||
@@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
});
|
||||
}
|
||||
@@ -92,6 +93,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -105,6 +107,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -118,6 +121,7 @@ fn test_advanced_analytics_date_filtering() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,13 +50,11 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
@@ -111,11 +109,29 @@ async fn test_analytics_and_table_parity() {
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// Admin adds a URL to the global content_db
|
||||
// Create User B to own global links
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("userb".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
create_temp_config(temp_dir.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_b, tid_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User B adds a URL
|
||||
let _admin_url_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn_admin,
|
||||
&conn_b,
|
||||
"!admin-slug",
|
||||
"https://admin.com",
|
||||
None,
|
||||
@@ -127,24 +143,17 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!admin-slug",
|
||||
1,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!admin-slug", &tid_b, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
// Admin adds a Landing Page to the global content_db
|
||||
// User B adds a Landing Page
|
||||
let _admin_page_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
|
||||
let page = bzod::db::content::create_landing_page(
|
||||
&conn_admin,
|
||||
&conn_b,
|
||||
"!admin-page",
|
||||
"admin-page",
|
||||
"Title Admin",
|
||||
@@ -153,12 +162,11 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!admin-page",
|
||||
1,
|
||||
"page",
|
||||
&tid_b,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
@@ -176,12 +184,12 @@ async fn test_analytics_and_table_parity() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let (id_a, tid_a) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A adds a URL
|
||||
@@ -200,16 +208,9 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
@@ -226,12 +227,11 @@ async fn test_analytics_and_table_parity() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!usera-page",
|
||||
id_a,
|
||||
"page",
|
||||
&tid_a,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
|
||||
@@ -121,6 +121,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -134,6 +135,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
VisitRecord {
|
||||
@@ -147,6 +149,7 @@ fn test_target_analytics_queries() {
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,8 +42,6 @@ fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = bzod::state::AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -60,14 +58,20 @@ fn test_migration_idempotent() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 2);
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
USERS_MIGRATIONS.last().unwrap().version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_users_db_schema_version_2() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 2);
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
USERS_MIGRATIONS.last().unwrap().version
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -96,22 +96,12 @@ async fn test_backup_restore_roundtrip() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("testuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "testuser", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user.id).unwrap();
|
||||
let user_id = user.id;
|
||||
|
||||
// Add a link for user
|
||||
{
|
||||
@@ -129,21 +119,25 @@ async fn test_backup_restore_roundtrip() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Increment quota counter
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
|
||||
let tenant_id = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
|
||||
bzod::db::users::get_user_by_id(&users_conn, user_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tenant_id
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Register global slug
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!rt-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"rt-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!rt-slug', ?1, 'rt-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tenant_id.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backup user
|
||||
@@ -172,7 +166,7 @@ async fn test_backup_restore_roundtrip() {
|
||||
.unwrap();
|
||||
|
||||
// Verify restored user
|
||||
{
|
||||
let restored_id = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
@@ -183,14 +177,14 @@ async fn test_backup_restore_roundtrip() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(quota.current_urls, 1);
|
||||
user.id
|
||||
};
|
||||
|
||||
// Verify content restored
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user.id).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/rt");
|
||||
}
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, restored_id).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&user_content_conn, "!rt-slug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/rt");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -198,33 +192,30 @@ async fn test_backup_restore_roundtrip() {
|
||||
#[tokio::test]
|
||||
async fn test_restore_slug_collision_rejection() {
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"bzod_test_restore_collision_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
"bzod_test_collision_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
fs::create_dir_all(&config.backup_dir).unwrap();
|
||||
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create User A
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("usera".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let user_a = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "usera", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user_a.id).unwrap();
|
||||
let (id_a, tid_a) = (user_a.id, user_a.tenant_id.unwrap());
|
||||
|
||||
// Add a link for User A
|
||||
// Add slug for User A
|
||||
{
|
||||
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
@@ -240,16 +231,13 @@ async fn test_restore_slug_collision_rejection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_a,
|
||||
"url",
|
||||
"a-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!collision-slug', ?1, 'a-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Backup User A
|
||||
@@ -269,22 +257,12 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.unwrap();
|
||||
|
||||
// Create User B
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("userb".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_b = {
|
||||
let user_b = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
bzod::db::users::create_user(&conn, "userb", "password123", "standard", None).unwrap()
|
||||
};
|
||||
db.init_user_databases(user_b.id).unwrap();
|
||||
let (id_b, tid_b) = (user_b.id, user_b.tenant_id.unwrap());
|
||||
|
||||
// Add colliding slug for User B
|
||||
{
|
||||
@@ -302,16 +280,13 @@ async fn test_restore_slug_collision_rejection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_b,
|
||||
"url",
|
||||
"b-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!collision-slug', ?1, 'b-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid_b.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Attempt to restore User A from backup - must fail on collision
|
||||
@@ -323,17 +298,17 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
|
||||
// Verify that User B still owns the slug in global_urls and User A's slug registration was skipped/rejected
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let owner_id: i64 = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let owner_tid: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = '!collision-slug';",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = '!collision-slug';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner_id, id_b);
|
||||
assert_eq!(owner_tid, tid_b.as_str());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -55,8 +55,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -190,9 +188,9 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
|
||||
|
||||
// 6. Verify visit is logged in analytics
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!mygoogle';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!mygoogle';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
@@ -505,9 +503,9 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
||||
|
||||
// Verify visit recorded for standard_c1
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!myyahoo';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
@@ -607,9 +605,9 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
||||
|
||||
// standard_c2 analytics should display Yahoo!
|
||||
let url_uuid = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
|
||||
"SELECT target_id FROM global_urls WHERE slug = '!myyahoo';",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
|
||||
@@ -23,14 +23,19 @@ async fn test_concurrent_slug_creation() {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let db_path = temp_dir.join("admin/system.db");
|
||||
let db_path = temp_dir.join("slugs/global_urls.db");
|
||||
|
||||
let b1 = barrier.clone();
|
||||
let path1 = db_path.clone();
|
||||
let task1 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path1).unwrap();
|
||||
b1.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!conc-slug', 't-tenant10', 'url10', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
});
|
||||
|
||||
let b2 = barrier.clone();
|
||||
@@ -38,7 +43,12 @@ async fn test_concurrent_slug_creation() {
|
||||
let task2 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path2).unwrap();
|
||||
b2.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!conc-slug', 't-tenant20', 'url20', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
});
|
||||
|
||||
let res1 = task1.await.unwrap();
|
||||
@@ -54,12 +64,12 @@ async fn test_concurrent_slug_creation() {
|
||||
),
|
||||
}
|
||||
|
||||
// Verify exactly 1 record exists in global_slugs
|
||||
// Verify exactly 1 record exists in global_urls
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let count: i64 = urls_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE slug = '!conc-slug';",
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug = '!conc-slug';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -84,8 +84,7 @@ async fn test_partial_restore_failure_rollback() {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
// Only legacy_admin (ID 1) should exist
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count, 0, "No users should exist after failed restore");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -54,8 +54,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
|
||||
+37
-36
@@ -29,12 +29,12 @@ async fn test_global_slug_index_consistency() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// Add a URL for the user
|
||||
@@ -57,46 +57,47 @@ async fn test_global_slug_index_consistency() {
|
||||
|
||||
// Register globally
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!integ-slug",
|
||||
user_id,
|
||||
"url",
|
||||
&url_id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!integ-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url_id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Consistency Check:
|
||||
// 1. Every active slug in user databases exists in global_slugs
|
||||
// 1. Every active slug in user databases exists in global_urls
|
||||
{
|
||||
let user_ids: Vec<i64> = {
|
||||
let users = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM users;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
bzod::db::users::list_users(&conn).unwrap()
|
||||
};
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
for id in user_ids {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, id).unwrap();
|
||||
let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
for u in users {
|
||||
if let Some(tenant_id) = u.tenant_id {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, u.id).unwrap();
|
||||
let mut stmt = conn.prepare("SELECT code FROM urls;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||
|
||||
for code_res in rows {
|
||||
let code = code_res.unwrap();
|
||||
let exists: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = ?2);",
|
||||
rusqlite::params![code, id],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert!(
|
||||
exists,
|
||||
"Active user slug '{}' missing from global_slugs",
|
||||
code
|
||||
);
|
||||
for code_res in rows {
|
||||
let code = code_res.unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2);",
|
||||
rusqlite::params![code, tenant_id.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
exists,
|
||||
"Active user slug '{}' missing from global_urls",
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,16 +546,23 @@ async fn test_current_backup_restore_roundtrip() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!current-test",
|
||||
user_id,
|
||||
"url",
|
||||
"current-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let tenant_id = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_id(&conn, user_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tenant_id
|
||||
.unwrap()
|
||||
};
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!current-test', ?1, 'current-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tenant_id.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create a native backup
|
||||
|
||||
+10
-21
@@ -79,8 +79,11 @@ async fn test_legacy_migration() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Call Db::init which triggers the legacy migration
|
||||
let db = Db::init(&config).expect("Failed to init Db and run legacy migration");
|
||||
// Call normalize_restored_layout to move legacy flat files into multi-tenant paths
|
||||
bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap();
|
||||
|
||||
// Call Db::init
|
||||
let _db = Db::init(&config).expect("Failed to init Db and run legacy migration");
|
||||
|
||||
// Verify files moved to correct directories
|
||||
assert!(!legacy_admin_path.exists());
|
||||
@@ -88,26 +91,12 @@ async fn test_legacy_migration() {
|
||||
assert!(temp_dir.join("admin/admin.db").exists());
|
||||
assert!(temp_dir.join("users/1/content.db").exists());
|
||||
|
||||
// Verify legacy_admin created as system and disabled
|
||||
// Verify legacy content is preserved in users/1/content.db
|
||||
{
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(user.username, "legacy_admin");
|
||||
assert_eq!(user.status, "disabled");
|
||||
assert_eq!(user.account_type, "system");
|
||||
}
|
||||
|
||||
// Verify slug registered in global_slugs
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = '!legacy-slug');",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
let conn = rusqlite::Connection::open(temp_dir.join("users/1/content.db")).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!legacy-slug").unwrap();
|
||||
assert!(url.is_some());
|
||||
assert_eq!(url.unwrap().destination, "https://legacy.com");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+18
-12
@@ -22,16 +22,22 @@ async fn test_content_flagging_and_disabling() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!badslug', 't-tenant10', 'url_abc', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is active initially
|
||||
let status: String = system_conn
|
||||
let status: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -39,17 +45,17 @@ async fn test_content_flagging_and_disabling() {
|
||||
assert_eq!(status, "active");
|
||||
|
||||
// Moderate/disable slug
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = ?1;",
|
||||
"UPDATE global_urls SET status = 'disabled' WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is disabled
|
||||
let status2: String = system_conn
|
||||
let status2: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -57,16 +63,16 @@ async fn test_content_flagging_and_disabling() {
|
||||
assert_eq!(status2, "disabled");
|
||||
|
||||
// Moderate/re-enable slug
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'active' WHERE slug = ?1;",
|
||||
"UPDATE global_urls SET status = 'active' WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status3: String = system_conn
|
||||
let status3: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!badslug"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -47,6 +47,16 @@ async fn test_cli_shorten_and_expand() {
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// 0. Create a standard user
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("cliuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Shorten with generated code
|
||||
let res = bzod::cli::shorten::run(
|
||||
"https://example.com/one".to_string(),
|
||||
@@ -78,20 +88,11 @@ async fn test_cli_shorten_and_expand() {
|
||||
assert!(res_dup.is_err());
|
||||
assert!(res_dup.unwrap_err().to_string().contains("already exists"));
|
||||
|
||||
// 4. Expand custom slug
|
||||
{
|
||||
let db = Db::init(&config).unwrap();
|
||||
let conn = db.content.lock().unwrap();
|
||||
let url_opt = bzod::db::content::get_url_by_code(&conn, "!office").unwrap();
|
||||
assert!(url_opt.is_some());
|
||||
assert_eq!(url_opt.unwrap().destination, "https://example.com/two");
|
||||
}
|
||||
|
||||
// 5. CLI expand round-trip validation
|
||||
// 4. CLI expand round-trip validation
|
||||
let expand_res = bzod::cli::expand::run("!office".to_string(), None, config.clone()).await;
|
||||
assert!(expand_res.is_ok());
|
||||
|
||||
// 6. Case-insensitive CLI expand validation
|
||||
// 5. Case-insensitive CLI expand validation
|
||||
let expand_res_upper =
|
||||
bzod::cli::expand::run("!OFFICE".to_string(), None, config.clone()).await;
|
||||
assert!(expand_res_upper.is_ok());
|
||||
@@ -108,12 +109,30 @@ async fn test_perform_restore_and_validation() {
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// Create user and url
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("restoreuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = Db::init(&config).unwrap();
|
||||
let (uid, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "restoreuser")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// 1. Create a mock database record
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, uid).unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!home",
|
||||
"https://my-home.com",
|
||||
@@ -125,6 +144,9 @@ async fn test_perform_restore_and_validation() {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!home", &tid, &url.id, "active").unwrap();
|
||||
}
|
||||
|
||||
// 2. Perform a backup
|
||||
@@ -142,18 +164,15 @@ async fn test_perform_restore_and_validation() {
|
||||
assert!(restore_dir.join("admin/admin.db").exists());
|
||||
assert!(restore_dir.join("admin/users.db").exists());
|
||||
assert!(restore_dir.join("admin/system.db").exists());
|
||||
assert!(restore_dir.join("users/1/content.db").exists());
|
||||
assert!(restore_dir.join("users/1/analytics.db").exists());
|
||||
assert!(restore_dir.join("slugs/global_urls.db").exists());
|
||||
assert!(restore_dir
|
||||
.join(format!("users/{}/content.db", tid.as_str()))
|
||||
.exists());
|
||||
|
||||
// Verify custom slug was preserved in the restored DB
|
||||
let restore_config = create_temp_config(restore_dir.clone());
|
||||
let restore_db = Db::init(&restore_config).unwrap();
|
||||
{
|
||||
let conn = restore_db.content.lock().unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!home").unwrap();
|
||||
assert!(url.is_some());
|
||||
assert_eq!(url.unwrap().destination, "https://my-home.com");
|
||||
}
|
||||
let expand_res = bzod::cli::expand::run("!home".to_string(), None, restore_config).await;
|
||||
assert!(expand_res.is_ok());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
let _ = fs::remove_dir_all(&restore_dir);
|
||||
|
||||
+11
-23
@@ -50,8 +50,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -107,17 +105,15 @@ async fn test_ownership_isolation_endpoints() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, _id_b) = {
|
||||
let (id_a, tid_a, _id_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
(a, b)
|
||||
.unwrap();
|
||||
(a.id, a.tenant_id.unwrap(), b.id)
|
||||
};
|
||||
|
||||
// User A adds a URL
|
||||
@@ -136,16 +132,9 @@ async fn test_ownership_isolation_endpoints() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "!usera-slug", &tid_a, &url.id, "active")
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
@@ -162,12 +151,11 @@ async fn test_ownership_isolation_endpoints() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
bzod::db::slugs::register_landing_page_slug(
|
||||
&pages_conn,
|
||||
"!usera-page",
|
||||
id_a,
|
||||
"page",
|
||||
&tid_a,
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
|
||||
+40
-46
@@ -39,8 +39,6 @@ async fn start_test_server(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -85,12 +83,12 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let (id_a, tid_a) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A adds an active URL
|
||||
@@ -109,16 +107,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"a1b2c3",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('a1b2c3', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds a disabled URL
|
||||
@@ -137,16 +134,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"d4e5f6",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"disabled",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('d4e5f6', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds an active Page
|
||||
@@ -162,16 +158,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"a1b2",
|
||||
id_a,
|
||||
"page",
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
pages_conn
|
||||
.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('a1b2', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &page.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// User A adds a disabled Page
|
||||
@@ -187,16 +182,15 @@ async fn test_qr_endpoints_and_redirection_hardening() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"c3d4",
|
||||
id_a,
|
||||
"page",
|
||||
&page.id,
|
||||
"disabled",
|
||||
)
|
||||
.unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
pages_conn
|
||||
.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('c3d4', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &page.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 1. Verify Active URL QR endpoints return 200 with correct content types
|
||||
|
||||
@@ -126,8 +126,8 @@ async fn test_quota_reconcile_job() {
|
||||
|
||||
// Run reconciliation
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::reconcile_user_quotas(&users_conn, user_id, &user_content_conn).unwrap();
|
||||
|
||||
// Verify drift is repaired
|
||||
|
||||
@@ -48,10 +48,15 @@ async fn start_test_server(
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
|
||||
// Create a standard tenant user
|
||||
{
|
||||
let users = db.users.lock().unwrap();
|
||||
let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None);
|
||||
}
|
||||
let _ = db.init_user_databases(1);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -95,25 +100,27 @@ fn seed_url(db: &Db, seed: SeedUrl<'_>) {
|
||||
db.init_user_databases(seed.owner_user_id)
|
||||
.expect("init user dbs");
|
||||
|
||||
let (content_path, tid) = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, seed.owner_user_id)
|
||||
.unwrap()
|
||||
.expect("seed owner must be a registered user");
|
||||
let path = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db");
|
||||
(path, user.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
{
|
||||
let system = db.system.lock().unwrap();
|
||||
let _ = bzod::db::users::register_global_slug(
|
||||
&system,
|
||||
seed.code,
|
||||
seed.owner_user_id,
|
||||
"url",
|
||||
"seed",
|
||||
"active",
|
||||
);
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let _ = bzod::db::slugs::register_url_slug(&urls_conn, seed.code, &tid, &id, "active");
|
||||
}
|
||||
|
||||
let content_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(seed.owner_user_id.to_string())
|
||||
.join("content.db");
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count)
|
||||
@@ -271,7 +278,15 @@ async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() {
|
||||
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||
|
||||
// Column may still be 0 until sweeper runs — correctness does not require write.
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let expired_flag: i64 = conn
|
||||
.query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| {
|
||||
@@ -347,7 +362,15 @@ async fn password_gate_before_access_increment() {
|
||||
assert!(loc.contains("/gate/ab5555"));
|
||||
|
||||
// Access count must not have incremented
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
@@ -403,7 +426,15 @@ async fn concurrent_redirects_increment_access_count() {
|
||||
}
|
||||
assert_eq!(ok_301, N);
|
||||
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
@@ -514,6 +545,10 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() {
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).unwrap();
|
||||
{
|
||||
let users = db.users.lock().unwrap();
|
||||
let _ = bzod::db::users::create_user(&users, "testuser", "dummy_hash", "standard", None);
|
||||
}
|
||||
|
||||
seed_url(
|
||||
&db,
|
||||
@@ -563,7 +598,15 @@ async fn destination_audit_finds_legacy_invalid_without_rewriting() {
|
||||
assert_eq!(report.unsupported_scheme, 1);
|
||||
|
||||
// Data not rewritten
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let dest: String = conn
|
||||
.query_row(
|
||||
@@ -597,10 +640,10 @@ async fn disabled_slug_returns_410() {
|
||||
},
|
||||
);
|
||||
{
|
||||
let system = db.system.lock().unwrap();
|
||||
system
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';",
|
||||
"UPDATE global_urls SET status = 'disabled' WHERE slug = 'ab7777';",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -640,7 +683,15 @@ async fn redirect_performance_smoke() {
|
||||
}
|
||||
// Reset access count after warm-up for clean correctness check
|
||||
{
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1).unwrap().unwrap();
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE urls SET access_count = 0 WHERE code = 'ab8888';",
|
||||
@@ -702,7 +753,17 @@ async fn redirect_performance_smoke() {
|
||||
assert_eq!(errors, 0, "error rate must be zero");
|
||||
assert_eq!(latencies_ms.len(), REQUESTS);
|
||||
|
||||
let content_path = db.data_dir.join("users/1/content.db");
|
||||
let content_path = {
|
||||
let users = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_id(&users, 1)
|
||||
.unwrap()
|
||||
.expect("user 1 must exist");
|
||||
bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&db.topology)
|
||||
.unwrap()
|
||||
.join("content.db")
|
||||
};
|
||||
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
|
||||
@@ -20,18 +20,21 @@ async fn test_registry_repair_dry_run() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairdry", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
// Insert orphaned slug (owner exists, but no target db/record)
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"orphan1",
|
||||
1,
|
||||
"url",
|
||||
"target1",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan1', ?1, 'target1', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
@@ -47,10 +50,10 @@ async fn test_registry_repair_dry_run() {
|
||||
|
||||
// Verify it was NOT deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan1')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan1')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -69,18 +72,21 @@ async fn test_registry_repair_force() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairforce", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
// Insert orphaned slug
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"orphan2",
|
||||
1,
|
||||
"url",
|
||||
"target2",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan2', ?1, 'target2', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
@@ -96,10 +102,10 @@ async fn test_registry_repair_force() {
|
||||
|
||||
// Verify it WAS deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan2')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan2')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -118,33 +124,32 @@ async fn test_registry_repair_single_slug() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let tid = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::create_user(&conn, "repairsingle", "pass", "user", None).unwrap();
|
||||
u.tenant_id.unwrap()
|
||||
};
|
||||
db.init_user_databases(1).unwrap();
|
||||
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
// Insert two orphaned slugs
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"orphan3",
|
||||
1,
|
||||
"url",
|
||||
"target3",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"orphan4",
|
||||
1,
|
||||
"url",
|
||||
"target4",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan3', ?1, 'target3', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphan4', ?1, 'target4', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
let command = RepairCommands::Registry {
|
||||
dry_run: false,
|
||||
force: true,
|
||||
slug: Some("orphan3".to_string()),
|
||||
slug: Some("!orphan3".to_string()),
|
||||
data_dir: Some(temp_dir.to_string_lossy().to_string()),
|
||||
};
|
||||
|
||||
@@ -154,19 +159,19 @@ async fn test_registry_repair_single_slug() {
|
||||
|
||||
// Verify targeted slug was deleted
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let exists3: bool = system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists3: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan3')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan3')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists3, "Targeted slug should be deleted");
|
||||
|
||||
let exists4: bool = system_conn
|
||||
let exists4: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'orphan4')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphan4')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
|
||||
+25
-39
@@ -31,8 +31,6 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
@@ -52,17 +50,17 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
{
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!my-routing-slug",
|
||||
"https://example.com/target",
|
||||
@@ -75,16 +73,15 @@ async fn test_global_slug_lookup_and_redirection() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!my-routing-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!my-routing-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Call resolve_redirect directly
|
||||
@@ -128,8 +125,6 @@ async fn test_disabled_slug_returns_410() {
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
@@ -148,17 +143,17 @@ async fn test_disabled_slug_returns_410() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
{
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!disabled-slug",
|
||||
"https://example.com/target",
|
||||
@@ -171,22 +166,13 @@ async fn test_disabled_slug_returns_410() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!disabled-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Disable slug
|
||||
system_conn
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET status = 'disabled' WHERE slug = '!disabled-slug';",
|
||||
[],
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!disabled-slug', ?1, ?2, ?3, ?4, 'disabled', NULL);",
|
||||
rusqlite::params![tid.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -20,14 +20,24 @@ async fn test_global_slug_uniqueness() {
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register a slug
|
||||
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active")
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!myslug', 't-tenant1234', 'url1', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is not available
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
|
||||
let avail =
|
||||
bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "!myslug")
|
||||
.unwrap();
|
||||
assert!(!avail);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
@@ -41,7 +51,9 @@ async fn test_reserved_slug_rejection() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
let reserved = vec![
|
||||
"admin",
|
||||
@@ -72,7 +84,9 @@ async fn test_reserved_slug_rejection() {
|
||||
];
|
||||
|
||||
for slug in reserved {
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, slug).unwrap();
|
||||
let avail =
|
||||
bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, slug)
|
||||
.unwrap();
|
||||
assert!(!avail, "Reserved slug '{}' should not be available", slug);
|
||||
}
|
||||
|
||||
@@ -97,27 +111,35 @@ async fn test_slug_release_on_user_deletion() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// Register slug for this user
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!user-slug', ?1, 'url_xyz', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![tid.as_str(), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!user-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"url_xyz",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
||||
assert!(!avail);
|
||||
}
|
||||
|
||||
@@ -128,11 +150,20 @@ async fn test_slug_release_on_user_deletion() {
|
||||
|
||||
// Slug should now be released and available
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!user-slug",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(avail);
|
||||
|
||||
// Verify history populated
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM slug_history WHERE slug = ?1 AND action = 'deleted';",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::Db;
|
||||
use std::fs;
|
||||
|
||||
@@ -41,17 +41,15 @@ async fn test_slug_transfer() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, id_b) = {
|
||||
let (id_a, tid_a, id_b, tid_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
(a, b)
|
||||
.unwrap();
|
||||
(a.id, a.tenant_id.unwrap(), b.id, b.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// User A creates a URL
|
||||
@@ -71,16 +69,15 @@ async fn test_slug_transfer() {
|
||||
|
||||
// Register globally
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!trans-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!trans-slug', ?1, ?2, ?3, ?4, 'active', NULL);",
|
||||
rusqlite::params![tid_a.as_str(), &url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
|
||||
@@ -112,12 +109,12 @@ async fn test_slug_transfer() {
|
||||
bzod::db::content::delete_url(&old_conn, &url_to_copy.id).unwrap();
|
||||
|
||||
// 3. Update global slugs and quotas
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![id_b, now, "!trans-slug"],
|
||||
"UPDATE global_urls SET owner_tenant_id = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![tid_b.as_str(), now, "!trans-slug"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
+51
-27
@@ -19,29 +19,41 @@ async fn test_soft_delete_reserves_slug() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!slug-to-delete",
|
||||
10,
|
||||
"url",
|
||||
"url_123",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!slug-to-delete', 't-tenant10', 'url_123', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Soft delete slug
|
||||
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
|
||||
// Soft delete slug (disable it)
|
||||
urls_conn
|
||||
.execute(
|
||||
"UPDATE global_urls SET status = 'disabled', retired_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![now, "!slug-to-delete"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it is not available (keeps the slug reserved)
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-delete").unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!slug-to-delete",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!avail);
|
||||
|
||||
let status: String = system_conn
|
||||
let status: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT status FROM global_slugs WHERE slug = ?1;",
|
||||
"SELECT status FROM global_urls WHERE slug = ?1;",
|
||||
["!slug-to-delete"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -59,24 +71,36 @@ async fn test_permanent_delete_releases_slug() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!slug-to-purge",
|
||||
10,
|
||||
"url",
|
||||
"url_456",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!slug-to-purge', 't-tenant10', 'url_456', ?1, ?2, 'active', NULL);",
|
||||
rusqlite::params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Release global slug (permanent deletion)
|
||||
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"DELETE FROM global_urls WHERE slug = ?1;",
|
||||
["!slug-to-purge"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify slug is released (available for reuse)
|
||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-purge").unwrap();
|
||||
let avail = bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"!slug-to-purge",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(avail);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
+30
-22
@@ -18,34 +18,38 @@ async fn test_cleanup_stale_reservations() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago)
|
||||
let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||
[&old_time]
|
||||
).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!stale-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)",
|
||||
[&old_time],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it exists before cleanup
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
|
||||
// Run cleanup
|
||||
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||
// Run cleanup for records older than 15 minutes (900 seconds)
|
||||
let cleaned =
|
||||
bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap();
|
||||
assert_eq!(cleaned, 1);
|
||||
|
||||
// Verify it is gone
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!stale-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -62,24 +66,28 @@ async fn test_cleanup_preserves_valid_reservations() {
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// Insert a reserving slug that is brand new (e.g. 1 minute ago)
|
||||
let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||
[&new_time]
|
||||
).unwrap();
|
||||
urls_conn
|
||||
.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!fresh-slug', 't-tenant1234', '', ?1, ?1, 'reserving', NULL)",
|
||||
[&new_time],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run cleanup
|
||||
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||
// Run cleanup for records older than 15 minutes (900 seconds)
|
||||
let cleaned =
|
||||
bzod::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900).unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
|
||||
// Verify it is still there
|
||||
let exists: bool = system_conn
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')",
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!fresh-slug')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
|
||||
@@ -99,6 +99,15 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
system_conn
|
||||
.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('!legacy-link', 1, 'url', ?1, ?2, ?3, 'active');",
|
||||
rusqlite::params![&url.id, now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 4. analytics.db
|
||||
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
@@ -131,10 +140,17 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Normalize legacy flat layout to multi-tenant paths
|
||||
bzod::services::backup_layout::normalize_restored_layout(&temp_dir).unwrap();
|
||||
|
||||
// Now start the application setup which should trigger migrations/upgrade
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
let db = Db::init(&config).expect("Db::init failed to upgrade v0.4.0 database directory");
|
||||
|
||||
// Run identity and slug migrations for legacy data
|
||||
let _ = bzod::db::identity_migrate::run_identity_migration(&config, false, true).await;
|
||||
let _ = bzod::db::slug_migrate::run_global_slug_migration(&config, false, true).await;
|
||||
|
||||
// Verify file layout was reorganized
|
||||
assert!(!legacy_admin_path.exists());
|
||||
assert!(!legacy_content_path.exists());
|
||||
@@ -153,8 +169,6 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
@@ -206,7 +220,16 @@ async fn test_upgrade_from_v0_4_0() {
|
||||
// 2. Existing links redirect correctly
|
||||
let redirect_url = format!("{}/!legacy-link", base_url);
|
||||
let redirect_res = client.get(&redirect_url).send().await.unwrap();
|
||||
let res_headers = redirect_res.headers();
|
||||
let status = redirect_res.status();
|
||||
let res_headers = redirect_res.headers().clone();
|
||||
let body = redirect_res.text().await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::MOVED_PERMANENTLY,
|
||||
"Got status {} with body {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
assert!(res_headers.get("location").is_some());
|
||||
assert_eq!(
|
||||
res_headers.get("location").unwrap().to_str().unwrap(),
|
||||
|
||||
@@ -31,12 +31,16 @@ async fn test_user_deletion_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let (user_id, user_dir) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let dir = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.id
|
||||
.dir(&bzod::db::topology::Topology::new(&temp_dir))
|
||||
.unwrap();
|
||||
(user.id, dir)
|
||||
};
|
||||
|
||||
// Add session and API token
|
||||
@@ -52,8 +56,7 @@ async fn test_user_deletion_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify directory is deleted
|
||||
let user_dir = temp_dir.join("users").join(user_id.to_string());
|
||||
// Verify tenant directory is deleted
|
||||
assert!(!user_dir.exists());
|
||||
|
||||
// Verify sessions/tokens are cascadingly deleted
|
||||
|
||||
@@ -38,21 +38,26 @@ async fn test_user_database_isolation() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (id_a, id_b) = {
|
||||
let (user_a, user_b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id;
|
||||
.unwrap();
|
||||
(a, b)
|
||||
};
|
||||
|
||||
let dir_a = temp_dir.join("users").join(id_a.to_string());
|
||||
let dir_b = temp_dir.join("users").join(id_b.to_string());
|
||||
let topology = bzod::db::topology::Topology::new(&temp_dir);
|
||||
let dir_a = bzod::db::tenant::location_for_user(&user_a)
|
||||
.unwrap()
|
||||
.dir(&topology)
|
||||
.unwrap();
|
||||
let dir_b = bzod::db::tenant::location_for_user(&user_b)
|
||||
.unwrap()
|
||||
.dir(&topology)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(dir_a, dir_b);
|
||||
assert!(dir_a.join("content.db").exists());
|
||||
|
||||
@@ -36,7 +36,10 @@ async fn test_user_creation() {
|
||||
.unwrap();
|
||||
assert_eq!(user.status, "active");
|
||||
|
||||
let user_dir = temp_dir.join("users").join(user.id.to_string());
|
||||
let user_dir = bzod::db::tenant::location_for_user(&user)
|
||||
.unwrap()
|
||||
.dir(&bzod::db::topology::Topology::new(&temp_dir))
|
||||
.unwrap();
|
||||
assert!(user_dir.join("content.db").exists());
|
||||
assert!(user_dir.join("analytics.db").exists());
|
||||
assert!(user_dir.join("profile.db").exists());
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bzod::analytics::AnalyticsQueue;
|
||||
use bzod::config::Config;
|
||||
use bzod::db::topology::Topology;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use bzod::web::create_router;
|
||||
|
||||
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
config
|
||||
}
|
||||
|
||||
fn build_test_state(db: &Db, config: &Config) -> AppState {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_provisioning_has_no_tenant_directory() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_admin_dir_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// 1. Initialize Db
|
||||
let db = Db::init(&config).expect("Db::init failed");
|
||||
let topology = Topology::new(&temp_dir);
|
||||
|
||||
// 2. Create Admin user via CLI
|
||||
let admin_res = bzod::cli::create_admin::run(
|
||||
Some("platform_admin".to_string()),
|
||||
Some("supersecretpass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(admin_res.is_ok());
|
||||
|
||||
// 3. Verify Admin in admin.db and users.db
|
||||
let admin_user = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&users_conn, "platform_admin")
|
||||
.unwrap()
|
||||
.expect("Admin user must exist in users.db");
|
||||
assert_eq!(u.account_type, "admin");
|
||||
assert!(u.tenant_id.is_none(), "Admin must NOT have a TenantId");
|
||||
u
|
||||
};
|
||||
|
||||
// 4. Verify NO tenant directory was created for admin
|
||||
let users_dir = topology.users_dir();
|
||||
if users_dir.exists() {
|
||||
let entries: Vec<_> = fs::read_dir(&users_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
for entry in entries {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
assert_ne!(name, format!("{}", admin_user.id));
|
||||
assert_ne!(name, "platform_admin");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_cannot_create_unowned_application_resources() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_unowned_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Db::init failed");
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let router = create_router(state);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{}", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// 1. Create Admin
|
||||
let _ = bzod::cli::create_admin::run(
|
||||
Some("operator".to_string()),
|
||||
Some("pass123456".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Log in as admin
|
||||
let login_page = client
|
||||
.get(format!("{}/admin/login", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Extract CSRF token
|
||||
let csrf = login_page
|
||||
.split("name=\"csrf_token\" value=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("username", "operator");
|
||||
params.insert("password", "pass123456");
|
||||
params.insert("csrf_token", csrf);
|
||||
|
||||
let login_res = client
|
||||
.post(format!("{}/admin/login", base_url))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(login_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// 3. Admin attempts to create URL via POST /admin/urls/create -> Should redirect with error
|
||||
let mut url_form = HashMap::new();
|
||||
url_form.insert("destination", "https://example.com");
|
||||
url_form.insert("code", "!admin_link");
|
||||
url_form.insert("csrf_token", csrf);
|
||||
|
||||
let create_url_res = client
|
||||
.post(format!("{}/admin/urls/create", base_url))
|
||||
.form(&url_form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
let location = create_url_res
|
||||
.headers()
|
||||
.get("location")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
location.contains("error="),
|
||||
"Admin URL creation must be rejected with error redirect"
|
||||
);
|
||||
|
||||
// 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should redirect with error
|
||||
let mut page_form = HashMap::new();
|
||||
page_form.insert("title", "Admin Page");
|
||||
page_form.insert("slug", "!admin_page");
|
||||
page_form.insert("code", "!admin_page");
|
||||
page_form.insert("custom_slug", "");
|
||||
page_form.insert("state", "published");
|
||||
page_form.insert("html_content", "<h1>Admin</h1>");
|
||||
page_form.insert("csrf_token", csrf);
|
||||
|
||||
let create_page_res = client
|
||||
.post(format!("{}/admin/pages/create", base_url))
|
||||
.form(&page_form)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
let location = create_page_res
|
||||
.headers()
|
||||
.get("location")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
location.contains("error="),
|
||||
"Admin Landing Page creation must be rejected with error redirect"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_platform_click_aggregation_without_admin_analytics() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_agg_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Db::init failed");
|
||||
|
||||
// 1. Create two standard tenant users
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("tenant_one".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("tenant_two".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (u1, u2) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user1 = bzod::db::users::get_user_by_username(&conn, "tenant_one")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let user2 = bzod::db::users::get_user_by_username(&conn, "tenant_two")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(user1, user2)
|
||||
};
|
||||
|
||||
// 2. Insert clicks directly into tenant 1 and tenant 2 analytics DBs
|
||||
let t1_tid = u1.tenant_id.unwrap();
|
||||
let t2_tid = u2.tenant_id.unwrap();
|
||||
|
||||
let t1_analytics_path = db.topology.tenant_analytics_db(t1_tid);
|
||||
let t2_analytics_path = db.topology.tenant_analytics_db(t2_tid);
|
||||
|
||||
{
|
||||
let conn1 = rusqlite::Connection::open(&t1_analytics_path).unwrap();
|
||||
let _ = conn1.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v1', 'url', 'url1', '2026-08-20T10:00:00Z', '1.1.1.1', 'US', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
let _ = conn1.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v2', 'url', 'url1', '2026-08-20T10:01:00Z', '1.1.1.2', 'US', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let conn2 = rusqlite::Connection::open(&t2_analytics_path).unwrap();
|
||||
let _ = conn2.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, country, referer, user_agent, accept_language, status_code)
|
||||
VALUES ('v3', 'url', 'url2', '2026-08-20T10:02:00Z', '2.2.2.2', 'CA', 'direct', 'ua', 'en', 301);",
|
||||
[],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// 3. Aggregate platform total clicks
|
||||
let total_clicks = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
total_clicks, 3,
|
||||
"Platform clicks must aggregate across all active tenant databases"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_resource_inspection_and_moderation() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p5_mod_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Db::init failed");
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// 1. Create standard tenant user
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("content_author".to_string()),
|
||||
Some("pass123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (author_id, author_tid) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "content_author")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(u.id, u.tenant_id.unwrap())
|
||||
};
|
||||
|
||||
// 2. Author creates URL
|
||||
let url_id = {
|
||||
let conn = bzod::jobs::open_user_content_conn(&db, author_id).unwrap();
|
||||
let u = bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!moderated-link",
|
||||
"https://bad-site.com",
|
||||
Some("Spam Link"),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(
|
||||
&urls_conn,
|
||||
"!moderated-link",
|
||||
&author_tid,
|
||||
&u.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
u.id
|
||||
};
|
||||
|
||||
// 3. Verify Admin can look up slug and inspect owner tenant
|
||||
let slug_info = state
|
||||
.lookup_slug("!moderated-link")
|
||||
.unwrap()
|
||||
.expect("Slug must exist in global registry");
|
||||
assert_eq!(slug_info.owner_tenant_id, author_tid.as_str());
|
||||
|
||||
// 4. Admin opens tenant content DB in CoreJob mode to inspect
|
||||
let tenant_dbs = state
|
||||
.open_tenant(author_tid, bzod::state::TenantOpenMode::CoreJob)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = tenant_dbs.content.lock().unwrap();
|
||||
let fetched_url = bzod::db::content::get_url_by_id(&conn, &url_id)
|
||||
.unwrap()
|
||||
.expect("URL must exist in tenant content.db");
|
||||
assert_eq!(fetched_url.destination, "https://bad-site.com");
|
||||
}
|
||||
|
||||
// 5. Admin retires/moderates the slug
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let retired =
|
||||
bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "!moderated-link").unwrap();
|
||||
assert!(retired);
|
||||
}
|
||||
|
||||
// 6. Verify slug cannot be re-registered by another tenant
|
||||
let _other_tid = TenantId::generate();
|
||||
let r_conn = db.reserved.lock().unwrap();
|
||||
let u_conn = db.global_urls.lock().unwrap();
|
||||
let p_conn = db.global_landing_pages.lock().unwrap();
|
||||
let available =
|
||||
bzod::db::slugs::is_slug_available(&r_conn, &u_conn, &p_conn, "!moderated-link").unwrap();
|
||||
assert!(
|
||||
!available,
|
||||
"Retired slug must remain unavailable across the platform"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! v0.8.0 Phase 1: frozen database topology under the existing physical root.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::slugs::CORE_RESERVED_SLUGS;
|
||||
use bzod::db::sqlite::get_user_version;
|
||||
use bzod::db::topology::{is_valid_user_dir_name, Topology};
|
||||
use bzod::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_topology_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_root_stays_data_dir_not_database() {
|
||||
let t = Topology::new("./data");
|
||||
assert_eq!(t.root(), std::path::Path::new("./data"));
|
||||
assert!(!t.root().ends_with("database"));
|
||||
assert!(t.slugs_dir().ends_with("data/slugs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn db_init_creates_frozen_slug_topology() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
|
||||
assert!(db.topology.admin_db().exists());
|
||||
assert!(db.topology.system_db().exists());
|
||||
assert!(db.topology.users_registry_db().exists());
|
||||
assert!(db.topology.global_urls_db().exists());
|
||||
assert!(db.topology.global_landing_pages_db().exists());
|
||||
assert!(db.topology.reserved_db().exists());
|
||||
assert!(!temp_dir.join("database").exists());
|
||||
|
||||
{
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
bzod::db::schema_v08::GLOBAL_URLS_MIGRATIONS
|
||||
.last()
|
||||
.unwrap()
|
||||
.version
|
||||
);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "Phase 1 does not move live slugs off system.db");
|
||||
}
|
||||
{
|
||||
let conn = db.global_landing_pages.lock().unwrap();
|
||||
assert_eq!(
|
||||
get_user_version(&conn).unwrap(),
|
||||
bzod::db::schema_v08::GLOBAL_LANDING_PAGES_MIGRATIONS
|
||||
.last()
|
||||
.unwrap()
|
||||
.version
|
||||
);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
{
|
||||
let conn = db.reserved.lock().unwrap();
|
||||
assert_eq!(get_user_version(&conn).unwrap(), 1);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, CORE_RESERVED_SLUGS.len() as i64);
|
||||
let has_admin: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = 'admin');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_admin);
|
||||
}
|
||||
|
||||
// Live slug registry is still system.db (Phase 4 moves ownership).
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let has_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_table);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_includes_slugs_directory() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
|
||||
.await
|
||||
.expect("backup");
|
||||
|
||||
let file = fs::File::open(&backup_path).unwrap();
|
||||
let dec = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(dec);
|
||||
let mut found_reserved = false;
|
||||
let mut found_global_urls = false;
|
||||
for entry in archive.entries().unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
if path.contains("slugs/reserved.db") {
|
||||
found_reserved = true;
|
||||
}
|
||||
if path.contains("slugs/global_urls.db") {
|
||||
found_global_urls = true;
|
||||
}
|
||||
}
|
||||
assert!(found_reserved, "backup missing slugs/reserved.db");
|
||||
assert!(found_global_urls, "backup missing slugs/global_urls.db");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_path_resolution_rejects_traversal() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert!(t.user_dir("..").is_err());
|
||||
assert!(t.user_dir("../etc").is_err());
|
||||
assert!(t.content_db_i64(-1).is_err());
|
||||
assert!(is_valid_user_dir_name("2"));
|
||||
assert!(is_valid_user_dir_name("a1b2c3d4e5f6"));
|
||||
assert!(!is_valid_user_dir_name("admin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_user_content_rejects_non_positive_id() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).expect("init");
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, 0).is_err());
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, -3).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_normalization_creates_slugs_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("bzod_v08_restore_{}", uuid::Uuid::new_v4()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("admin.db"), b"a").unwrap();
|
||||
fs::write(dir.join("system.db"), b"s").unwrap();
|
||||
fs::write(dir.join("users.db"), b"u").unwrap();
|
||||
fs::write(dir.join("content.db"), b"c").unwrap();
|
||||
fs::write(dir.join("analytics.db"), b"an").unwrap();
|
||||
|
||||
bzod::services::backup_layout::normalize_restored_layout(&dir).unwrap();
|
||||
assert!(dir.join("slugs").is_dir());
|
||||
assert!(Connection::open(dir.join("admin/admin.db")).is_ok());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! v0.8.0 Phase 3: Identity Migration Integration Tests.
|
||||
//!
|
||||
//! Tests:
|
||||
//! 1. New users automatically get TenantId + UUID.
|
||||
//! 2. Explicit identity migration lifecycle (preflight, backup, backfill, directory rename, validation).
|
||||
//! 3. Legacy Admin (users/1) is preserved and not converted into a normal tenant.
|
||||
//! 4. Restart-safety & idempotency (repeated migration runs).
|
||||
//! 5. Recovery when target directory already exists.
|
||||
//! 6. Admin bootstrap concurrency without re-entrant mutex deadlock.
|
||||
//! 7. Cross-tenant isolation after identity migration.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::identity_migrate::run_identity_migration;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::state::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_v08_id_migrate_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_user_gets_tenant_id_and_uuid() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("carol".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "carol")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let tid = user.tenant_id.expect("new user must receive TenantId");
|
||||
assert_eq!(tid.as_str().len(), 12);
|
||||
|
||||
let uuid_str = user.uuid.expect("new user must receive UUID");
|
||||
assert!(uuid::Uuid::parse_str(&uuid_str).is_ok());
|
||||
|
||||
// Directory is created under users/<TenantId>/
|
||||
assert!(temp_dir
|
||||
.join("users")
|
||||
.join(tid.as_str())
|
||||
.join("content.db")
|
||||
.exists());
|
||||
assert!(!temp_dir.join("users").join(user.id.to_string()).exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_identity_migration_lifecycle() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// 1. Manually insert legacy admin and legacy users without tenant_id/uuid in users.db
|
||||
let hash = bzod::auth::hash_password("password123").unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (id_bob, id_dave) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('legacy_admin', ?1, 'disabled', ?2, 'system');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('bob', ?1, 'active', ?2, 'standard');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
let b = conn.last_insert_rowid();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES ('dave', ?1, 'active', ?2, 'standard');",
|
||||
rusqlite::params![hash, now],
|
||||
).unwrap();
|
||||
let d = conn.last_insert_rowid();
|
||||
(b, d)
|
||||
};
|
||||
|
||||
// 2. Provision legacy directories at users/1 (legacy admin) and users/<id>/ (bob & dave)
|
||||
let legacy_admin_dir = temp_dir.join("users").join("1");
|
||||
let legacy_bob_dir = temp_dir.join("users").join(id_bob.to_string());
|
||||
let legacy_dave_dir = temp_dir.join("users").join(id_dave.to_string());
|
||||
fs::create_dir_all(&legacy_admin_dir).unwrap();
|
||||
fs::create_dir_all(&legacy_bob_dir).unwrap();
|
||||
fs::create_dir_all(&legacy_dave_dir).unwrap();
|
||||
|
||||
// Seed content in Bob's legacy content.db
|
||||
let bob_content_path = legacy_bob_dir.join("content.db");
|
||||
{
|
||||
let mut conn = Connection::open(&bob_content_path).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
&mut conn,
|
||||
"content",
|
||||
bzod::db::migrations::CONTENT_MIGRATIONS,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!bob-link",
|
||||
"https://bob.example.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 3. Run Dry Run Preflight
|
||||
let dry_report = run_identity_migration(&config, true, true)
|
||||
.await
|
||||
.expect("dry run");
|
||||
assert_eq!(dry_report.users_assigned_tenant_id, 2);
|
||||
assert_eq!(dry_report.users_assigned_uuid, 2);
|
||||
assert!(legacy_bob_dir.exists(), "dry run must not move directory");
|
||||
|
||||
// 4. Run Execution
|
||||
let report = run_identity_migration(&config, false, true)
|
||||
.await
|
||||
.expect("migration");
|
||||
assert_eq!(report.users_assigned_tenant_id, 2);
|
||||
assert_eq!(report.users_assigned_uuid, 2);
|
||||
assert_eq!(report.directories_moved, 2); // Both Bob and Dave had directories moved
|
||||
assert!(report.validation_passed);
|
||||
assert!(report.legacy_admin_preserved);
|
||||
|
||||
// 5. Verify Bob's identity and migrated directory
|
||||
let bob = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let bob_tid = bob.tenant_id.expect("bob must have TenantId");
|
||||
assert_eq!(bob_tid.as_str().len(), 12);
|
||||
assert!(bob.uuid.is_some());
|
||||
|
||||
let migrated_bob_dir = temp_dir.join("users").join(bob_tid.as_str());
|
||||
assert!(
|
||||
migrated_bob_dir.exists(),
|
||||
"migrated directory must exist at users/<TenantId>/"
|
||||
);
|
||||
assert!(!legacy_bob_dir.exists(), "legacy directory must be moved");
|
||||
|
||||
// Verify Bob's content is preserved exactly
|
||||
{
|
||||
let conn = Connection::open(migrated_bob_dir.join("content.db")).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&conn, "!bob-link")
|
||||
.unwrap()
|
||||
.expect("bob's link must exist in migrated content.db");
|
||||
assert_eq!(url.destination, "https://bob.example.com");
|
||||
}
|
||||
|
||||
// 6. Verify legacy admin (users/1) was preserved
|
||||
assert!(
|
||||
temp_dir.join("users").join("1").exists(),
|
||||
"legacy admin directory users/1 must be preserved"
|
||||
);
|
||||
|
||||
// 7. Verify Completion Marker in system.db
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let marker: Option<String> = system_conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'v08_identity_migration_completed';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(marker.is_some());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_is_restart_safe_and_idempotent() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create a normal user
|
||||
bzod::cli::create_user::run(
|
||||
Some("eva".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let original_tid = original_user.tenant_id.unwrap();
|
||||
let original_uuid = original_user.uuid.unwrap();
|
||||
|
||||
// Run migration twice
|
||||
let report1 = run_identity_migration(&config, false, true).await.unwrap();
|
||||
let report2 = run_identity_migration(&config, false, true).await.unwrap();
|
||||
|
||||
assert_eq!(report1.users_assigned_tenant_id, 0); // already had TenantId
|
||||
assert_eq!(report2.users_assigned_tenant_id, 0);
|
||||
|
||||
let current_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Identity mappings must be identical (never regenerated)
|
||||
assert_eq!(current_user.tenant_id.unwrap(), original_tid);
|
||||
assert_eq!(current_user.uuid.unwrap(), original_uuid);
|
||||
assert!(temp_dir.join("users").join(original_tid.as_str()).exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_bootstrap_concurrency_no_deadlock() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
// Call create_admin_user multiple times or simulate bootstrap login
|
||||
let hash = bzod::auth::hash_password("securepassword").unwrap();
|
||||
let res = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_admin_user(&conn, "admin_user", &hash)
|
||||
};
|
||||
assert!(res.is_ok());
|
||||
let admin = res.unwrap();
|
||||
assert_eq!(admin.account_type, "admin");
|
||||
assert!(admin.uuid.is_some());
|
||||
assert_eq!(admin.tenant_id, None, "admin is Core-only");
|
||||
|
||||
let _ = state;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_tenant_isolation_after_migration() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
// Create user X and user Y
|
||||
bzod::cli::create_user::run(
|
||||
Some("userx".into()),
|
||||
Some("pass123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
bzod::cli::create_user::run(Some("usery".into()), Some("pass123".into()), None, config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (x, y) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
(
|
||||
bzod::db::users::get_user_by_username(&conn, "userx")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bzod::db::users::get_user_by_username(&conn, "usery")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
let tid_x = x.tenant_id.unwrap();
|
||||
let tid_y = y.tenant_id.unwrap();
|
||||
|
||||
let dbs_x = state.open_tenant(tid_x, TenantOpenMode::Ordinary).unwrap();
|
||||
{
|
||||
let conn = dbs_x.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!x-secret",
|
||||
"https://x.example.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let dbs_y = state.open_tenant(tid_y, TenantOpenMode::Ordinary).unwrap();
|
||||
let stolen = {
|
||||
let conn = dbs_y.content.lock().unwrap();
|
||||
bzod::db::content::get_url_by_code(&conn, "!x-secret").unwrap()
|
||||
};
|
||||
assert!(
|
||||
stolen.is_none(),
|
||||
"Tenant Y must not see Tenant X's secret content"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bzod::analytics::AnalyticsQueue;
|
||||
use bzod::cli::RepairCommands;
|
||||
use bzod::config::Config;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::topology::Topology;
|
||||
use bzod::db::Db;
|
||||
use bzod::models::visit::VisitRecord;
|
||||
use bzod::services::bulk_urls::{create_urls_bulk, BulkUrlCreateItem};
|
||||
use bzod::services::registry_validator::{RegistryIssueType, RegistryValidator};
|
||||
use bzod::services::slug_transfer::{transfer_slug, SlugTransferRequest};
|
||||
use bzod::state::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn create_test_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_p6a_test_{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn build_test_state(db: &Db, config: &Config) -> AppState {
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f01_bulk_urls_uses_v08_slug_registry() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create a tenant
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "bulkuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let tenant_id = user.tenant_id.unwrap();
|
||||
|
||||
let user_dbs = state
|
||||
.open_tenant(tenant_id, TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
|
||||
let items = vec![
|
||||
BulkUrlCreateItem {
|
||||
code: Some("!bulk1".to_string()),
|
||||
destination: "https://example.com/1".to_string(),
|
||||
title: Some("Bulk 1".to_string()),
|
||||
description: None,
|
||||
tags: Some(vec!["tag1".to_string()]),
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
},
|
||||
BulkUrlCreateItem {
|
||||
code: Some("!bulk2".to_string()),
|
||||
destination: "https://example.com/2".to_string(),
|
||||
title: Some("Bulk 2".to_string()),
|
||||
description: None,
|
||||
tags: Some(vec!["tag2".to_string()]),
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
let created = create_urls_bulk(
|
||||
&user_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
user.id,
|
||||
tenant_id,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.len(), 2);
|
||||
|
||||
// Verify slugs are in slugs/global_urls.db with tenant_id
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let (owner, status): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk1';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner, tenant_id.as_str());
|
||||
assert_eq!(status, "active");
|
||||
|
||||
let (owner2, status2): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!bulk2';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner2, tenant_id.as_str());
|
||||
assert_eq!(status2, "active");
|
||||
}
|
||||
|
||||
// Verify zero records in system.db.global_slugs
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let count: i64 = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"system.db.global_slugs must not receive bulk URLs"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f03_slug_transfer_uses_v08_architecture() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create source and dest users
|
||||
let u1 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "alice", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u2 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "bob", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u1_tid = u1.tenant_id.unwrap();
|
||||
let u2_tid = u2.tenant_id.unwrap();
|
||||
|
||||
let u1_dbs = state.open_tenant(u1_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL for alice
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!transferslug".to_string()),
|
||||
destination: "https://example.com/transfer".to_string(),
|
||||
title: Some("Transfer Me".to_string()),
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u1_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u1.id,
|
||||
u1_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Transfer slug to bob
|
||||
let req = SlugTransferRequest {
|
||||
slug: "!transferslug".to_string(),
|
||||
new_owner_user_id: u2.id,
|
||||
};
|
||||
let transfer_res = transfer_slug(&state, &req, "admin").unwrap();
|
||||
|
||||
assert_eq!(transfer_res.old_owner_tenant_id, u1_tid);
|
||||
assert_eq!(transfer_res.new_owner_tenant_id, u2_tid);
|
||||
|
||||
// Verify global_urls.db ownership updated
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let owner: String = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = '!transferslug';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(owner, u2_tid.as_str());
|
||||
}
|
||||
|
||||
// Verify bob has the URL in content DB
|
||||
let bob_dbs = state.open_tenant(u2_tid, TenantOpenMode::CoreJob).unwrap();
|
||||
let bob_conn = bob_dbs.content.lock().unwrap();
|
||||
let bob_url = bzod::db::content::get_url_by_code(&bob_conn, "!transferslug")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bob_url.destination, "https://example.com/transfer");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f02_restore_identity_resolution_and_v08_registry() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// 1. Create a user with tenant
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "restoreuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!restorelink".to_string()),
|
||||
destination: "https://example.com/restore".to_string(),
|
||||
title: Some("Restore Link".to_string()),
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2. Backup user
|
||||
let backup_dir = temp_dir.join("backups");
|
||||
fs::create_dir_all(&backup_dir).unwrap();
|
||||
let archive_path = backup_dir.join("restoreuser_backup.tar.zst");
|
||||
bzod::cli::backup_user::run(
|
||||
"restoreuser".to_string(),
|
||||
Some(archive_path.to_str().unwrap().to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3. Delete user
|
||||
bzod::cli::delete_user::run(u.id, false, None, config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify slug removed from global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!restorelink');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
// 4. Restore user from archive
|
||||
bzod::cli::restore_user::run(
|
||||
archive_path.to_str().unwrap().to_string(),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Confirm user restored with same TenantId
|
||||
let restored_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "restoreuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(restored_user.tenant_id, Some(u_tid));
|
||||
|
||||
// Confirm slug restored in slugs/global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let (owner, status): (String, String) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, status FROM global_urls WHERE slug = '!restorelink';",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.expect("restored slug should be present in global_urls.db");
|
||||
assert_eq!(owner, u_tid.as_str());
|
||||
assert_eq!(status, "active");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f07_registry_validation_and_non_destructive_repair() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
// Create a tenant and open it so content.db is created
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "repairuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
let _ = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Insert an active orphan slug in global_urls.db (valid tenant and content.db, but target record missing)
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES ('!orphanslug', ?1, 'missing-id', ?2, ?3, 'active', NULL);",
|
||||
rusqlite::params![u_tid.as_str(), now, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// 1. Read-only validation scan
|
||||
let issues = {
|
||||
let sys_conn = db.system.lock().unwrap();
|
||||
let usr_conn = db.users.lock().unwrap();
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None).unwrap()
|
||||
};
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].issue_type, RegistryIssueType::MissingTarget);
|
||||
assert_eq!(issues[0].slug, "!orphanslug");
|
||||
|
||||
// 2. Dry run repair does NOT delete
|
||||
bzod::cli::repair::run(
|
||||
RepairCommands::Registry {
|
||||
dry_run: true,
|
||||
force: false,
|
||||
slug: None,
|
||||
data_dir: None,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
}
|
||||
|
||||
// 3. Force repair cleans the missing target
|
||||
bzod::cli::repair::run(
|
||||
RepairCommands::Registry {
|
||||
dry_run: false,
|
||||
force: true,
|
||||
slug: None,
|
||||
data_dir: None,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!orphanslug');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f08_user_deletion_and_storage_cleanup() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "tobedeleted", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create a URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!deleteme".to_string()),
|
||||
destination: "https://example.com/del".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tenant_dir = Topology::new(&temp_dir).tenant_dir(u_tid);
|
||||
assert!(tenant_dir.exists());
|
||||
|
||||
// Delete user
|
||||
bzod::cli::delete_user::run(u.id, false, None, config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Tenant dir deleted
|
||||
assert!(
|
||||
!tenant_dir.exists(),
|
||||
"tenant storage directory must be deleted"
|
||||
);
|
||||
|
||||
// Slugs removed from global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let exists: bool = urls_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = '!deleteme');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f10_analytics_worker_tenant_identity() {
|
||||
let (temp_dir, config) = create_test_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = build_test_state(&db, &config);
|
||||
|
||||
let u = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::create_user(&conn, "analyticsuser", "hash", "user", None).unwrap()
|
||||
};
|
||||
let u_tid = u.tenant_id.unwrap();
|
||||
|
||||
let u_dbs = state.open_tenant(u_tid, TenantOpenMode::Ordinary).unwrap();
|
||||
|
||||
// Create URL
|
||||
let items = vec![BulkUrlCreateItem {
|
||||
code: Some("!statslug".to_string()),
|
||||
destination: "https://example.com/stat".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
tags: None,
|
||||
expires_at: None,
|
||||
password: None,
|
||||
max_access_count: None,
|
||||
}];
|
||||
create_urls_bulk(
|
||||
&u_dbs.content,
|
||||
&db.reserved,
|
||||
&db.global_urls,
|
||||
&db.global_landing_pages,
|
||||
&db.users,
|
||||
u.id,
|
||||
u_tid,
|
||||
items,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Push visit record through AnalyticsQueue
|
||||
let visit = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: "target-url-id".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
ip_address: "1.2.3.4".to_string(),
|
||||
user_agent: "TestUA".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en-US".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 302,
|
||||
owner_user_id: Some(u.id),
|
||||
owner_tenant_id: Some(u_tid),
|
||||
};
|
||||
|
||||
state.analytics_queue.push(visit);
|
||||
|
||||
// Give worker time to process or flush
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Check visit was written to tenant analytics DB
|
||||
let analytics_db_path = Topology::new(&temp_dir).tenant_analytics_db(u_tid);
|
||||
if analytics_db_path.exists() {
|
||||
let a_conn = rusqlite::Connection::open(&analytics_db_path).unwrap();
|
||||
let count: i64 = a_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = 'target-url-id';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
assert!(count >= 0);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! v0.8.0 Phase 4: Global Slug Migration Integration Tests.
|
||||
//!
|
||||
//! Tests:
|
||||
//! 1. Preflight and dry-run validation.
|
||||
//! 2. Full transactional migration of URLs, landing pages, and reserved routes to `slugs/*.db`.
|
||||
//! 3. Exact TenantId ownership resolution via users.db.
|
||||
//! 4. Global uniqueness across separate SQLite databases.
|
||||
//! 5. Anti-reuse enforcement: retired slugs in URL or landing page registry can never be re-allocated.
|
||||
//! 6. Restart safety and idempotency.
|
||||
//! 7. Live 301 redirect and 410 Gone resolution from `slugs/*.db`.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::slug_migrate::run_global_slug_migration;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_v08_slug_migrate_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_preflight_and_dry_run() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create a normal user
|
||||
bzod::cli::create_user::run(
|
||||
Some("alice".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alice = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "alice")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Insert legacy slugs in system.db.global_slugs
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('my-link', ?1, 'url', '!link1', ?2, ?2, 'active');",
|
||||
rusqlite::params![alice.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('my-page', ?1, 'page', '!page1', ?2, ?2, 'active');",
|
||||
rusqlite::params![alice.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run Dry Run
|
||||
let dry_report = run_global_slug_migration(&config, true, true)
|
||||
.await
|
||||
.expect("dry run");
|
||||
assert_eq!(dry_report.total_legacy_slugs, 2);
|
||||
assert_eq!(dry_report.url_slugs_migrated, 1);
|
||||
assert_eq!(dry_report.page_slugs_migrated, 1);
|
||||
|
||||
// Target databases should still be empty
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let count: i64 = urls_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "dry run must not write to target global_urls.db");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_slug_migration_lifecycle() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
// Create user Bob
|
||||
bzod::cli::create_user::run(
|
||||
Some("bob".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bob = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let bob_tid = bob.tenant_id.unwrap();
|
||||
|
||||
// Insert active, disabled, and retired legacy slugs
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-active', ?1, 'url', '!active1', ?2, ?2, 'active');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-disabled', ?1, 'url', '!dis1', ?2, ?2, 'disabled');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at)
|
||||
VALUES ('bob-retired', ?1, 'url', '!ret1', ?2, ?2, 'retired', ?2);",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('bob-landing', ?1, 'page', '!page1', ?2, ?2, 'active');",
|
||||
rusqlite::params![bob.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run Migration
|
||||
let report = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.expect("slug migration");
|
||||
assert_eq!(report.total_legacy_slugs, 4);
|
||||
assert_eq!(report.url_slugs_migrated, 3);
|
||||
assert_eq!(report.page_slugs_migrated, 1);
|
||||
assert!(report.validation_passed);
|
||||
|
||||
// Verify records in global_urls.db
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
|
||||
let active_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-active")
|
||||
.unwrap()
|
||||
.expect("active url must exist in global_urls.db");
|
||||
assert_eq!(active_url.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(active_url.status, "active");
|
||||
|
||||
let retired_url = bzod::db::slugs::lookup_url_slug(&urls_conn, "bob-retired")
|
||||
.unwrap()
|
||||
.expect("retired url must exist in global_urls.db");
|
||||
assert_eq!(retired_url.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(retired_url.status, "retired");
|
||||
assert!(retired_url.retired_at.is_some());
|
||||
}
|
||||
|
||||
// Verify record in global_landing_pages.db
|
||||
{
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let page = bzod::db::slugs::lookup_landing_page_slug(&pages_conn, "bob-landing")
|
||||
.unwrap()
|
||||
.expect("page must exist in global_landing_pages.db");
|
||||
assert_eq!(page.owner_tenant_id, bob_tid.as_str());
|
||||
assert_eq!(page.status, "active");
|
||||
}
|
||||
|
||||
// Verify Completion Marker in system.db
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let marker: Option<String> = system_conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'v08_global_slug_migration_completed';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(marker.is_some());
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_global_slug_uniqueness_and_anti_reuse() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
let tid_a = TenantId::generate();
|
||||
let tid_b = TenantId::generate();
|
||||
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
|
||||
// 1. Reserved slug check
|
||||
assert!(
|
||||
!bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "admin")
|
||||
.unwrap(),
|
||||
"Reserved route 'admin' must not be available"
|
||||
);
|
||||
|
||||
// 2. Register URL slug
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "cool-slug", &tid_a, "!target1", "active")
|
||||
.unwrap();
|
||||
|
||||
// 'cool-slug' is now unavailable for URL or page registration
|
||||
assert!(!bzod::db::slugs::is_slug_available(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"cool-slug"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// 3. Retire 'cool-slug'
|
||||
let retired = bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "cool-slug").unwrap();
|
||||
assert!(retired);
|
||||
|
||||
// Anti-reuse invariant: Retired slug MUST REMAIN UNAVAILABLE across both URLs and landing pages
|
||||
assert!(
|
||||
!bzod::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, "cool-slug")
|
||||
.unwrap(),
|
||||
"Retired slug must NEVER become available for reuse"
|
||||
);
|
||||
|
||||
// Attempting to register page with retired slug must fail
|
||||
let page_reg_res = bzod::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
"cool-slug",
|
||||
&tid_b,
|
||||
);
|
||||
assert!(
|
||||
page_reg_res.is_err(),
|
||||
"Cannot allocate a retired slug for a landing page"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_migration_restart_safety_and_idempotency() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("dan".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dan = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "dan")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('dan-link', ?1, 'url', '!dan1', ?2, ?2, 'active');",
|
||||
rusqlite::params![dan.id, now],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Run migration twice
|
||||
let report1 = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let report2 = run_global_slug_migration(&config, false, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report1.url_slugs_migrated, 1);
|
||||
assert_eq!(report2.url_slugs_migrated, 0);
|
||||
assert_eq!(report2.existing_records_verified, 1);
|
||||
assert!(report2.validation_passed);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_live_slug_lookup_and_redirect_resolution() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
|
||||
bzod::cli::create_user::run(
|
||||
Some("eva".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let eva = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "eva")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let eva_tid = eva.tenant_id.unwrap();
|
||||
|
||||
// Register slug in global_urls.db directly
|
||||
{
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
bzod::db::slugs::register_url_slug(&urls_conn, "eva-promo", &eva_tid, "!eva1", "active")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Seed content in Eva's tenant content.db
|
||||
let eva_dbs = state
|
||||
.open_tenant(eva_tid, TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = eva_dbs.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"eva-promo",
|
||||
"https://eva.example.com/promo",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Test AppState::lookup_slug finds the record in global_urls.db
|
||||
let resolved = state
|
||||
.lookup_slug("eva-promo")
|
||||
.unwrap()
|
||||
.expect("must find slug");
|
||||
assert_eq!(resolved.owner_tenant_id, eva_tid.as_str());
|
||||
assert_eq!(resolved.status, "active");
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! v0.8.0 Phase 2: tenant boundary. TenantId is the access key; unknown ids
|
||||
//! must never create databases; user-1 fallbacks are gone.
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::tenant::TenantOpenMode;
|
||||
use bzod::db::Db;
|
||||
use bzod::identity::TenantId;
|
||||
use bzod::state::AppState;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
fn temp_config() -> (PathBuf, Config) {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_boundary_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.join("backups");
|
||||
(temp_dir, config)
|
||||
}
|
||||
|
||||
fn state_from(db: &Db, config: Config) -> AppState {
|
||||
let (queue, _handle) =
|
||||
bzod::analytics::AnalyticsQueue::new(db.clone(), 8, tokio::sync::watch::channel(false).1);
|
||||
AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_id_rejects_invalid_forms() {
|
||||
assert!(TenantId::parse("FAFAFA12C3E4").is_err());
|
||||
assert!(TenantId::parse("abc").is_err());
|
||||
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
|
||||
assert!(TenantId::parse("../etc/passwd").is_err());
|
||||
assert!(TenantId::parse("fafafa12c3e4").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tenant_id_does_not_create_database() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
let forged = TenantId::parse("aaaaaaaaaaaa").unwrap();
|
||||
let before = fs::read_dir(temp_dir.join("users")).unwrap().count();
|
||||
let err = match state.open_tenant(forged, TenantOpenMode::Ordinary) {
|
||||
Ok(_) => panic!("unknown tenant must not open"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(err.to_string().contains("not found"));
|
||||
let after = fs::read_dir(temp_dir.join("users")).unwrap().count();
|
||||
assert_eq!(before, after);
|
||||
assert!(!temp_dir.join("users").join("aaaaaaaaaaaa").exists());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_integer_id_does_not_create_database() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
assert!(state.get_user_dbs(999_999).is_err());
|
||||
assert!(!temp_dir.join("users").join("999999").exists());
|
||||
assert!(bzod::jobs::open_user_content_conn(&db, 999_999).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_user_gets_tenant_id_and_hex_directory() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
bzod::cli::create_user::run(
|
||||
Some("alice".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "alice")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let tid = user.tenant_id.expect("new users receive TenantId");
|
||||
assert_eq!(tid.as_str().len(), 12);
|
||||
assert!(temp_dir
|
||||
.join("users")
|
||||
.join(tid.as_str())
|
||||
.join("content.db")
|
||||
.exists());
|
||||
assert!(!temp_dir.join("users").join(user.id.to_string()).exists());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tenant_a_cannot_open_tenant_b_by_forged_id() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("usera".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
bzod::cli::create_user::run(
|
||||
Some("userb".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (a, b) = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
(
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
bzod::db::users::get_user_by_username(&conn, "userb")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
let dbs_a = state
|
||||
.open_tenant(a.tenant_id.unwrap(), TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
{
|
||||
let conn = dbs_a.content.lock().unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&conn,
|
||||
"!only-a",
|
||||
"https://example.com/a",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let dbs_b = state
|
||||
.open_tenant(b.tenant_id.unwrap(), TenantOpenMode::Ordinary)
|
||||
.unwrap();
|
||||
let stolen = {
|
||||
let conn = dbs_b.content.lock().unwrap();
|
||||
bzod::db::content::get_url_by_code(&conn, "!only-a").unwrap()
|
||||
};
|
||||
assert!(stolen.is_none());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_user_denied_ordinary_tenant_access() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("bob".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = bzod::db::users::get_user_by_username(&conn, "bob")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bzod::db::users::update_user_status(&conn, u.id, "disabled").unwrap();
|
||||
u
|
||||
};
|
||||
let tid = user.tenant_id.unwrap();
|
||||
let err = match state.open_tenant(tid, TenantOpenMode::Ordinary) {
|
||||
Ok(_) => panic!("disabled tenant must not open ordinarily"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("denied") || err.to_string().contains("status")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleted_user_cannot_open_tenant_db() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config.clone());
|
||||
bzod::cli::create_user::run(
|
||||
Some("gone".into()),
|
||||
Some("password123".into()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "gone")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let tid = user.tenant_id.unwrap();
|
||||
bzod::cli::delete_user::run(user.id, false, None, config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.open_tenant(tid, TenantOpenMode::Ordinary).is_err());
|
||||
assert!(state.get_user_dbs(user.id).is_err());
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_worker_does_not_create_tenant_1_for_missing_owner() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let before = temp_dir
|
||||
.join("users")
|
||||
.join("1")
|
||||
.join("analytics.db")
|
||||
.exists();
|
||||
let batch = vec![bzod::models::VisitRecord {
|
||||
id: "v1".into(),
|
||||
target_type: "url".into(),
|
||||
target_id: "u1".into(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
ip_address: "127.0.0.1".into(),
|
||||
user_agent: "test".into(),
|
||||
referer: "Direct".into(),
|
||||
accept_language: "en".into(),
|
||||
country: "US".into(),
|
||||
status_code: 200,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: None,
|
||||
}];
|
||||
// flush_batch is private; missing owner is dropped and must not create unknown dirs.
|
||||
assert!(bzod::jobs::open_user_analytics_conn(&db, 424242).is_err());
|
||||
assert!(!temp_dir.join("users").join("424242").exists());
|
||||
let after = temp_dir
|
||||
.join("users")
|
||||
.join("1")
|
||||
.join("analytics.db")
|
||||
.exists();
|
||||
assert_eq!(before, after);
|
||||
let _ = batch.len();
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_gate_does_not_fall_back_to_tenant_1() {
|
||||
let (temp_dir, config) = temp_config();
|
||||
let db = Db::init(&config).unwrap();
|
||||
let state = state_from(&db, config);
|
||||
// No global slug, no user-1 lookup: unknown code is missing.
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'zz9999');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!exists);
|
||||
}
|
||||
let _ = state;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
Reference in new issue
Block a user