BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation
This commit is contained in:
1 parent
743502b183
commit
7dfb8c0f1b
100 files changed
+15588
-309
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms)
|
||||
.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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms).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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user