Add QR codes, link expiry, password protection, previews, audit trail and bulk operations

This commit is contained in:
thakares committed 2026-06-12 19:57:53 +05:30
1 parent 157aa81252
commit bcbcc90d98
88 files changed
+5354 -815

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
use bzod::db::audit_events::{list_audit_events, write_audit_event};
use bzod::db::content::{
create_url_extended, expire_urls, get_url_by_id, increment_access_count, remove_url_password,
set_url_password,
};
use bzod::db::migrations::{run_migrations, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS};
use bzod::db::preview::{delete_preview, get_preview, upsert_preview};
use chrono::Utc;
use rusqlite::Connection;
fn setup_content_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "content", CONTENT_MIGRATIONS, None).unwrap();
conn
}
fn setup_system_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "system", SYSTEM_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_expiring_links() {
let conn = setup_content_db();
// Create an expired URL
let past = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let url_expired = create_url_extended(
&conn,
"exp001",
"https://expired.com",
Some("Expired Link"),
None,
&[],
Some(&past),
None,
None,
)
.unwrap();
// Create a future URL (not expired)
let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let url_active = create_url_extended(
&conn,
"act001",
"https://active.com",
Some("Active Link"),
None,
&[],
Some(&future),
None,
None,
)
.unwrap();
// Verify initial state
assert!(!url_expired.expired);
assert!(!url_active.expired);
// Run expiration logic
let expired_count = expire_urls(&conn).unwrap();
assert_eq!(expired_count, 1);
// Verify after expiration
let url_expired_after = get_url_by_id(&conn, &url_expired.id).unwrap().unwrap();
let url_active_after = get_url_by_id(&conn, &url_active.id).unwrap().unwrap();
assert!(url_expired_after.expired);
assert!(!url_active_after.expired);
}
#[test]
fn test_password_protected_links() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"pwd001",
"https://protected.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
assert!(!url.is_password_protected());
// Set password hash
let hash = "fake_argon_hash";
let set_ok = set_url_password(&conn, &url.id, hash).unwrap();
assert!(set_ok);
let url_updated = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(url_updated.is_password_protected());
assert_eq!(url_updated.password_hash.as_deref(), Some(hash));
// Remove password
let remove_ok = remove_url_password(&conn, &url.id).unwrap();
assert!(remove_ok);
let url_removed = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(!url_removed.is_password_protected());
}
#[test]
fn test_one_time_links() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"one001",
"https://onetime.com",
None,
None,
&[],
None,
None,
Some(2), // Max access count = 2
)
.unwrap();
assert!(!url.is_access_exhausted());
// 1st click
let clicks = increment_access_count(&conn, &url.id).unwrap();
assert_eq!(clicks, 1);
let url_refetched = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(!url_refetched.is_access_exhausted());
// 2nd click
let clicks2 = increment_access_count(&conn, &url.id).unwrap();
assert_eq!(clicks2, 2);
let url_refetched2 = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(url_refetched2.is_access_exhausted());
}
#[test]
fn test_link_previews() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"prv001",
"https://previewed.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
// Initial check: no preview
let prev_init = get_preview(&conn, &url.id).unwrap();
assert!(prev_init.is_none());
// Create preview
let prev = upsert_preview(
&conn,
&url.id,
Some("Sample Page"),
Some("Sample Description"),
Some("https://logo.png"),
Some("Proceed"),
)
.unwrap();
assert_eq!(prev.title.as_deref(), Some("Sample Page"));
assert_eq!(prev.button_text, "Proceed");
// Get preview
let prev_get = get_preview(&conn, &url.id).unwrap().unwrap();
assert_eq!(prev_get.description.as_deref(), Some("Sample Description"));
// Update preview
let prev_updated =
upsert_preview(&conn, &url.id, Some("Updated Page"), None, None, None).unwrap();
assert_eq!(prev_updated.title.as_deref(), Some("Updated Page"));
assert_eq!(prev_updated.button_text, "Continue"); // defaults
// Delete preview
let del_ok = delete_preview(&conn, &url.id).unwrap();
assert!(del_ok);
let prev_final = get_preview(&conn, &url.id).unwrap();
assert!(prev_final.is_none());
}
#[test]
fn test_system_audit_events() {
let conn = setup_system_db();
// Initial check
let events_init = list_audit_events(&conn, 100, 0, None, None).unwrap();
assert!(events_init.is_empty());
// Write events
write_audit_event(
&conn,
"admin",
"URL_CREATION",
"url",
"url-uuid-1",
Some("metadata-1"),
)
.unwrap();
write_audit_event(&conn, "api-user", "URL_UPDATE", "url", "url-uuid-2", None).unwrap();
// List events
let events = list_audit_events(&conn, 100, 0, None, None).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].actor, "api-user"); // ordered desc by timestamp
assert_eq!(events[1].actor, "admin");
// Filter by actor
let events_filtered = list_audit_events(&conn, 100, 0, Some("admin"), None).unwrap();
assert_eq!(events_filtered.len(), 1);
assert_eq!(events_filtered[0].action, "URL_CREATION");
}
+76
View File
@@ -0,0 +1,76 @@
use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS};
use bzod::db::qr::{
get_qr_code_style, get_qr_scan_count, get_qr_stats_for_url, log_qr_access, upsert_qr_code,
};
use rusqlite::Connection;
#[test]
fn test_qr_service_png_and_svg() {
let url = "https://example.com/some/path";
let png = bzod::services::qr::generate_qr_png(url, 256).unwrap();
assert!(!png.is_empty());
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']); // PNG magic bytes
let svg = bzod::services::qr::generate_qr_svg(url).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_qr_access_logging() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
let url_id = "test-url-uuid-123";
log_qr_access(&conn, url_id, Some("127.0.0.1"), Some("TestBrowser/1.0")).unwrap();
log_qr_access(&conn, url_id, None, None).unwrap();
let count = get_qr_scan_count(&conn, url_id).unwrap();
assert_eq!(count, 2);
let stats = get_qr_stats_for_url(&conn, url_id).unwrap();
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].1, "");
assert_eq!(stats[1].1, "127.0.0.1");
}
#[test]
fn test_qr_code_styling() {
let mut conn = Connection::open_in_memory().unwrap();
// Run content migrations because qr_codes table is in content.db
run_migrations(
&mut conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Create a dummy URL first to avoid FOREIGN KEY failure
let url = bzod::db::content::create_url_extended(
&conn,
"qrstyle",
"https://example.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
// Default style when not configured
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "default");
// Upsert a style
upsert_qr_code(&conn, &url.id, "fancy-blue").unwrap();
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "fancy-blue");
// Update the style
upsert_qr_code(&conn, &url.id, "sleek-dark").unwrap();
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "sleek-dark");
}
+45 -36
View File
@@ -1,15 +1,13 @@
use rusqlite::Connection;
use chrono::Utc;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rusqlite::Connection;
use bzod::auth::{
verify_csrf, generate_csrf_token, authenticate_session, authenticate_api_key,
hash_password, verify_sha256, verify_password
};
use bzod::db::admin::{
create_user, create_session, get_user_count, create_api_key
authenticate_api_key, authenticate_session, 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};
// Helper to set up an in-memory admin.db connection with migrations applied
@@ -23,13 +21,13 @@ fn setup_test_db() -> Connection {
fn test_csrf_tampering_prevention() {
let session_id = "secret_session_id_123456";
let valid_token = generate_csrf_token(session_id);
// Mismatched token must fail
assert!(!verify_csrf(session_id, "different_token_value"));
// Valid token must pass
assert!(verify_csrf(session_id, &valid_token));
// Mismatched session id must fail even if token matches the original session id
assert!(!verify_csrf("different_session_id_789", &valid_token));
}
@@ -37,39 +35,39 @@ fn test_csrf_tampering_prevention() {
#[test]
fn test_api_key_sql_injection_resistance() {
let conn = setup_test_db();
// Create an API key
let user_hash = hash_password("admin_pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// Generate valid API key
let key_secret = "bzo_validkey1234567890abcdef";
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
create_api_key(&conn, &user.id, "my-key", &hashed_key).unwrap();
// 1. Test valid key passes
let valid_auth = format!("Bearer {}", key_secret);
let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap();
assert!(auth_res.is_some());
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();
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();
assert!(res.is_none());
// 3. Test malformed header
let malformed_auth = "Bearer";
let res = authenticate_api_key(&conn, malformed_auth).unwrap();
assert!(res.is_none());
let wrong_scheme = "Basic admin:pass";
let res = authenticate_api_key(&conn, wrong_scheme).unwrap();
assert!(res.is_none());
@@ -78,25 +76,25 @@ fn test_api_key_sql_injection_resistance() {
#[test]
fn test_expired_session_invalidation() {
let conn = setup_test_db();
let user_hash = hash_password("pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// 1. Session in the future must be valid
let future_expiry = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let session_id_future = "future_session_token";
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();
assert!(auth_future.is_some());
assert_eq!(auth_future.unwrap().0.id, user.id);
// 2. Session in the past must be rejected
let past_expiry = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let session_id_past = "expired_session_token";
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();
assert!(auth_past.is_none());
@@ -105,26 +103,29 @@ fn test_expired_session_invalidation() {
#[test]
fn test_bootstrap_credentials_deactivation() {
let conn = setup_test_db();
let bootstrap_sha = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; // SHA-256 of "admin"
// 1. Initially, no users exist in database
assert_eq!(get_user_count(&conn).unwrap(), 0);
// Bootstrap validation is allowed
assert!(verify_sha256("admin", bootstrap_sha));
// 2. Provision a user in database (either via bootstrap login or CLI)
let user_hash = hash_password("new_secure_admin_password").unwrap();
create_user(&conn, "admin", &user_hash).unwrap();
// Check that database now has users
assert_eq!(get_user_count(&conn).unwrap(), 1);
// Standard credential validation must pass
let user_opt = bzod::db::admin::get_user_by_username(&conn, "admin").unwrap();
assert!(user_opt.is_some());
assert!(verify_password("new_secure_admin_password", &user_opt.unwrap().password_hash));
assert!(verify_password(
"new_secure_admin_password",
&user_opt.unwrap().password_hash
));
// The bootstrap credentials MUST be ignored now (the application logic checks users count,
// which is 1, so it bypasses the bootstrap check and verifies ONLY against the database).
}
@@ -135,16 +136,24 @@ fn test_path_traversal_rejection() {
// If a request has /../admin, standard HTTP parsers and Axum router resolve it as /admin
// (which checks session cookies) or return 404 for unresolved paths.
// Here we verify that code inputs containing traversal strings are parsed as invalid codes.
let invalid_codes = vec!["../foo", "..%2ff", "/admin", "a/b/c", "1234567"];
for code in invalid_codes {
// Validate redirect code must be exactly 6 hex digits
let is_valid_redirect_code = code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_redirect_code, "Code '{}' should be rejected as a valid redirect shortcode", code);
assert!(
!is_valid_redirect_code,
"Code '{}' should be rejected as a valid redirect shortcode",
code
);
// Validate landing page code must be exactly 4 hex digits
let is_valid_page_code = code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_page_code, "Code '{}' should be rejected as a valid landing page shortcode", code);
assert!(
!is_valid_page_code,
"Code '{}' should be rejected as a valid landing page shortcode",
code
);
}
}