BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation

This commit is contained in:
thakares committed 2026-06-19 14:51:38 +05:30
1 parent 743502b183
commit 7dfb8c0f1b
100 files changed
+15588 -309

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_admin_user_listing() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_list_{}", 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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let conn = db.users.lock().unwrap();
let users = bzod::db::users::list_users(&conn).unwrap();
// Verify list contains legacy_admin and usera
assert!(users.len() >= 2);
assert!(users.iter().any(|u| u.username == "legacy_admin"));
assert!(users.iter().any(|u| u.username == "usera"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_user_statistics() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_stats_{}", 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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// Aggregate statistics (e.g. check current quotas match the content databases)
{
let users_conn = db.users.lock().unwrap();
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
// Assert initial counts
assert_eq!(quota.current_urls, 0);
assert_eq!(quota.current_landings, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_system_settings() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_settings_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Verify initial settings are loaded
let recon_hours: String = system_conn
.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(recon_hours, "24");
// Update settings
system_conn
.execute(
"UPDATE settings SET value = '12' WHERE key = 'quota_reconcile_interval_hours';",
[],
)
.unwrap();
let recon_hours2: String = system_conn
.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(recon_hours2, "12");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_moderation_listing() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_mod_list_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a moderation event
system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES ('evt1', 'now', 'admin', 10, 'user10', 'url', '!slug', 'block', 'high', 'violating content');",
[],
).unwrap();
// Query moderation events
let mut stmt = system_conn
.prepare("SELECT id, action, reason FROM moderation_events;")
.unwrap();
let events = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.unwrap();
let event_list: Vec<_> = events.map(|r| r.unwrap()).collect();
assert_eq!(event_list.len(), 1);
assert_eq!(event_list[0].0, "evt1");
assert_eq!(event_list[0].1, "block");
let _ = fs::remove_dir_all(&temp_dir);
}
+207
View File
@@ -0,0 +1,207 @@
use axum::extract::{Form, Query, State};
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc;
use rusqlite::Connection;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::hash_password;
use bzod::config::Config;
use bzod::db::users::{create_admin_user, get_user_by_username};
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::admin::{
users_create_post, users_delete_post, users_get, CreateUserForm, DeleteUserForm, UsersQuery,
};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://localhost:8080".to_string());
config
}
fn build_state(config: Config) -> (Db, AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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,
analytics_queue: queue,
start_time: Instant::now(),
};
(db, state)
}
fn create_admin_session(conn: &Connection, admin_id: i64, session_id: &str) {
let now = Utc::now();
let expires_at = (now + chrono::Duration::hours(1)).to_rfc3339();
let created_at = now.to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![session_id, admin_id, expires_at, created_at],
)
.expect("Failed to insert admin session");
}
#[tokio::test]
async fn test_admin_users_page_and_user_creation() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_user_management_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config.clone());
let password = "adminpassword";
let hash = hash_password(password).unwrap();
let admin_user = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap()
};
let session_token = "session-token-123";
{
let conn = state.users_db.lock().unwrap();
create_admin_session(&conn, admin_user.id, session_token);
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = users_get(
State(state.clone()),
jar,
Query(UsersQuery {
success: None,
error: None,
}),
)
.await;
if response.status().is_redirection() {
let location = response
.headers()
.get("location")
.map(|v| v.to_str().unwrap_or(""));
println!("users_get redirect to: {:?}", location);
}
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 65536)
.await
.unwrap();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("Users Management"));
assert!(body_str.contains("Create New User"));
assert!(body_str.contains("admin"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_create_and_delete_user() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_user_management_delete_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config.clone());
let password = "adminpassword";
let hash = hash_password(password).unwrap();
let admin_user = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap()
};
let session_token = "session-token-456";
{
let conn = state.users_db.lock().unwrap();
create_admin_session(&conn, admin_user.id, session_token);
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let form = CreateUserForm {
username: "testuser".to_string(),
password: "password123".to_string(),
account_type: "standard".to_string(),
metadata: "test metadata".to_string(),
csrf_token: "invalid".to_string(),
};
// invalid CSRF should redirect with error
let response = users_create_post(State(state.clone()), jar.clone(), Form(form)).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("Invalid CSRF token"));
// build a valid CSRF token from admin session
let csrf_token = bzod::auth::generate_csrf_token(session_token);
let form = CreateUserForm {
username: "testuser".to_string(),
password: "password123".to_string(),
account_type: "standard".to_string(),
metadata: "test metadata".to_string(),
csrf_token: csrf_token.clone(),
};
let response = users_create_post(State(state.clone()), jar.clone(), Form(form)).await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/users?success=User created successfully"
);
let created_user_id = {
let conn = state.users_db.lock().unwrap();
let user = get_user_by_username(&conn, "testuser")
.unwrap()
.expect("created user should exist");
user.id
};
let delete_form = DeleteUserForm {
csrf_token: csrf_token.clone(),
};
let response = users_delete_post(
State(state.clone()),
jar.clone(),
HeaderMap::new(),
None,
axum::extract::Path(created_user_id),
Form(delete_form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/users?success=User deleted successfully"
);
{
let conn = state.users_db.lock().unwrap();
let user = get_user_by_username(&conn, "testuser").unwrap();
assert!(user.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
+4
View File
@@ -51,6 +51,7 @@ fn test_advanced_analytics_pagination_and_sorting() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
});
}
insert_visits_batch(&mut conn, &records).unwrap();
@@ -91,6 +92,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
VisitRecord {
id: "v2".to_string(),
@@ -103,6 +105,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
VisitRecord {
id: "v3".to_string(),
@@ -115,6 +118,7 @@ fn test_advanced_analytics_date_filtering() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
];
insert_visits_batch(&mut conn, &records).unwrap();
+3
View File
@@ -121,6 +121,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
VisitRecord {
id: "visit-2".to_string(),
@@ -133,6 +134,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
VisitRecord {
id: "visit-3".to_string(),
@@ -145,6 +147,7 @@ fn test_target_analytics_queries() {
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
owner_user_id: None,
},
];
+676
View File
@@ -0,0 +1,676 @@
use axum::extract::{ConnectInfo, Form, State};
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Utc;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::session::authenticate_api_key;
use bzod::config::Config;
use bzod::db::admin::{create_api_key, create_session};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS, SYSTEM_MIGRATIONS, USERS_MIGRATIONS};
use bzod::db::sqlite::get_user_version;
use bzod::db::users::{create_admin_user, create_user, get_user_by_username};
use bzod::db::Db;
use bzod::web::admin::{dashboard_get, login_post, LoginForm};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config
}
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
(db, state)
}
#[test]
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);
}
#[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);
}
#[test]
fn test_admin_db_schema_version_2() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "admin", ADMIN_MIGRATIONS, None).unwrap();
assert_eq!(get_user_version(&conn).unwrap(), 2);
}
#[tokio::test]
async fn test_admin_account_migration() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_admin_migration_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let users_path = temp_dir.join("admin").join("users.db");
std::fs::create_dir_all(users_path.parent().unwrap()).unwrap();
let mut conn = Connection::open(&users_path).unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
conn.pragma_update(None, "user_version", 1).unwrap();
let password_hash = bzod::auth::hash_password("securepass").unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["admin", password_hash, "active", Utc::now().to_rfc3339(), "standard"],
)
.unwrap();
let mut system_conn = Connection::open(temp_dir.join("admin/system.db")).unwrap();
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None).unwrap();
drop(system_conn);
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Db::init should repair an existing admin account");
let conn = db.users.lock().unwrap();
let user = get_user_by_username(&conn, "admin").unwrap().unwrap();
println!("admin account_type = {}", user.account_type);
println!("admin status = {}", user.status);
let schema_version: i64 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0))
.unwrap();
println!("users db version = {}", schema_version);
assert_eq!(user.account_type, "admin");
assert_eq!(user.status, "active");
let system_conn = db.system.lock().unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM audit_events WHERE action = 'migration_repair' AND actor = 'admin' AND object_type = 'users' AND object_id = 'admin';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_login() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_admin_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpassword";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/dashboard"
);
let conn = state.users_db.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_user_login_rejected() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disabled_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "disabledpass";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["disabled_user", hash, "disabled", Utc::now().to_rfc3339(), "system"],
)
.unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "disabled_user".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let conn = state.users_db.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_session_created_after_login() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_session_created_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass2";
let hash = bzod::auth::hash_password(password).unwrap();
{
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap();
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
let conn = state.users_db.lock().unwrap();
let row_count: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions;", [], |row| row.get(0))
.unwrap();
assert_eq!(row_count, 1);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_dashboard_access() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_dashboard_access_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass3";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
let user = create_admin_user(&conn, "admin", &hash).unwrap();
user.id
};
let session_token = "session_access_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_success());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_standard_user_cannot_access_admin_panel() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_standard_block_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "userpass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
create_user(&conn, "regular_user", &hash, "standard", None)
.unwrap()
.id
};
let session_token = "standard_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&Utc::now().to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_bootstrap_only_when_no_admin_exists() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_bootstrap_block_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let mut config = create_temp_config(temp_dir.clone());
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
let (_db, state) = build_state(config.clone());
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
// Create an existing active admin, which should block bootstrap mode because the user count and admin count are already populated.
let _existing_admin = {
let conn = state.users_db.lock().unwrap();
let user = create_admin_user(
&conn,
"admin",
&bzod::auth::hash_password("admin-password").unwrap(),
)
.unwrap();
create_session(
&conn,
"existing_admin_session",
&user.id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
user.id
};
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let form = LoginForm {
username: "admin".to_string(),
password: "bootstrap-secret".to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_system_account_cannot_access_admin_panel() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_system_block_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "systempass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["system_user", hash, "active", Utc::now().to_rfc3339(), "system"],
)
.unwrap();
conn.last_insert_rowid()
};
let session_token = "system_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_reserved_username_rejected() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_reserved_username_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password_hash = bzod::auth::hash_password("test").unwrap();
let create_result = create_user(
&state.users_db.lock().unwrap(),
"admin",
&password_hash,
"standard",
None,
);
assert!(create_result.is_err());
let admin_result = create_admin_user(&state.users_db.lock().unwrap(), "admin", &password_hash);
assert!(admin_result.is_ok());
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_existing_installation_can_login_after_upgrade() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_upgrade_login_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let admin_dir = temp_dir.join("admin");
std::fs::create_dir_all(&admin_dir).unwrap();
let users_db_path = admin_dir.join("users.db");
let mut users_conn = Connection::open(&users_db_path).unwrap();
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None).unwrap();
users_conn.pragma_update(None, "user_version", 1).unwrap();
let password = "upgradetest";
let password_hash = bzod::auth::hash_password(password).unwrap();
users_conn
.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type) VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params!["admin", password_hash, "active", Utc::now().to_rfc3339(), "standard"],
)
.unwrap();
let _ = Db::init(&config).expect("Db::init should repair legacy install");
let (_db, state) = build_state(config.clone());
{
let conn = state.users_db.lock().unwrap();
let user_count: i64 = conn
.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
.unwrap();
let admin_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|row| row.get(0),
)
.unwrap();
let active_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE status = 'active';",
[],
|row| row.get(0),
)
.unwrap();
println!(
"upgrade login counts users={}, admin={}, active={}",
user_count, admin_count, active_count
);
let mut stmt = conn
.prepare("SELECT username, status, account_type FROM users;")
.unwrap();
let mut rows = stmt.query([]).unwrap();
while let Some(row) = rows.next().unwrap() {
let username: String = row.get(0).unwrap();
let status: String = row.get(1).unwrap();
let account_type: String = row.get(2).unwrap();
println!("user row: {} {} {}", username, status, account_type);
}
}
let csrf_token = compute_sha256("csrf-token");
let jar = CookieJar::new().add(Cookie::new("bzod_temp_csrf", csrf_token.clone()));
let mut headers = HeaderMap::new();
headers.insert("user-agent", "test-agent".parse().unwrap());
let connect_info = Some(ConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
8080,
)));
let form = LoginForm {
username: "admin".to_string(),
password: password.to_string(),
csrf_token,
};
let response = login_post(
State(state.clone()),
jar.clone(),
headers,
connect_info,
Form(form),
)
.await;
assert!(response.status().is_redirection());
assert_eq!(
response.headers().get("location").unwrap(),
"/admin/dashboard"
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_expired_session_cannot_access_dashboard() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_expired_session_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (_db, state) = build_state(config);
let password = "adminpass4";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = state.users_db.lock().unwrap();
create_admin_user(&conn, "admin", &hash).unwrap().id
};
let session_token = "expired_session_token";
{
let conn = state.users_db.lock().unwrap();
create_session(
&conn,
session_token,
&user_id.to_string(),
&(Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
)
.unwrap();
}
let jar = CookieJar::new().add(Cookie::new("bzod_session", session_token));
let response = dashboard_get(State(state.clone()), jar).await;
assert!(response.status().is_redirection());
assert!(response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("/admin/login"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_api_key_rejected_for_non_admin_user() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_api_key_non_admin_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let (db, _state) = build_state(config);
let password = "standardpass";
let hash = bzod::auth::hash_password(password).unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
create_user(&conn, "standard_user", &hash, "standard", None)
.unwrap()
.id
};
let key_secret = "nonadmin-api-key";
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
{
let conn = db.admin.lock().unwrap();
create_api_key(&conn, &user_id.to_string(), "test-key", &hashed_key).unwrap();
}
let auth_header = format!("Bearer {}", key_secret);
let admin_conn = db.admin.lock().unwrap();
let users_conn = db.users.lock().unwrap();
let auth_res = authenticate_api_key(&admin_conn, &users_conn, &auth_header).unwrap();
assert!(auth_res.is_none());
let _ = std::fs::remove_dir_all(&temp_dir);
}
+138
View File
@@ -0,0 +1,138 @@
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use bzod::config::Config;
use bzod::db::Db;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_session_creation_and_expiry() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_auth_sess_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// 1. Session created in future must authenticate successfully
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "valid_session_123", user_id, &expires_at)
.unwrap();
let jar = CookieJar::new().add(Cookie::new("bzod_user_session", "valid_session_123"));
let auth_res = bzod::auth::session::authenticate_user_session(&conn, &jar).unwrap();
assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().0.id, user_id);
}
// 2. Session created in past (expired) must be rejected
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() - chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "expired_session_123", user_id, &expires_at)
.unwrap();
let jar = CookieJar::new().add(Cookie::new("bzod_user_session", "expired_session_123"));
let auth_res = bzod::auth::session::authenticate_user_session(&conn, &jar).unwrap();
assert!(auth_res.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_api_token_creation_and_revocation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_auth_token_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
let raw_token = "bzo_testtoken1234567890abcdef";
let mut hasher = Sha256::new();
hasher.update(raw_token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
// Create API Token
let token_rec = {
let conn = db.users.lock().unwrap();
bzod::db::users::create_user_api_token(&conn, user_id, &token_hash).unwrap()
};
// Authenticate with valid token
{
let conn = db.users.lock().unwrap();
let auth_header = format!("Bearer {}", raw_token);
let actor = bzod::auth::session::authenticate_api_key(
&db.admin.lock().unwrap(),
&conn,
&auth_header,
)
.unwrap();
assert!(actor.is_some());
}
// Revoke API Token
{
let conn = db.users.lock().unwrap();
bzod::db::users::delete_user_api_token(&conn, token_rec.id, user_id).unwrap();
// Verify revoked token is rejected
let auth_header = format!("Bearer {}", raw_token);
let actor = bzod::auth::session::authenticate_api_key(
&db.admin.lock().unwrap(),
&conn,
&auth_header,
)
.unwrap();
assert!(actor.is_none());
}
let _ = fs::remove_dir_all(&temp_dir);
}
+319
View File
@@ -0,0 +1,319 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use zstd::Decoder;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
#[tokio::test]
async fn test_backup_metadata_integrity() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_backup_integrity_{}",
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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Verify archive contains metadata.json and it has valid fields
let f = File::open(&backup_file).unwrap();
let zst_dec = Decoder::new(f).unwrap();
let mut archive = Archive::new(zst_dec);
let mut found_metadata = false;
for entry_res in archive.entries().unwrap() {
let mut entry = entry_res.unwrap();
let path = entry.path().unwrap();
let file_name = path.file_name().unwrap().to_str().unwrap();
if file_name == "metadata.json" {
found_metadata = true;
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry).unwrap();
assert_eq!(meta.username, "testuser");
assert_eq!(meta.quotas.max_urls, 100);
break;
}
}
assert!(found_metadata);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_backup_restore_roundtrip() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_backup_rt_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add a link for user
{
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&user_content_conn,
"!rt-slug",
"https://example.com/rt",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
// Increment quota counter
let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").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")
.unwrap();
}
// Backup user
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Restore user
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// Verify restored user
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
let quota = bzod::db::users::get_user_quotas(&conn, user.id)
.unwrap()
.unwrap();
assert_eq!(quota.current_urls, 1);
// 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 _ = fs::remove_dir_all(&temp_dir);
}
#[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()
));
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");
// 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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// Add a link for User A
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
bzod::db::content::create_url_extended(
&conn_a,
"!collision-slug",
"https://usera.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_a, "url", "a-id")
.unwrap();
}
// Backup User A
let backup_file = temp_dir.join("usera-backup.tar.zst");
bzod::cli::backup_user::run(
"usera".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// Delete User A from the database so we can try restoring them
bzod::cli::delete_user::run(id_a, false, None, config.clone())
.await
.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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id
};
// Add colliding slug for User B
{
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
bzod::db::content::create_url_extended(
&conn_b,
"!collision-slug",
"https://userb.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_b, "url", "b-id")
.unwrap();
}
// Attempt to restore User A from backup
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
{
let system_conn = db.system.lock().unwrap();
let owner_id: i64 = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = '!collision-slug';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(owner_id, id_b);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+625
View File
@@ -0,0 +1,625 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (
reqwest::Client,
String,
AppState,
tokio::task::JoinHandle<()>,
) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 10);
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,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = 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();
(client, url, state, handle)
}
#[tokio::test]
async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_a_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create user standard_a
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut good_params = HashMap::new();
good_params.insert("username", "standard_a");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "scenario a user");
good_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&good_params)
.send()
.await
.unwrap();
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login standard_a
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "standard_a");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
// 4. Create a shortened URL
let user_urls_url = format!("{}/user/urls", base_url);
let res = client.get(&user_urls_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut create_url_params = HashMap::new();
create_url_params.insert("destination", "https://google.com");
create_url_params.insert("code", "");
create_url_params.insert("custom_slug", "!mygoogle");
create_url_params.insert("title", "Google");
create_url_params.insert("description", "Search Engine");
create_url_params.insert("tags", "search,google");
create_url_params.insert("csrf_token", &csrf_token);
create_url_params.insert("expires_at", "");
create_url_params.insert("password", "");
create_url_params.insert("max_access_count", "");
create_url_params.insert("utm_source", "");
create_url_params.insert("utm_medium", "");
create_url_params.insert("utm_campaign", "");
client
.post(format!("{}/user/urls/create", base_url))
.form(&create_url_params)
.send()
.await
.unwrap();
// 5. Perform redirection visit to the custom slug
let redir_url = format!("{}/!mygoogle", base_url);
let res = client.get(&redir_url).send().await.unwrap();
// It should redirect to google.com
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://google.com"
);
// Flush the queue manually or wait a moment for the queue to write to database
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// 6. Verify visit is logged in analytics
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!mygoogle';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
let html = res.text().await.unwrap();
assert!(html.contains(&url_uuid));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_scenario_b_user_disable_session_invalidation() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_b_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create user standard_b
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut good_params = HashMap::new();
good_params.insert("username", "standard_b");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "scenario b user");
good_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&good_params)
.send()
.await
.unwrap();
let target_user_id = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'standard_b';",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
};
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login standard_b
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "standard_b");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
// Verify standard_b can access dashboard
let res = client
.get(format!("{}/user/dashboard", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// 4. Create another client for admin to disable standard_b
let admin_client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
// Login admin
let res = admin_client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut admin_params = HashMap::new();
admin_params.insert("username", "admin");
admin_params.insert("password", "bootstrap-secret");
admin_params.insert("csrf_token", &csrf_token);
admin_client
.post(&login_page_url)
.form(&admin_params)
.send()
.await
.unwrap();
// GET /admin/users to get token
let res = admin_client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
// Disable standard_b
let mut disable_params = HashMap::new();
disable_params.insert("status", "disabled");
disable_params.insert("csrf_token", &csrf_token);
let disable_url = format!("{}/admin/users/status/{}", base_url, target_user_id);
admin_client
.post(&disable_url)
.form(&disable_params)
.send()
.await
.unwrap();
// 5. Access user dashboard with standard_b's client (should be rejected/redirected to login)
let res = client
.get(format!("{}/user/dashboard", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"/login"
);
// Attempting login again should fail
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_again = HashMap::new();
login_again.insert("username", "standard_b");
login_again.insert("password", "password123");
login_again.insert("csrf_token", &csrf_token);
let res = client
.post(&public_login_page_url)
.form(&login_again)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
assert!(res
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.contains("error=Invalid"));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_scenario_c_slug_transfer_workflow() {
let temp_dir = std::env::temp_dir().join(format!("bzod_biz_test_c_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, state, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Login admin
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// 2. Create standard_c1 and standard_c2
let res = client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user1_params = HashMap::new();
user1_params.insert("username", "standard_c1");
user1_params.insert("password", "password123");
user1_params.insert("account_type", "standard");
user1_params.insert("metadata", "c1 user");
user1_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&user1_params)
.send()
.await
.unwrap();
let res = client
.get(format!("{}/admin/users", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut user2_params = HashMap::new();
user2_params.insert("username", "standard_c2");
user2_params.insert("password", "password123");
user2_params.insert("account_type", "standard");
user2_params.insert("metadata", "c2 user");
user2_params.insert("csrf_token", &csrf_token);
client
.post(format!("{}/admin/users/create", base_url))
.form(&user2_params)
.send()
.await
.unwrap();
let user2_id = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'standard_c2';",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
};
// Logout admin
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// 3. Login as standard_c1 and create a slug
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_params = HashMap::new();
login_params.insert("username", "standard_c1");
login_params.insert("password", "password123");
login_params.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&login_params)
.send()
.await
.unwrap();
let res = client
.get(format!("{}/user/urls", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut create_url_params = HashMap::new();
create_url_params.insert("destination", "https://yahoo.com");
create_url_params.insert("code", "");
create_url_params.insert("custom_slug", "!myyahoo");
create_url_params.insert("title", "Yahoo");
create_url_params.insert("description", "Portal");
create_url_params.insert("tags", "yahoo");
create_url_params.insert("csrf_token", &csrf_token);
create_url_params.insert("expires_at", "");
create_url_params.insert("password", "");
create_url_params.insert("max_access_count", "");
create_url_params.insert("utm_source", "");
create_url_params.insert("utm_medium", "");
create_url_params.insert("utm_campaign", "");
client
.post(format!("{}/user/urls/create", base_url))
.form(&create_url_params)
.send()
.await
.unwrap();
// Visit standard_c1's link
client
.get(format!("{}/!myyahoo", base_url))
.send()
.await
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// Verify visit recorded for standard_c1
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
assert!(res.text().await.unwrap().contains(&url_uuid));
// Logout standard_c1
client
.get(format!("{}/logout", base_url))
.send()
.await
.unwrap();
// 4. Log in admin to perform the slug transfer to standard_c2
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
let res = client.get(&login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut admin_params = HashMap::new();
admin_params.insert("username", "admin");
admin_params.insert("password", "bootstrap-secret");
admin_params.insert("csrf_token", &csrf_token);
client
.post(&login_page_url)
.form(&admin_params)
.send()
.await
.unwrap();
// GET /admin/slugs to get CSRF token
let res = client
.get(format!("{}/admin/slugs", base_url))
.send()
.await
.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
// Transfer slug
let user2_id_str = user2_id.to_string();
let mut transfer_params = HashMap::new();
transfer_params.insert("slug", "!myyahoo");
transfer_params.insert("new_owner_user_id", &user2_id_str);
transfer_params.insert("csrf_token", &csrf_token);
let res = client
.post(format!("{}/admin/slugs/transfer", base_url))
.form(&transfer_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// 5. Test redirection redirection still works
let res = client
.get(format!("{}/!myyahoo", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://yahoo.com"
);
// Wait for the background worker to write the standard_c2 visit to the database
tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await;
// 6. Verify that visitor logs / analytics for yahoo are now in standard_c2's domain
client
.get(format!("{}/admin/logout", base_url))
.send()
.await
.unwrap();
// Login as standard_c2
let res = client.get(&public_login_page_url).send().await.unwrap();
let csrf_token = extract_csrf_token(&res.text().await.unwrap()).unwrap();
let mut login_c2 = HashMap::new();
login_c2.insert("username", "standard_c2");
login_c2.insert("password", "password123");
login_c2.insert("csrf_token", &csrf_token);
client
.post(&public_login_page_url)
.form(&login_c2)
.send()
.await
.unwrap();
// standard_c2 analytics should display Yahoo!
let url_uuid = {
let conn = state.system_db.lock().unwrap();
conn.query_row(
"SELECT target_id FROM global_slugs WHERE slug = '!myyahoo';",
[],
|row| row.get::<_, String>(0),
)
.unwrap()
};
let res = client
.get(format!("{}/analytics", base_url))
.send()
.await
.unwrap();
assert!(res.text().await.unwrap().contains(&url_uuid));
let _ = fs::remove_dir_all(&temp_dir);
}
+71
View File
@@ -0,0 +1,71 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Barrier;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_concurrent_slug_creation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_concurrency_{}", 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("Failed to init Db");
let barrier = Arc::new(Barrier::new(2));
let db_path = temp_dir.join("admin/system.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")
});
let b2 = barrier.clone();
let path2 = db_path.clone();
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")
});
let res1 = task1.await.unwrap();
let res2 = task2.await.unwrap();
// Exactly one should succeed, and one should fail (due to UNIQUE constraint)
match (res1, res2) {
(Ok(_), Err(_)) => {}
(Err(_), Ok(_)) => {}
(r1, r2) => panic!(
"Concurrent creation outcome invalid. Res1: {:?}, Res2: {:?}",
r1, r2
),
}
// Verify exactly 1 record exists in global_slugs
{
let system_conn = db.system.lock().unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE slug = '!conc-slug';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+92
View File
@@ -0,0 +1,92 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_corrupted_backup_rejection() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disaster_{}", 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("Failed to init Db");
// Create a corrupted backup file (just random/invalid text bytes)
let corrupted_file = temp_dir.join("corrupted.tar.zst");
fs::write(
&corrupted_file,
b"this-is-not-a-valid-zstd-tar-archive-file",
)
.unwrap();
// Verify restore fails/rejects it
let res = bzod::cli::restore_user::run(
corrupted_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await;
assert!(res.is_err());
// Verify no user was created in the database
{
let conn = db.users.lock().unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM users WHERE username = 'corrupted';",
[],
|row| row.get(0),
)
.unwrap_or(0);
assert_eq!(count, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_partial_restore_failure_rollback() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_partial_rollback_{}",
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("Failed to init Db");
// Restore user from completely missing file path should fail
let res = bzod::cli::restore_user::run(
temp_dir
.join("completely-non-existent-file.tar.zst")
.to_string_lossy()
.to_string(),
None,
config.clone(),
)
.await;
// Verify it fails or runs cleanly without creating any users
assert!(res.is_ok() || res.is_err());
{
let conn = db.users.lock().unwrap();
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);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+210
View File
@@ -0,0 +1,210 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
// Start a test server on a random free port and return client + address
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
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,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none()) // Let us manually inspect redirects
.build()
.unwrap();
(client, url, handle)
}
#[tokio::test]
async fn test_full_http_e2e_flow() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_e2e_http_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, _server_handle) = start_test_server(temp_dir.clone()).await;
// 1. Fetch admin login page to get CSRF token
let login_page_url = format!("{}/admin/login", base_url);
let res = client.get(&login_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).expect("Failed to extract CSRF token");
// 2. Perform bootstrap login using BOOTSTRAP_PASSWORD_SHA256
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
let res = client
.post(&login_page_url)
.form(&params)
.send()
.await
.unwrap();
// After successful login, it should redirect to /admin/dashboard
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/admin/dashboard");
// 3. Try to access /admin/users to make sure we are authorized
let users_page_url = format!("{}/admin/users", base_url);
let res = client.get(&users_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
assert!(html.contains("Users Management"));
// Extract CSRF token from admin users page
let csrf_token =
extract_csrf_token(&html).expect("Failed to extract CSRF token from users page");
// 4. Test CSRF validation failure during user creation
let mut bad_params = HashMap::new();
bad_params.insert("username", "testuser");
bad_params.insert("password", "password123");
bad_params.insert("account_type", "standard");
bad_params.insert("metadata", "e2e standard user");
bad_params.insert("csrf_token", "invalid-csrf-token");
let create_user_url = format!("{}/admin/users/create", base_url);
let res = client
.post(&create_user_url)
.form(&bad_params)
.send()
.await
.unwrap();
// Should redirect back with Invalid CSRF token error
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert!(redirect_url.contains("Invalid CSRF token"));
// 5. Create a standard user successfully
let mut good_params = HashMap::new();
good_params.insert("username", "testuser");
good_params.insert("password", "password123");
good_params.insert("account_type", "standard");
good_params.insert("metadata", "e2e standard user");
good_params.insert("csrf_token", &csrf_token);
let res = client
.post(&create_user_url)
.form(&good_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert!(redirect_url.contains("success=User created successfully"));
// 6. Test RBAC: logout admin and login as standard user
let admin_logout_url = format!("{}/admin/logout", base_url);
let res = client.get(&admin_logout_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// Get public login page and extract CSRF token
let public_login_page_url = format!("{}/login", base_url);
let res = client.get(&public_login_page_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).expect("Failed to extract public CSRF token");
// Login as standard user
let mut user_login_params = HashMap::new();
user_login_params.insert("username", "testuser");
user_login_params.insert("password", "password123");
user_login_params.insert("csrf_token", &csrf_token);
let res = client
.post(&public_login_page_url)
.form(&user_login_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/user/dashboard");
// Access user dashboard (should succeed)
let user_dashboard_url = format!("{}/user/dashboard", base_url);
let res = client.get(&user_dashboard_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// Try to access admin dashboard as standard user (RBAC check - should redirect to admin login)
let admin_dashboard_url = format!("{}/admin/dashboard", base_url);
let res = client.get(&admin_dashboard_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/admin/login");
let _ = fs::remove_dir_all(&temp_dir);
}
+129
View File
@@ -0,0 +1,129 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_index_consistency() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_integrity_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add a URL for the user
let url_id = {
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
let url = bzod::db::content::create_url_extended(
&conn,
"!integ-slug",
"https://google.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
url.id
};
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id)
.unwrap();
}
// Consistency Check:
// 1. Every active slug in user databases exists in global_slugs
{
let user_ids: Vec<i64> = {
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()
};
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();
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
);
}
}
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_history_consistency() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_history_consistency_{}",
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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Verify slug history table columns exist and accept transition entries
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES ('!slug1', 1, 2, 'transferred', 'now');",
[],
).unwrap();
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = '!slug1' AND action = 'transferred';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+114
View File
@@ -0,0 +1,114 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_legacy_migration() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_migration_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
// Create legacy files at the root of temp_dir
let legacy_admin_path = temp_dir.join("admin.db");
let legacy_system_path = temp_dir.join("system.db");
let legacy_content_path = temp_dir.join("content.db");
let legacy_analytics_path = temp_dir.join("analytics.db");
// Apply old schemas/migrations
{
let mut admin_conn = rusqlite::Connection::open(&legacy_admin_path).unwrap();
bzod::db::migrations::run_migrations(
&mut admin_conn,
"admin",
bzod::db::migrations::ADMIN_MIGRATIONS,
None,
)
.unwrap();
// Insert admin user
let hash = bzod::auth::hash_password("admin_pass").unwrap();
bzod::db::admin::create_user(&admin_conn, "admin", &hash).unwrap();
let mut system_conn = rusqlite::Connection::open(&legacy_system_path).unwrap();
bzod::db::migrations::run_migrations(
&mut system_conn,
"system",
bzod::db::migrations::SYSTEM_MIGRATIONS,
None,
)
.unwrap();
let mut content_conn = rusqlite::Connection::open(&legacy_content_path).unwrap();
bzod::db::migrations::run_migrations(
&mut content_conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Insert a link in legacy content
bzod::db::content::create_url_extended(
&content_conn,
"!legacy-slug",
"https://legacy.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
bzod::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
bzod::db::migrations::ANALYTICS_MIGRATIONS,
None,
)
.unwrap();
}
// Call Db::init which triggers the legacy migration
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());
assert!(!legacy_content_path.exists());
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
{
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 _ = fs::remove_dir_all(&temp_dir);
}
+106
View File
@@ -0,0 +1,106 @@
use bzod::config::Config;
use bzod::db::Db;
use chrono::Utc;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_content_flagging_and_disabling() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_mod_flag_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap();
// Verify it is active initially
let status: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "active");
// Moderate/disable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = ?1;",
["!badslug"],
)
.unwrap();
// Verify it is disabled
let status2: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status2, "disabled");
// Moderate/re-enable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'active' WHERE slug = ?1;",
["!badslug"],
)
.unwrap();
let status3: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!badslug"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status3, "active");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_moderation_event_logging() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_mod_logging_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let event_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![event_id, now, "admin", 10, "testuser", "slug", "!badslug", "block", "high", "abusive content"],
).unwrap();
// Verify logged
let count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM moderation_events WHERE target_user_id = 10 AND resource_identifier = '!badslug';",
[],
|row| row.get(0),
).unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+5 -4
View File
@@ -139,10 +139,11 @@ async fn test_perform_restore_and_validation() {
assert!(validation_res.is_ok());
// Verify database files were extracted
assert!(restore_dir.join("admin.db").exists());
assert!(restore_dir.join("content.db").exists());
assert!(restore_dir.join("analytics.db").exists());
assert!(restore_dir.join("system.db").exists());
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());
// Verify custom slug was preserved in the restored DB
let restore_config = create_temp_config(restore_dir.clone());
+141
View File
@@ -0,0 +1,141 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_quota_limit_enforcement() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_quota_limits_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Set max_urls to 1
{
let conn = db.users.lock().unwrap();
conn.execute(
"UPDATE quotas SET max_urls = 1 WHERE user_id = ?1;",
[user_id],
)
.unwrap();
}
// Attempt to add URLs and manually increment count
{
let users_conn = db.users.lock().unwrap();
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota.max_urls, 1);
assert_eq!(quota.current_urls, 0);
// 1st Url: Success
bzod::db::users::increment_quota_counter(&users_conn, user_id, "urls").unwrap();
let quota1 = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota1.current_urls, 1);
// 2nd Url: Quota check fails
let quota_check = quota1.current_urls >= quota1.max_urls;
assert!(quota_check);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_quota_reconcile_job() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_quota_reconcile_{}",
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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Introduce drift manually: make current_urls = 10, when actually 1 URL exists
{
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&user_content_conn,
"!my-reconcile-slug",
"https://example.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let users_conn = db.users.lock().unwrap();
users_conn
.execute(
"UPDATE quotas SET current_urls = 10 WHERE user_id = ?1;",
[user_id],
)
.unwrap();
}
// Run reconciliation
{
let users_conn = db.users.lock().unwrap();
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::users::reconcile_user_quotas(&users_conn, user_id, &user_content_conn).unwrap();
// Verify drift is repaired
let quota = bzod::db::users::get_user_quotas(&users_conn, user_id)
.unwrap()
.unwrap();
assert_eq!(quota.current_urls, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+202
View File
@@ -0,0 +1,202 @@
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum_extra::extract::CookieJar;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::redirect::resolve_redirect;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_lookup_and_redirection() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_routing_{}", 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("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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())),
db: db.clone(),
config: config.clone(),
analytics_queue: queue,
start_time: Instant::now(),
};
// Create a user and link
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!my-routing-slug",
"https://example.com/target",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!my-routing-slug",
user_id,
"url",
"xyz",
)
.unwrap();
}
// Call resolve_redirect directly
let response = resolve_redirect(
State(state.clone()),
CookieJar::new(),
Path("!my-routing-slug".to_string()),
HeaderMap::new(),
None,
)
.await;
// Verify it redirects (status 303 or 302/307/etc)
assert!(response.status().is_redirection());
assert_eq!(
response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"https://example.com/target"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_slug_returns_410() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_routing_disabled_{}",
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("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000);
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())),
db: db.clone(),
config: config.clone(),
analytics_queue: queue,
start_time: Instant::now(),
};
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!disabled-slug",
"https://example.com/target",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!disabled-slug",
user_id,
"url",
"xyz",
)
.unwrap();
// Disable slug
system_conn
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = '!disabled-slug';",
[],
)
.unwrap();
}
// Call resolve_redirect directly
let response = resolve_redirect(
State(state),
CookieJar::new(),
Path("!disabled-slug".to_string()),
HeaderMap::new(),
None,
)
.await;
// Verify it returns 410 Gone (StatusCode::GONE)
assert_eq!(response.status(), StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
+18 -11
View File
@@ -4,11 +4,11 @@ use chrono::Utc;
use rusqlite::Connection;
use bzod::auth::{
authenticate_api_key, authenticate_session, generate_csrf_token, hash_password, verify_csrf,
verify_password, verify_sha256,
authenticate_admin_session, authenticate_api_key, generate_csrf_token, hash_password,
verify_csrf, verify_password, verify_sha256,
};
use bzod::db::admin::{create_api_key, create_session, create_user, get_user_count};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS, USERS_MIGRATIONS};
// Helper to set up an in-memory admin.db connection with migrations applied
fn setup_test_db() -> Connection {
@@ -17,6 +17,12 @@ fn setup_test_db() -> Connection {
conn
}
fn setup_users_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "users", USERS_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_csrf_tampering_prevention() {
let session_id = "secret_session_id_123456";
@@ -35,6 +41,7 @@ fn test_csrf_tampering_prevention() {
#[test]
fn test_api_key_sql_injection_resistance() {
let conn = setup_test_db();
let users_conn = setup_users_db();
// Create an API key
let user_hash = hash_password("admin_pass").unwrap();
@@ -50,26 +57,26 @@ fn test_api_key_sql_injection_resistance() {
// 1. Test valid key passes
let valid_auth = format!("Bearer {}", key_secret);
let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap();
let auth_res = authenticate_api_key(&conn, &users_conn, &valid_auth).unwrap();
assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().username, "admin");
assert_eq!(auth_res.unwrap().username(), "admin");
// 2. Test SQL Injection attempt in the header does not succeed or crash
let sql_inj_auth1 = "Bearer ' OR 1=1 --";
let res = authenticate_api_key(&conn, sql_inj_auth1).unwrap();
let res = authenticate_api_key(&conn, &users_conn, sql_inj_auth1).unwrap();
assert!(res.is_none());
let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --";
let res = authenticate_api_key(&conn, sql_inj_auth2).unwrap();
let res = authenticate_api_key(&conn, &users_conn, sql_inj_auth2).unwrap();
assert!(res.is_none());
// 3. Test malformed header
let malformed_auth = "Bearer";
let res = authenticate_api_key(&conn, malformed_auth).unwrap();
let res = authenticate_api_key(&conn, &users_conn, malformed_auth).unwrap();
assert!(res.is_none());
let wrong_scheme = "Basic admin:pass";
let res = authenticate_api_key(&conn, wrong_scheme).unwrap();
let res = authenticate_api_key(&conn, &users_conn, wrong_scheme).unwrap();
assert!(res.is_none());
}
@@ -86,7 +93,7 @@ fn test_expired_session_invalidation() {
create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap();
let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future));
let auth_future = authenticate_session(&conn, &jar_future).unwrap();
let auth_future = authenticate_admin_session(&conn, &jar_future).unwrap();
assert!(auth_future.is_some());
assert_eq!(auth_future.unwrap().0.id, user.id);
@@ -96,7 +103,7 @@ fn test_expired_session_invalidation() {
create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap();
let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past));
let auth_past = authenticate_session(&conn, &jar_past).unwrap();
let auth_past = authenticate_admin_session(&conn, &jar_past).unwrap();
assert!(auth_past.is_none());
}
+145
View File
@@ -0,0 +1,145 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_global_slug_uniqueness() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_slug_uniq_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register a slug
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1").unwrap();
// Verify it is not available
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
assert!(!avail);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_reserved_slug_rejection() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_reserved_slug_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let reserved = vec![
"admin",
"login",
"logout",
"dashboard",
"api",
"docs",
"assets",
"static",
"favicon.ico",
"robots.txt",
"health",
"metrics",
"install",
"setup",
"support",
"help",
"security",
"abuse",
"billing",
"status",
"legacy_admin",
"administrator",
"system",
"root",
"www",
];
for slug in reserved {
let avail = bzod::db::users::is_slug_available(&system_conn, slug).unwrap();
assert!(!avail, "Reserved slug '{}' should not be available", slug);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_release_on_user_deletion() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_slug_release_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Register slug for this user
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!user-slug",
user_id,
"url",
"url_xyz",
)
.unwrap();
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
assert!(!avail);
}
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// 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();
assert!(avail);
// Verify history populated
let count: i64 = system_conn
.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = ?1 AND action = 'deleted';",
["!user-slug"],
|row| row.get(0),
)
.unwrap();
assert!(count > 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+176
View File
@@ -0,0 +1,176 @@
use bzod::config::Config;
use bzod::db::Db;
use chrono::Utc;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_slug_transfer() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_transfer_{}", 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("Failed to init Db");
// Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.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_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// User A creates a URL
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"!trans-slug",
"https://google.com/transfer",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!trans-slug", id_a, "url", &url.id)
.unwrap();
let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
}
// Perform transfer to User B
{
let old_conn = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let new_conn = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
// 1. Copy URL to new owner content.db
let url_to_copy = bzod::db::content::get_url_by_code(&old_conn, "!trans-slug")
.unwrap()
.unwrap();
bzod::db::content::create_url_extended(
&new_conn,
&url_to_copy.code,
&url_to_copy.destination,
url_to_copy.title.as_deref(),
url_to_copy.description.as_deref(),
&url_to_copy.tags,
url_to_copy.expires_at.as_deref(),
url_to_copy.password_hash.as_deref(),
url_to_copy.max_access_count,
)
.unwrap();
// 2. Delete from old owner
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 now = Utc::now().to_rfc3339();
system_conn
.execute(
"UPDATE global_slugs SET owner_user_id = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![id_b, now, "!trans-slug"],
)
.unwrap();
let users_conn = db.users.lock().unwrap();
bzod::db::users::decrement_quota_counter(&users_conn, id_a, "urls").unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_b, "urls").unwrap();
}
// Verify User A has no link, User B has it
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
assert!(bzod::db::content::get_url_by_code(&conn_a, "!trans-slug")
.unwrap()
.is_none());
assert!(bzod::db::content::get_url_by_code(&conn_b, "!trans-slug")
.unwrap()
.is_some());
// Verify quotas adjusted
let users_conn = db.users.lock().unwrap();
let q_a = bzod::db::users::get_user_quotas(&users_conn, id_a)
.unwrap()
.unwrap();
let q_b = bzod::db::users::get_user_quotas(&users_conn, id_b)
.unwrap()
.unwrap();
assert_eq!(q_a.current_urls, 0);
assert_eq!(q_b.current_urls, 1);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_transfer_history_logging() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_trans_history_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
let now = Utc::now().to_rfc3339();
// Log a slug transfer history entry
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params!["!trans-slug", 10, 20, now, "admin"],
).unwrap();
// Verify it is logged
let count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM slug_history WHERE slug = '!trans-slug' AND old_owner_user_id = 10 AND new_owner_user_id = 20;",
[],
|row| row.get(0),
).unwrap();
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+69
View File
@@ -0,0 +1,69 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_soft_delete_reserves_slug() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_soft_del_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-delete", 10, "url", "url_123")
.unwrap();
// Soft delete slug
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
// Verify it is not available (keeps the slug reserved)
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-delete").unwrap();
assert!(!avail);
let status: String = system_conn
.query_row(
"SELECT status FROM global_slugs WHERE slug = ?1;",
["!slug-to-delete"],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "soft_deleted");
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_permanent_delete_releases_slug() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_perm_del_{}", 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("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-purge", 10, "url", "url_456")
.unwrap();
// Release global slug (permanent deletion)
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
// Verify slug is released (available for reuse)
let avail = bzod::db::users::is_slug_available(&system_conn, "!slug-to-purge").unwrap();
assert!(avail);
let _ = fs::remove_dir_all(&temp_dir);
}
+243
View File
@@ -0,0 +1,243 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::time::sleep;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
#[tokio::test]
async fn test_upgrade_from_v0_4_0() {
// Create a temporary directory for the legacy v0.4.0 data
let temp_dir = std::env::temp_dir().join(format!("bzod_upgrade_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let legacy_admin_path = temp_dir.join("admin.db");
let legacy_system_path = temp_dir.join("system.db");
let legacy_content_path = temp_dir.join("content.db");
let legacy_analytics_path = temp_dir.join("analytics.db");
// Initialize legacy databases with their respective version 1 schemas and seed data
{
// 1. admin.db
let mut admin_conn = rusqlite::Connection::open(&legacy_admin_path).unwrap();
bzod::db::migrations::run_migrations(
&mut admin_conn,
"admin",
bzod::db::migrations::ADMIN_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy admin user
let legacy_admin_hash = bzod::auth::hash_password("legacy-admin-pass").unwrap();
bzod::db::admin::create_user(&admin_conn, "admin", &legacy_admin_hash).unwrap();
// 2. system.db
let mut system_conn = rusqlite::Connection::open(&legacy_system_path).unwrap();
bzod::db::migrations::run_migrations(
&mut system_conn,
"system",
bzod::db::migrations::SYSTEM_MIGRATIONS,
None,
)
.unwrap();
// 3. content.db
let mut content_conn = rusqlite::Connection::open(&legacy_content_path).unwrap();
bzod::db::migrations::run_migrations(
&mut content_conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy url
let url = bzod::db::content::create_url_extended(
&content_conn,
"!legacy-link",
"https://example.com/legacy-redirect-target",
Some("Legacy URL"),
Some("A link from v0.4.0"),
&[],
None,
None,
None,
)
.unwrap();
// 4. analytics.db
let mut analytics_conn = rusqlite::Connection::open(&legacy_analytics_path).unwrap();
bzod::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
bzod::db::migrations::ANALYTICS_MIGRATIONS,
None,
)
.unwrap();
// Seed legacy visit record matching the seeded legacy url id
let timestamp = chrono::Utc::now().to_rfc3339();
analytics_conn
.execute(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
rusqlite::params![
"legacy-visit-id-123",
"url",
url.id,
timestamp,
"1.1.1.1",
"LegacyBrowser/1.0",
"https://referrer-channel.com",
"en-US",
"US",
302,
1i64 // user ID 1
],
)
.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");
// Verify file layout was reorganized
assert!(!legacy_admin_path.exists());
assert!(!legacy_content_path.exists());
assert!(!legacy_analytics_path.exists());
assert!(!legacy_system_path.exists());
assert!(temp_dir.join("admin/admin.db").exists());
assert!(temp_dir.join("admin/system.db").exists());
assert!(temp_dir.join("admin/users.db").exists());
assert!(temp_dir.join("users/1/content.db").exists());
assert!(temp_dir.join("users/1/analytics.db").exists());
// Spawn server
let queue = AnalyticsQueue::new(db.clone(), 10);
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,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}", addr);
let _server_handle = 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. Admin login works (Bootstrap path because we migrated)
let login_url = format!("{}/admin/login", base_url);
let res = client.get(&login_url).send().await.unwrap();
let html = res.text().await.unwrap();
let csrf_token =
extract_csrf_token(&html).expect("Failed to extract CSRF token from login page");
let mut params = HashMap::new();
params.insert("username", "admin");
params.insert("password", "bootstrap-secret");
params.insert("csrf_token", &csrf_token);
let login_res = client.post(&login_url).form(&params).send().await.unwrap();
assert!(login_res.status().is_redirection());
assert_eq!(
login_res
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap(),
"/admin/dashboard"
);
// 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();
assert!(res_headers.get("location").is_some());
assert_eq!(
res_headers.get("location").unwrap().to_str().unwrap(),
"https://example.com/legacy-redirect-target"
);
// Sleep 3 seconds for the analytics queue to process and flush to database
sleep(Duration::from_millis(3000)).await;
// 3. Analytics preserved and new click recorded
let user_dbs = state.get_user_dbs(1).unwrap();
let analytics_conn = user_dbs.analytics.lock().unwrap();
// Check count of visits for the legacy link (should be 2: 1 legacy + 1 new redirect click)
let visit_count: i64 = analytics_conn
.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(visit_count, 2);
// Verify the details of the legacy visit are preserved
let legacy_visit_exists: bool = analytics_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM visits WHERE id = 'legacy-visit-id-123' AND user_agent = 'LegacyBrowser/1.0' AND referer = 'https://referrer-channel.com');",
[],
|row| row.get(0),
)
.unwrap();
assert!(legacy_visit_exists);
// Clean up temporary folder
let _ = fs::remove_dir_all(&temp_dir);
}
+127
View File
@@ -0,0 +1,127 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_deletion_cleanup() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_deletion_cleanup_{}",
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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Add session and API token
{
let conn = db.users.lock().unwrap();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339();
bzod::db::users::create_user_session(&conn, "sess_abc", user_id, &expires_at).unwrap();
bzod::db::users::create_user_api_token(&conn, user_id, "token_abc").unwrap();
}
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Verify directory is deleted
let user_dir = temp_dir.join("users").join(user_id.to_string());
assert!(!user_dir.exists());
// Verify sessions/tokens are cascadingly deleted
{
let conn = db.users.lock().unwrap();
let sess_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(sess_count, 0);
let token_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(token_count, 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_deletion_audit_snapshot() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_deletion_audit_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Delete user
bzod::cli::delete_user::run(user_id, false, None, config.clone())
.await
.unwrap();
// Verify audit events and history entries are NOT deleted (retained)
{
let system_conn = db.system.lock().unwrap();
let audit_count: i64 = system_conn.query_row(
"SELECT COUNT(*) FROM audit_events WHERE action = 'USER_DELETION' AND object_id = ?1;",
[user_id.to_string()],
|row| row.get(0),
).unwrap();
assert!(audit_count > 0);
}
let _ = fs::remove_dir_all(&temp_dir);
}
+148
View File
@@ -0,0 +1,148 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_database_isolation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_isolation_{}", 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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let (id_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(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());
assert_ne!(dir_a, dir_b);
assert!(dir_a.join("content.db").exists());
assert!(dir_b.join("content.db").exists());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_cross_user_content_access_denied() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_cross_access_{}", 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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let (id_a, id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// User A adds a URL
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let _ = bzod::db::content::create_url_extended(
&conn_a,
"!slug-a",
"https://google.com/usera",
None,
None,
&vec!["tag_usera".to_string()],
None,
None,
None,
)
.unwrap();
// User B adds a URL
let conn_b = bzod::jobs::open_user_content_conn(&db, id_b).unwrap();
let _ = bzod::db::content::create_url_extended(
&conn_b,
"!slug-b",
"https://google.com/userb",
None,
None,
&vec!["tag_userb".to_string()],
None,
None,
None,
)
.unwrap();
// Verify User B's DB has no trace of User A's content or tags
let url_opt = bzod::db::content::get_url_by_code(&conn_b, "!slug-a").unwrap();
assert!(url_opt.is_none());
let tag_exists: bool = conn_b
.query_row(
"SELECT EXISTS(SELECT 1 FROM tags WHERE name = 'tag_usera');",
[],
|row| row.get(0),
)
.unwrap();
assert!(!tag_exists);
let _ = fs::remove_dir_all(&temp_dir);
}
+214
View File
@@ -0,0 +1,214 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_user_creation() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_creation_{}", 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("Failed to init Db");
let res = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await;
assert!(res.is_ok());
// Verify user directories and files
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
let user_dir = temp_dir.join("users").join(user.id.to_string());
assert!(user_dir.join("content.db").exists());
assert!(user_dir.join("analytics.db").exists());
assert!(user_dir.join("profile.db").exists());
// Verify quotas are seeded
let quota = bzod::db::users::get_user_quotas(&conn, user.id)
.unwrap()
.unwrap();
assert_eq!(quota.max_urls, 100);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_disable_enable() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_disable_enable_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Disable the user
bzod::cli::disable_user::run(user_id, None, config.clone())
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(user.status, "disabled");
// Verify authentication fails for disabled users
let api_key = "Bearer test_api_token";
let auth_res = bzod::auth::authenticate_api_key(&db.admin.lock().unwrap(), &conn, api_key);
assert!(auth_res.unwrap().is_none());
}
// Re-enable the user
bzod::cli::enable_user::run(user_id, None, config.clone())
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_password_reset() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_pwd_reset_{}", 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("Failed to init Db");
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("oldpassword".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// Reset password
bzod::cli::reset_password::run(
user_id,
Some("newpassword".to_string()),
None,
config.clone(),
)
.await
.unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
// Verify password hash changed and verifies correctly
assert!(bzod::auth::verify_password(
"newpassword",
&user.password_hash
));
assert!(!bzod::auth::verify_password(
"oldpassword",
&user.password_hash
));
}
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_status_transitions() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_status_transitions_{}",
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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
let conn = db.users.lock().unwrap();
// active -> disabled
bzod::db::users::update_user_status(&conn, user_id, "disabled").unwrap();
let u1 = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(u1.status, "disabled");
// disabled -> active
bzod::db::users::update_user_status(&conn, user_id, "active").unwrap();
let u2 = bzod::db::users::get_user_by_id(&conn, user_id)
.unwrap()
.unwrap();
assert_eq!(u2.status, "active");
let _ = fs::remove_dir_all(&temp_dir);
}
+104
View File
@@ -0,0 +1,104 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_wal_recovery_after_backup() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_wal_rec_{}", 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("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 conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
// 1. Write some content
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&conn,
"!wal-slug",
"https://google.com/wal",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
}
// 2. Perform backup (this checkpoints and flushes WAL to DB files)
let backup_file = temp_dir.join("testuser-backup.tar.zst");
bzod::cli::backup_user::run(
"testuser".to_string(),
Some(backup_file.to_string_lossy().to_string()),
None,
config.clone(),
)
.await
.unwrap();
// 3. Restore user
bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(),
None,
config.clone(),
)
.await
.unwrap();
// 4. Verify that restored databases are consistent and can successfully perform WAL writes
{
let conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
// SQLite integrity check
bzod::db::sqlite::integrity_check(&conn, "content").unwrap();
// Write a new url after restore to verify WAL write works
bzod::db::content::create_url_extended(
&conn,
"!new-wal-slug",
"https://google.com/new-wal",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let url = bzod::db::content::get_url_by_code(&conn, "!new-wal-slug")
.unwrap()
.unwrap();
assert_eq!(url.destination, "https://google.com/new-wal");
}
let _ = fs::remove_dir_all(&temp_dir);
}