Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
@@ -0,0 +1,287 @@
|
||||
use nx9_auth::cli::{Commands, run};
|
||||
use nx9_auth::config::Config;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn setup_test_db(db_path: &str) {
|
||||
let _ = fs::remove_file(db_path);
|
||||
}
|
||||
|
||||
fn teardown_test_db(db_path: &str) {
|
||||
let _ = fs::remove_file(db_path);
|
||||
let _ = fs::remove_file(format!("{}-wal", db_path));
|
||||
let _ = fs::remove_file(format!("{}-shm", db_path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_expansion() {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
|
||||
|
||||
let mut config = Config::default();
|
||||
config.database.path = "~/test_subdir/test.db".to_string();
|
||||
config.resolve_paths();
|
||||
|
||||
let expected = Path::new(&home).join("test_subdir/test.db");
|
||||
assert_eq!(config.database.path, expected.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backup_validation_and_integrity() {
|
||||
let db_path = "test_cli_backup.db";
|
||||
setup_test_db(db_path);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.database.path = db_path.to_string();
|
||||
|
||||
// 1. Initialize DB and run migrations
|
||||
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||
nx9_auth::db::run_migrations(&pool).await.unwrap();
|
||||
|
||||
// 2. Validate backup safety rejects active DB
|
||||
let res = run(
|
||||
Commands::Backup {
|
||||
path: PathBuf::from(db_path),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
assert!(
|
||||
res.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backup destination cannot be the active database file")
|
||||
);
|
||||
|
||||
// Reject WAL file
|
||||
let wal_path = format!("{}-wal", db_path);
|
||||
let res = run(
|
||||
Commands::Backup {
|
||||
path: PathBuf::from(&wal_path),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
assert!(
|
||||
res.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backup destination cannot be the active WAL file")
|
||||
);
|
||||
|
||||
// Reject SHM file
|
||||
let shm_path = format!("{}-shm", db_path);
|
||||
let res = run(
|
||||
Commands::Backup {
|
||||
path: PathBuf::from(&shm_path),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
assert!(
|
||||
res.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backup destination cannot be the active SHM file")
|
||||
);
|
||||
|
||||
// 3. Test successful backup
|
||||
let backup_path = "test_cli_backup_dest.db";
|
||||
let _ = fs::remove_file(backup_path);
|
||||
|
||||
let res = run(
|
||||
Commands::Backup {
|
||||
path: PathBuf::from(backup_path),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_ok());
|
||||
assert!(Path::new(backup_path).exists());
|
||||
|
||||
// 4. Verify integrity of the backup database
|
||||
let backup_pool = nx9_auth::db::create_pool(backup_path).await.unwrap();
|
||||
let integrity: (String,) = sqlx::query_as("PRAGMA integrity_check")
|
||||
.fetch_one(&backup_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(integrity.0, "ok");
|
||||
|
||||
// Clean up
|
||||
teardown_test_db(db_path);
|
||||
teardown_test_db(backup_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_config_path_json() {
|
||||
let mut config = Config::default();
|
||||
config.database.path = "test.db".to_string();
|
||||
|
||||
let res = run(Commands::ConfigPath { json: true }, config.clone()).await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
let res = run(Commands::ConfigPath { json: false }, config).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_init_non_interactive() {
|
||||
let db_path = "test_cli_init.db";
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_file(db_path);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.database.path = db_path.to_string();
|
||||
|
||||
// Run init command in non-interactive mode
|
||||
let res = run(
|
||||
Commands::Init {
|
||||
non_interactive: true,
|
||||
skip_admin: false,
|
||||
force: false,
|
||||
admin_user: Some("init_admin".to_string()),
|
||||
admin_password: Some("S3cur3#P@ssw0rd$N0S3qu3nc3!".to_string()),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(ref e) = res {
|
||||
println!("INIT ERROR: {:?}", e);
|
||||
}
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Verify DB exists
|
||||
assert!(Path::new(db_path).exists());
|
||||
|
||||
// Verify admin user is created in database
|
||||
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||
let admin_exists = nx9_auth::db::repository::users::username_exists(
|
||||
&pool,
|
||||
nx9_auth::db::models::Tenant::DEFAULT_ID,
|
||||
"init_admin",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(admin_exists);
|
||||
|
||||
// Clean up
|
||||
teardown_test_db(db_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_init_skip_admin() {
|
||||
let db_path = "test_cli_init_skip_admin.db";
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_file(db_path);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.database.path = db_path.to_string();
|
||||
|
||||
// Run init command with skip_admin
|
||||
let res = run(
|
||||
Commands::Init {
|
||||
non_interactive: true,
|
||||
skip_admin: true,
|
||||
force: false,
|
||||
admin_user: None,
|
||||
admin_password: None,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Verify DB exists
|
||||
assert!(Path::new(db_path).exists());
|
||||
|
||||
// Verify no admin users exist
|
||||
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||
let admin_count = nx9_auth::db::repository::users::count_admins(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(admin_count, 0);
|
||||
|
||||
// Clean up
|
||||
teardown_test_db(db_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cli_show_user_and_token() {
|
||||
let db_path = "test_cli_show.db";
|
||||
setup_test_db(db_path);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.database.path = db_path.to_string();
|
||||
|
||||
// 1. Init DB and seed user
|
||||
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||
nx9_auth::db::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = nx9_auth::identity::users::create_user(
|
||||
&pool,
|
||||
&config.security,
|
||||
nx9_auth::db::models::Tenant::DEFAULT_ID,
|
||||
"show_test_user",
|
||||
"S3cur3#P@ssw0rd$N0S3qu3nc3!",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Assign role
|
||||
nx9_auth::identity::roles::assign_role(&pool, &user.id, "viewer", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a token
|
||||
let (token, _raw) = nx9_auth::security::tokens::create_token(
|
||||
&pool,
|
||||
&user.id,
|
||||
"test-token",
|
||||
&config.security,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Run show-user command
|
||||
let res = run(
|
||||
Commands::ShowUser {
|
||||
id_or_username: "show_test_user".to_string(),
|
||||
permissions: true,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
let res = run(
|
||||
Commands::ShowUser {
|
||||
id_or_username: user.id.clone(),
|
||||
permissions: false,
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// 3. Run show-token command
|
||||
let res = run(
|
||||
Commands::ShowToken {
|
||||
id: token.id.clone(),
|
||||
},
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_ok());
|
||||
|
||||
// Clean up
|
||||
teardown_test_db(db_path);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,181 @@
|
||||
use nx9_auth::db::{self, models::Tenant, repository::roles as role_repo};
|
||||
|
||||
async fn setup_test_db() -> (sqlx::SqlitePool, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/migration_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
(pool, db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Scenario 1: Fresh Database -> Migrate -> Success
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_scenario_1_fresh() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
|
||||
// Run all migrations
|
||||
let res = db::run_migrations(&pool).await;
|
||||
assert!(res.is_ok(), "Fresh migration failed: {:?}", res);
|
||||
|
||||
// Verify default tables exist
|
||||
for table in &[
|
||||
"tenants",
|
||||
"users",
|
||||
"roles",
|
||||
"permissions",
|
||||
"sessions",
|
||||
"audit_logs",
|
||||
"_sqlx_migrations",
|
||||
] {
|
||||
let exists: Option<(String,)> =
|
||||
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.bind(table)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(exists.is_some(), "Table '{}' was not created", table);
|
||||
}
|
||||
|
||||
// Verify default tenant and admin/viewer roles exist
|
||||
let tenant_exists = sqlx::query("SELECT 1 FROM tenants WHERE id = ?")
|
||||
.bind(Tenant::DEFAULT_ID)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some();
|
||||
assert!(tenant_exists);
|
||||
|
||||
let admin_role = role_repo::find_by_name(&pool, "admin").await.unwrap();
|
||||
assert!(admin_role.is_some());
|
||||
|
||||
let viewer_role = role_repo::find_by_name(&pool, "viewer").await.unwrap();
|
||||
assert!(viewer_role.is_some());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Scenario 2: Database at migration N -> Migrate to N+1 -> Success
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_scenario_2_incremental() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
|
||||
let migrator = sqlx::migrate!("src/db/migrations");
|
||||
let all_migrations = &migrator.migrations;
|
||||
assert!(
|
||||
all_migrations.len() >= 3,
|
||||
"Expected at least 3 migrations to test incremental scenario"
|
||||
);
|
||||
|
||||
// 1. Manually create the _sqlx_migrations table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE _sqlx_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
success BOOLEAN NOT NULL,
|
||||
checksum BLOB NOT NULL,
|
||||
execution_time INTEGER NOT NULL
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Manually apply the first 2 migrations (N = 2)
|
||||
for migration in all_migrations.iter().take(2) {
|
||||
let sql: &'static str = Box::leak(migration.sql.as_ref().to_string().into_boxed_str());
|
||||
// Run SQL query directly
|
||||
sqlx::query(sql).execute(&pool).await.unwrap();
|
||||
|
||||
// Record it in _sqlx_migrations so SQLx knows it is applied
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time)
|
||||
VALUES (?, ?, 1, ?, 0)
|
||||
"#,
|
||||
)
|
||||
.bind(migration.version)
|
||||
.bind(migration.description.as_ref())
|
||||
.bind(migration.checksum.as_ref())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 3. Now run the SQLx Migrator to migrate to N+1 (and all remaining ones)
|
||||
let res = migrator.run(&pool).await;
|
||||
assert!(res.is_ok(), "Incremental migration failed: {:?}", res);
|
||||
|
||||
// Verify all tables are successfully created
|
||||
for table in &["tenants", "users", "roles", "permissions"] {
|
||||
let exists: Option<(String,)> =
|
||||
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.bind(table)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
exists.is_some(),
|
||||
"Table '{}' was not created incrementally",
|
||||
table
|
||||
);
|
||||
}
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Scenario 3: Run Migrations Twice -> Idempotent
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_scenario_3_idempotence() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
|
||||
// First run
|
||||
let res1 = db::run_migrations(&pool).await;
|
||||
assert!(res1.is_ok());
|
||||
|
||||
// Second run
|
||||
let res2 = db::run_migrations(&pool).await;
|
||||
assert!(
|
||||
res2.is_ok(),
|
||||
"Second migration run failed (idempotency issue): {:?}",
|
||||
res2
|
||||
);
|
||||
|
||||
// Verify default tenant and roles count didn't duplicate
|
||||
let tenant_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tenants WHERE id = ?")
|
||||
.bind(Tenant::DEFAULT_ID)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tenant_count.0, 1, "Default tenant was duplicated!");
|
||||
|
||||
let admin_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(admin_count.0, 1, "Admin role was duplicated!");
|
||||
|
||||
let viewer_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'viewer'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(viewer_count.0, 1, "Viewer role was duplicated!");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use nx9_auth::{
|
||||
api,
|
||||
config::{Config, SecurityConfig},
|
||||
db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
repository::{roles as role_repo, tokens as token_repo, users as user_repo},
|
||||
},
|
||||
identity::{roles as identity_roles, users as identity_users},
|
||||
security::{passwords, sessions, tokens},
|
||||
state::AppState,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn setup_test_db() -> (sqlx::SqlitePool, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/security_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path)
|
||||
.await
|
||||
.expect("Failed to create test pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run test migrations");
|
||||
(pool, db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
fn test_security_config() -> SecurityConfig {
|
||||
SecurityConfig {
|
||||
session_ttl_hours: 24,
|
||||
session_absolute_ttl_days: 30,
|
||||
token_ttl_days: 365,
|
||||
argon2_memory: 4096, // low cost for fast tests
|
||||
argon2_iterations: 1,
|
||||
argon2_parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(db_path: String) -> Config {
|
||||
Config {
|
||||
server: nx9_auth::config::ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8656,
|
||||
},
|
||||
database: nx9_auth::config::DatabaseConfig { path: db_path },
|
||||
security: test_security_config(),
|
||||
audit: nx9_auth::config::AuditConfig { enabled: true },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Password & Token Leakage Verification
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_no_plaintext_passwords_in_db() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let password = "super_secret_special_pass_123456";
|
||||
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"leak_test_user",
|
||||
password,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Query the raw database row and verify the plaintext password is not in the row
|
||||
let row: (String,) = sqlx::query_as("SELECT password_hash FROM users WHERE id = ?")
|
||||
.bind(&user.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!row.0.contains(password));
|
||||
assert_ne!(row.0, password);
|
||||
|
||||
// Grep/search the entire users table for the plaintext password string
|
||||
let matches: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT id FROM users WHERE password_hash LIKE ? OR username LIKE ?")
|
||||
.bind(format!("%{}%", password))
|
||||
.bind(format!("%{}%", password))
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches.is_empty());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_no_plaintext_tokens_in_db() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"token_leak_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (token, raw_pat) =
|
||||
tokens::create_token(&pool, &user.id, "my_pat", &sec_cfg, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Check token_hash in db
|
||||
let row: (String,) = sqlx::query_as("SELECT token_hash FROM api_tokens WHERE id = ?")
|
||||
.bind(&token.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!raw_pat.is_empty());
|
||||
assert!(!row.0.contains(&raw_pat));
|
||||
assert_ne!(row.0, raw_pat);
|
||||
|
||||
// Search table
|
||||
let matches: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT id FROM api_tokens WHERE token_hash LIKE ? OR name LIKE ?")
|
||||
.bind(format!("%{}%", raw_pat))
|
||||
.bind(format!("%{}%", raw_pat))
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches.is_empty());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_no_plaintext_sessions_in_db() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"session_leak_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (session, raw_token) =
|
||||
sessions::create_session(&pool, &user.id, Some("127.0.0.1"), Some("UA"), &sec_cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row: (String,) = sqlx::query_as("SELECT token_hash FROM sessions WHERE id = ?")
|
||||
.bind(&session.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!raw_token.is_empty());
|
||||
assert!(!row.0.contains(&raw_token));
|
||||
assert_ne!(row.0, raw_token);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. User Enumeration Protection
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_user_enumeration_payload_match() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let config = test_config(db_path.clone());
|
||||
let state = AppState::new(pool.clone(), config);
|
||||
let app = api::router::build(state);
|
||||
|
||||
// Scenario A: Non-existent user
|
||||
let req_non_existent = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"username": "non_existent_user_123", "password": "some_random_password"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let res_non_existent = app.clone().oneshot(req_non_existent).await.unwrap();
|
||||
assert_eq!(res_non_existent.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let body_bytes = axum::body::to_bytes(res_non_existent.into_body(), 2048)
|
||||
.await
|
||||
.unwrap();
|
||||
let json_non_existent: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
|
||||
// Scenario B: Existent user, wrong password
|
||||
let sec_cfg = test_security_config();
|
||||
let _user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"existent_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req_wrong_password = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"username": "existent_user", "password": "wrong_password_abc"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let res_wrong_password = app.oneshot(req_wrong_password).await.unwrap();
|
||||
assert_eq!(res_wrong_password.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let body_bytes_wrong = axum::body::to_bytes(res_wrong_password.into_body(), 2048)
|
||||
.await
|
||||
.unwrap();
|
||||
let json_wrong_password: Value = serde_json::from_slice(&body_bytes_wrong).unwrap();
|
||||
|
||||
// Compare JSON outputs and check format
|
||||
let expected = serde_json::json!({
|
||||
"error": "invalid credentials",
|
||||
"code": "unauthorized"
|
||||
});
|
||||
|
||||
assert_eq!(json_non_existent, expected);
|
||||
assert_eq!(json_wrong_password, expected);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Session Revocation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_session_revocation_lifecycle() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let config = test_config(db_path.clone());
|
||||
let state = AppState::new(pool.clone(), config);
|
||||
let app = api::router::build(state);
|
||||
|
||||
let sec_cfg = test_security_config();
|
||||
let _user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"session_lifecycle_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Login to get cookie
|
||||
let req_login = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"username": "session_lifecycle_user", "password": "super_secure_passphrase_123"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let res_login = app.clone().oneshot(req_login).await.unwrap();
|
||||
assert_eq!(res_login.status(), StatusCode::OK);
|
||||
|
||||
let cookie_header = res_login
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let cookie_value = cookie_header.split(';').next().unwrap(); // e.g. nx9_session=abc...
|
||||
|
||||
// 2. Validate GET /api/v1/auth/me works
|
||||
let req_me = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/auth/me")
|
||||
.header(header::COOKIE, cookie_value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res_me = app.clone().oneshot(req_me).await.unwrap();
|
||||
assert_eq!(res_me.status(), StatusCode::OK);
|
||||
|
||||
// 3. Logout to revoke session
|
||||
let req_logout = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/logout")
|
||||
.header(header::COOKIE, cookie_value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res_logout = app.clone().oneshot(req_logout).await.unwrap();
|
||||
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||
|
||||
// 4. Try reuse session cookie -> must get 401
|
||||
let req_me_revoked = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/auth/me")
|
||||
.header(header::COOKIE, cookie_value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res_me_revoked = app.oneshot(req_me_revoked).await.unwrap();
|
||||
assert_eq!(res_me_revoked.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Transaction Rollback Verification
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_transaction_rollback_on_audit_failure_create_user() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
|
||||
// Trigger Foreign Key constraint violation by passing non-existent audit actor ID
|
||||
let bad_actor_id = "non_existent_user_id_trigger_rollback";
|
||||
let res = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"rollback_user",
|
||||
"super_secure_passphrase_123",
|
||||
Some(bad_actor_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Must return Database/Constraint error
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify user was NOT created in the database due to transaction rollback
|
||||
let user_in_db = user_repo::find_by_username(&pool, "rollback_user")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(user_in_db.is_none());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_transaction_rollback_on_audit_failure_reset_password() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
|
||||
// Create user successfully
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"rollback_pwd_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original_hash = user.password_hash.clone();
|
||||
|
||||
// Try resetting password but with a bad audit actor id to trigger FK violation
|
||||
let bad_actor_id = "non_existent_actor_id";
|
||||
let res = identity_users::reset_password(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
&user.id,
|
||||
"new_super_secure_passphrase_123456",
|
||||
Some(bad_actor_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify password hash in db is still the original one (rolled back)
|
||||
let user_after = user_repo::find_by_id(&pool, &user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(user_after.password_hash, original_hash);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_transaction_rollback_on_audit_failure_assign_role() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"rollback_role_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to assign admin role but fail on audit step
|
||||
let bad_actor_id = "non_existent_actor_id";
|
||||
let res =
|
||||
identity_roles::assign_role(&pool, &user.id, "admin", Some(bad_actor_id), None, None).await;
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify role was not assigned
|
||||
let user_roles = role_repo::list_for_user(&pool, &user.id).await.unwrap();
|
||||
assert!(user_roles.is_empty());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_transaction_rollback_on_audit_failure_create_token() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"rollback_tok_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to create token but fail on audit log FK violation
|
||||
let bad_actor_id = "non_existent_actor_id";
|
||||
let res = tokens::create_token(
|
||||
&pool,
|
||||
&user.id,
|
||||
"my-pat-token",
|
||||
&sec_cfg,
|
||||
Some(bad_actor_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify no tokens were created for the user
|
||||
let user_tokens = token_repo::list_for_user(&pool, &user.id).await.unwrap();
|
||||
assert!(user_tokens.is_empty());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_assign_non_existent_role_fails() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let user = identity_users::create_user(
|
||||
&pool,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"no_role_user",
|
||||
"super_secure_passphrase_123",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res =
|
||||
identity_roles::assign_role(&pool, &user.id, "non_existent_role_name", None, None, None)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_create_token_non_existent_user_fails() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let res = tokens::create_token(
|
||||
&pool,
|
||||
"non_existent_user_id",
|
||||
"my-token",
|
||||
&sec_cfg,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_service_account_audit_lifecycle() {
|
||||
let (pool, db_path) = setup_test_db().await;
|
||||
let name = "my-service-account";
|
||||
let desc = Some("A test description");
|
||||
|
||||
// 1. Create service account
|
||||
let sa = nx9_auth::identity::service_accounts::create(
|
||||
&pool,
|
||||
Tenant::DEFAULT_ID,
|
||||
name,
|
||||
desc,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sa.name, name);
|
||||
assert_eq!(sa.description.as_deref(), desc);
|
||||
assert!(sa.enabled);
|
||||
|
||||
// 2. Disable service account
|
||||
let res_disable =
|
||||
nx9_auth::identity::service_accounts::set_enabled(&pool, &sa.id, false, None, None, None)
|
||||
.await;
|
||||
assert!(res_disable.is_ok());
|
||||
|
||||
let sa_disabled = nx9_auth::identity::service_accounts::list(&pool, Tenant::DEFAULT_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|x| x.id == sa.id)
|
||||
.unwrap();
|
||||
assert!(!sa_disabled.enabled);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_verify_dummy_execution() {
|
||||
let sec_cfg = test_security_config();
|
||||
let res = passwords::verify_dummy(&sec_cfg);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_security_invalid_password_strength_admin() {
|
||||
// Admin password needs to be at least 12 characters
|
||||
let res = passwords::validate_password_strength("too_short_1", true);
|
||||
assert!(res.is_err());
|
||||
|
||||
let res_ok = passwords::validate_password_strength("long_enough_admin_pass_123", true);
|
||||
assert!(res_ok.is_ok());
|
||||
}
|
||||
Reference in new issue
Block a user