use super::*;
const KEY: &[u8] = b"test-hash-secret";
#[test]
fn password_policy_enforces_complexity() {
let policy = PasswordPolicy::default();
assert!(policy.validate("CorrectHorse1!").is_ok());
assert!(policy.validate("Ab1!").is_err(), "too short");
assert!(policy.validate("correcthorse1!").is_err(), "no uppercase");
assert!(policy.validate("CORRECTHORSE1!").is_err(), "no lowercase");
assert!(policy.validate("CorrectHorse!!").is_err(), "no digit");
assert!(policy.validate("CorrectHorse12").is_err(), "no symbol");
}
fn live_pg_dsn() -> Option<String> {
std::env::var("UDB_LIVE_AUTH_PG_DSN")
.or_else(|_| std::env::var("UDB_INTEGRATION_PG_DSN"))
.ok()
.or_else(|| {
std::env::var("UDB_LIVE_AUTH_TESTS")
.ok()
.filter(|v| matches!(v.as_str(), "1" | "true" | "yes"))
.map(|_| "postgres://udb:udb@localhost:55432/udb".to_string())
})
}
async fn live_auth_pool() -> Option<sqlx::PgPool> {
let dsn = live_pg_dsn()?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect(&dsn)
.await
.unwrap_or_else(|err| panic!("connect live auth postgres at {dsn}: {err}"));
cleanup_live_auth_db(&pool).await;
for stmt in crate::runtime::native_catalog::native_service_catalog_ddl() {
sqlx::raw_sql(&stmt)
.execute(&pool)
.await
.unwrap_or_else(|err| panic!("native auth DDL failed: {err}\nSQL:\n{stmt}"));
}
Some(pool)
}
async fn cleanup_live_auth_db(pool: &sqlx::PgPool) {
let schemas: Vec<String> = sqlx::query_scalar(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'udb\\_%' ESCAPE '\\'",
)
.fetch_all(pool)
.await
.expect("list native udb_* schemas");
for schema in schemas {
let stmt = format!(
"DROP SCHEMA IF EXISTS \"{}\" CASCADE",
schema.replace('"', "\"\"")
);
sqlx::query(&stmt)
.execute(pool)
.await
.unwrap_or_else(|err| panic!("drop native schema {schema}: {err}"));
}
let public_tables: Vec<String> =
sqlx::query_scalar("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
.fetch_all(pool)
.await
.expect("list public tables");
for table in public_tables {
let stmt = format!(
"DROP TABLE IF EXISTS public.\"{}\" CASCADE",
table.replace('"', "\"\"")
);
sqlx::query(&stmt)
.execute(pool)
.await
.unwrap_or_else(|err| panic!("drop public table {table}: {err}"));
}
sqlx::query("DROP EXTENSION IF EXISTS pg_partman CASCADE")
.execute(pool)
.await
.expect("drop pg_partman extension");
sqlx::query("DROP SCHEMA IF EXISTS partman CASCADE")
.execute(pool)
.await
.expect("drop partman schema");
}
fn test_now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
async fn seed_live_user(pool: &sqlx::PgPool, label: &str) -> String {
let user_id = uuid::Uuid::new_v4().to_string();
let suffix = uuid::Uuid::new_v4().simple().to_string();
let now = test_now_unix();
PostgresUserStore::new(pool.clone(), "")
.put_user(UserRecord {
user_id: user_id.clone(),
username: format!("{label}_{suffix}"),
email: format!("{label}_{suffix}@example.com"),
password_hash: hash_secret("password", KEY),
account_kind: AccountKind::Person,
status: AccountStatus::Active,
tenant_id: "acme".into(),
full_name: label.to_string(),
created_at_unix: now,
updated_at_unix: now,
profile_attributes_json: "{}".into(),
..Default::default()
})
.await
.expect("seed live auth user");
user_id
}
#[test]
fn hash_secret_is_deterministic_and_keyed() {
let a = hash_secret("session-abc", KEY);
let b = hash_secret("session-abc", KEY);
assert_eq!(a, b, "same secret + key → same hash");
assert!(a.starts_with("hmac-sha256:"));
assert_ne!(a, hash_secret("session-abc", b"other-key"));
assert_ne!(a, hash_secret("session-xyz", KEY));
assert!(!a.contains("session-abc"));
}
#[test]
fn password_hash_runtime_and_proto_contract_agree_on_argon2id() {
let hashed = hash_password("CorrectHorse1!", KEY);
assert!(hashed.starts_with("$argon2"));
assert!(!password_hash_needs_upgrade(&hashed));
assert!(password_hash_needs_upgrade(&hash_secret(
"password:legacy",
KEY
)));
let proto = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("proto/udb/core/authn/entity/v1/user.proto"),
)
.expect("read user proto");
assert!(proto.contains("Argon2id PHC hash"));
assert!(proto.contains("hashing_algorithm: \"argon2id\""));
assert!(!proto.to_ascii_lowercase().contains("bcrypt"));
}
#[test]
fn session_hash_runtime_and_proto_contract_agree_on_hmac_sha256_storage_only() {
let hashed = hash_secret("sess_raw", KEY);
assert!(hashed.starts_with("hmac-sha256:"));
assert!(!hashed.contains("sess_raw"));
let proto = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("proto/udb/core/authn/entity/v1/session.proto"),
)
.expect("read session proto");
assert!(proto.contains("hashing_algorithm: \"hmac-sha256\""));
assert!(proto.contains("output_view: OUTPUT_VIEW_STORAGE_ONLY"));
assert!(proto.contains("Keyed HMAC digest of the session token"));
assert!(!proto.to_ascii_lowercase().contains("bcrypt"));
}
#[tokio::test]
#[ignore = "requires Postgres; run with UDB_LIVE_AUTH_TESTS=1 ... -- --ignored"]
async fn session_lifecycle_create_validate_expire_revoke() {
let Some(pool) = live_auth_pool().await else {
eprintln!("skipped: set UDB_LIVE_AUTH_TESTS=1 or UDB_LIVE_AUTH_PG_DSN");
return;
};
let user_id = seed_live_user(&pool, "session_lifecycle").await;
let store = PostgresSessionStore::new(pool, "");
let raw = "sess-raw-123";
let rec = SessionRecord {
session_id_hash: hash_secret(raw, KEY),
principal_id: "user_1".into(),
user_id,
tenant_id: "acme".into(),
created_at_unix: test_now_unix(),
updated_at_unix: test_now_unix(),
expires_at_unix: 2000,
..Default::default()
};
store.put(&rec).await.unwrap();
assert!(
validate_session(&store, raw, KEY, 1500, 0)
.await
.unwrap()
.is_some()
);
assert!(
validate_session(&store, "wrong", KEY, 1500, 0)
.await
.unwrap()
.is_none()
);
assert!(
validate_session(&store, raw, KEY, 2000, 0)
.await
.unwrap()
.is_none()
);
assert!(
validate_session(&store, raw, KEY, 2500, 0)
.await
.unwrap()
.is_none()
);
assert!(store.revoke(&hash_secret(raw, KEY), 1200).await.unwrap());
assert!(
validate_session(&store, raw, KEY, 1500, 0)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "requires Postgres; run with UDB_LIVE_AUTH_TESTS=1 ... -- --ignored"]
async fn refresh_session_extends_active_but_not_expired() {
let Some(pool) = live_auth_pool().await else {
eprintln!("skipped: set UDB_LIVE_AUTH_TESTS=1 or UDB_LIVE_AUTH_PG_DSN");
return;
};
let user_id = seed_live_user(&pool, "refresh_session").await;
let store = PostgresSessionStore::new(pool, "");
let raw = "s1";
let record = SessionRecord {
session_id_hash: hash_secret(raw, KEY),
principal_id: "u".into(),
user_id,
created_at_unix: test_now_unix(),
updated_at_unix: test_now_unix(),
expires_at_unix: 2000,
..Default::default()
};
store.put(&record).await.unwrap();
let r = refresh_session(&store, raw, KEY, 1500, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(r.expires_at_unix, 2500);
assert_eq!(r.updated_at_unix, 1500);
assert!(
validate_session(&store, raw, KEY, 2400, 0)
.await
.unwrap()
.is_some()
);
assert!(
refresh_session(&store, raw, KEY, 3000, 1000)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "requires Postgres; run with UDB_LIVE_AUTH_TESTS=1 ... -- --ignored"]
async fn revoke_all_for_principal_revokes_only_that_principal() {
let Some(pool) = live_auth_pool().await else {
eprintln!("skipped: set UDB_LIVE_AUTH_TESTS=1 or UDB_LIVE_AUTH_PG_DSN");
return;
};
let user_1_id = seed_live_user(&pool, "revoke_user_1").await;
let user_2_id = seed_live_user(&pool, "revoke_user_2").await;
let store = PostgresSessionStore::new(pool, "");
for (raw, principal) in [("a", "user_1"), ("b", "user_1"), ("c", "user_2")] {
let user_id = if principal == "user_1" {
user_1_id.clone()
} else {
user_2_id.clone()
};
let record = SessionRecord {
session_id_hash: hash_secret(raw, KEY),
principal_id: principal.into(),
user_id,
created_at_unix: test_now_unix(),
updated_at_unix: test_now_unix(),
expires_at_unix: 9999,
..Default::default()
};
store.put(&record).await.unwrap();
}
let revoked = store.revoke_all_for_principal("user_1", 500).await.unwrap();
assert_eq!(revoked, 2);
assert!(
validate_session(&store, "a", KEY, 100, 0)
.await
.unwrap()
.is_none()
);
assert!(
validate_session(&store, "b", KEY, 100, 0)
.await
.unwrap()
.is_none()
);
assert!(
validate_session(&store, "c", KEY, 100, 0)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
#[ignore = "requires Postgres; run with UDB_LIVE_AUTH_TESTS=1 ... -- --ignored"]
async fn api_key_validate_hash_lookup_expiry_revocation() {
let Some(pool) = live_auth_pool().await else {
eprintln!("skipped: set UDB_LIVE_AUTH_TESTS=1 or UDB_LIVE_AUTH_PG_DSN");
return;
};
let store = PostgresApiKeyStore::new(pool, "");
let raw = "udbk_abc123.secretpart";
store
.put(ApiKeyRecord {
key_prefix: api_key_prefix(raw),
key_hash: hash_secret(raw, KEY),
principal_id: "svc-1".into(),
tenant_id: "acme".into(),
scopes: vec!["udb:read".into()],
expires_at_unix: 2000,
..Default::default()
})
.await
.unwrap();
assert_eq!(api_key_prefix(raw), "udbk_abc123");
assert!(
validate_api_key(&store, raw, KEY, 1000)
.await
.unwrap()
.is_some()
);
assert!(
validate_api_key(&store, "udbk_abc123.WRONG", KEY, 1000)
.await
.unwrap()
.is_none()
);
assert!(
validate_api_key(&store, raw, KEY, 2000)
.await
.unwrap()
.is_none()
);
assert!(store.revoke("udbk_abc123", 500).await.unwrap());
assert!(
validate_api_key(&store, raw, KEY, 1000)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "requires Postgres; run with UDB_LIVE_AUTH_TESTS=1 ... -- --ignored"]
async fn rotate_api_key_issues_new_and_revokes_old() {
let Some(pool) = live_auth_pool().await else {
eprintln!("skipped: set UDB_LIVE_AUTH_TESTS=1 or UDB_LIVE_AUTH_PG_DSN");
return;
};
let store = PostgresApiKeyStore::new(pool, "");
let old = "udbk_old.s1";
store
.put(ApiKeyRecord {
key_prefix: api_key_prefix(old),
key_hash: hash_secret(old, KEY),
principal_id: "svc".into(),
scopes: vec!["udb:read".into()],
expires_at_unix: 9999,
..Default::default()
})
.await
.unwrap();
assert!(
validate_api_key(&store, old, KEY, 100)
.await
.unwrap()
.is_some()
);
let new = "udbk_new.s2";
let template = ApiKeyRecord {
principal_id: "svc".into(),
scopes: vec!["udb:read".into()],
expires_at_unix: 9999,
..Default::default()
};
let new_rec = rotate_api_key(&store, &api_key_prefix(old), new, KEY, 500, template)
.await
.unwrap();
assert_eq!(new_rec.key_prefix, "udbk_new");
assert!(
validate_api_key(&store, old, KEY, 600)
.await
.unwrap()
.is_none()
);
assert!(
validate_api_key(&store, new, KEY, 600)
.await
.unwrap()
.is_some()
);
}
#[test]
fn auth_catalog_ddl_is_idempotent_and_covers_all_tables() {
let ddl = auth_catalog_ddl("udb_system");
let joined = ddl.join("\n");
for fragment in [
"CREATE TABLE IF NOT EXISTS \"udb_authn\".\"users\"",
"CREATE TABLE IF NOT EXISTS \"udb_authn\".\"sessions\"",
"CREATE TABLE IF NOT EXISTS \"udb_authn\".\"otps\"",
"CREATE TABLE IF NOT EXISTS \"udb_authn\".\"api_keys\"",
] {
assert!(
joined.contains(fragment),
"missing generated DDL fragment {fragment}"
);
}
assert!(
joined.contains("UDB:proto_manifest_checksum"),
"native authn DDL must carry proto manifest metadata"
);
}
#[test]
fn authn_config_defaults_and_sessions_usable_gate() {
let cfg = AuthnConfig::default();
assert!(!cfg.session_enabled);
assert_eq!(cfg.session_backend, "postgres");
assert_eq!(cfg.session_ttl_secs, 86_400);
assert_eq!(cfg.session_idle_ttl_secs, 3_600);
assert!(!cfg.sessions_usable());
let ok = AuthnConfig {
session_enabled: true,
session_hash_secret: "secret".into(),
..AuthnConfig::default()
};
assert!(ok.sessions_usable());
let no_secret = AuthnConfig {
session_enabled: true,
..AuthnConfig::default()
};
assert!(!no_secret.sessions_usable());
}
#[test]
fn external_jwt_provider_maps_configured_claims() {
let provider = ExternalJwtProvider::new(ExternalProviderConfig {
provider_id: "auth0".into(),
tenant_claim: "org_id".into(),
roles_claim: "https://udb/roles".into(),
..ExternalProviderConfig::default()
});
let claims = r#"{
"sub": "u1",
"org_id": "acme",
"project_id": "billing",
"https://udb/roles": ["admin", "writer"],
"scopes": "udb:read udb:write"
}"#;
let p = provider.map_identity("", claims).unwrap();
assert_eq!(p.provider_id, "auth0");
assert_eq!(p.subject, "u1");
assert_eq!(p.principal_id, "auth0:u1");
assert_eq!(p.tenant_id, "acme");
assert_eq!(p.project_id, "billing");
assert_eq!(p.roles, vec!["admin", "writer"]);
assert_eq!(p.scopes, vec!["udb:read", "udb:write"]);
assert_eq!(p.auth_method, "external");
}
#[tokio::test]
async fn external_roles_alone_do_not_bypass_udb_policy() {
use crate::runtime::authz::{AuthzPolicy, AuthzQuery, AuthzSnapshot, Effect, ResourceRef};
use std::collections::BTreeMap;
let provider = ExternalJwtProvider::new(ExternalProviderConfig::default());
let principal = provider
.map_identity("", r#"{"sub":"u1","tenant_id":"acme","roles":["admin"]}"#)
.unwrap();
let res = ResourceRef::message("acme.Invoice");
let attrs = BTreeMap::new();
let q = AuthzQuery {
principal: &principal,
resource: &res,
action: "data.delete",
purpose: "x",
attributes: &attrs,
};
let empty = AuthzSnapshot {
version: "v".into(),
..Default::default()
};
assert!(
!empty.casbin_authorize(&q).await.allowed,
"external roles must not bypass UDB authorization"
);
let snap = AuthzSnapshot {
version: "v".into(),
policies: vec![AuthzPolicy {
id: "p".into(),
role: "admin".into(),
action: "*".into(),
effect: Effect::Allow,
..Default::default()
}],
..Default::default()
};
assert!(snap.casbin_authorize(&q).await.allowed);
}