feat: harden runtime lifecycle and application credentials
- enforce deterministic runtime lifecycle state transitions - add live graceful-to-forced shutdown escalation - align HTTP draining and worker shutdown with global deadline - guarantee deterministic shutdown hook ordering - add secure application client IDs and one-time client secrets - hash application secrets with BLAKE3 and constant-time verification - make credential creation and rotation transactionally auditable - enforce strict client_id authentication and redirect URI validation - add SQLite and PostgreSQL credential migrations - add application credential and runtime lifecycle acceptance tests - update Dioxus application management workflows - update security and architecture documentation
This commit is contained in:
1 parent
4c697e9adf
commit
dc5417334b
26 files changed
+2477
-183
No files matched your search
@@ -0,0 +1,722 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nx9_auth::{
|
||||
api,
|
||||
config::{Config, DatabaseConfig, SecurityConfig, ServerConfig},
|
||||
db::{self, models::Tenant, provider::SqliteProvider},
|
||||
error::AppError,
|
||||
identity::{applications, roles, users},
|
||||
security::sessions,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn setup_test_db() -> (
|
||||
Arc<dyn db::provider::DatabaseProvider>,
|
||||
sqlx::SqlitePool,
|
||||
String,
|
||||
) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_app_{}.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");
|
||||
let provider = Arc::new(SqliteProvider::new(pool.clone()));
|
||||
(provider, 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,
|
||||
argon2_iterations: 1,
|
||||
argon2_parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(db_path: String) -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 8655,
|
||||
cookie_secure: false,
|
||||
production: false,
|
||||
},
|
||||
database: DatabaseConfig {
|
||||
path: Some(db_path),
|
||||
..Default::default()
|
||||
},
|
||||
security: test_security_config(),
|
||||
audit: nx9_auth::config::AuditConfig { enabled: true },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_app() -> (axum::Router, String, String, String) {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
let config = test_config(db_path.clone());
|
||||
let sec_cfg = config.security.clone();
|
||||
|
||||
let admin = users::create_user(
|
||||
&provider,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"admin_app_user",
|
||||
"AdminSecret123!",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
roles::assign_role(&provider, &admin.id, "admin", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_session, raw_token) = sessions::create_session(
|
||||
&provider,
|
||||
&admin.id,
|
||||
Some("127.0.0.1"),
|
||||
Some("TestUA"),
|
||||
&sec_cfg,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = AppState::new(provider.clone(), config);
|
||||
let router = api::router::build(state);
|
||||
(router, admin.id, raw_token, db_path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_credential_generation_and_validation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let client_id = applications::generate_client_id();
|
||||
assert!(client_id.starts_with("nx9_app_"));
|
||||
assert_eq!(client_id.len(), 40); // nx9_app_ (8) + 32 hex chars = 40
|
||||
|
||||
let client_secret = applications::generate_client_secret();
|
||||
assert!(client_secret.starts_with("nx9_secret_"));
|
||||
assert_eq!(client_secret.len(), 75); // nx9_secret_ (11) + 64 hex chars = 75
|
||||
|
||||
let (app, raw_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Test App",
|
||||
"test-app",
|
||||
Some("Description of Test App"),
|
||||
Some(vec!["https://example.com/callback".into()]),
|
||||
Some(vec!["openid".into(), "profile".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(app.get_client_id().starts_with("nx9_app_"));
|
||||
assert!(app.has_credentials());
|
||||
assert_eq!(app.redirect_urls(), vec!["https://example.com/callback"]);
|
||||
assert_eq!(app.scopes(), vec!["openid", "profile"]);
|
||||
|
||||
// Secret hash in DB must be hex encoded BLAKE3 digest, not plaintext secret
|
||||
assert_ne!(app.client_secret_hash.as_ref().unwrap(), &raw_secret);
|
||||
|
||||
// Valid credentials authentication
|
||||
let validated =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &raw_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validated.id, app.id);
|
||||
|
||||
// Invalid secret
|
||||
let invalid_sec = applications::validate_client_credentials(
|
||||
&provider,
|
||||
app.get_client_id(),
|
||||
"nx9_secret_invalid",
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(invalid_sec, Err(AppError::Unauthorized)));
|
||||
|
||||
// Unknown client_id
|
||||
let unknown_client =
|
||||
applications::validate_client_credentials(&provider, "nx9_app_nonexistent", &raw_secret)
|
||||
.await;
|
||||
assert!(matches!(unknown_client, Err(AppError::Unauthorized)));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_secret_rotation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, old_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rotate App",
|
||||
"rotate-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let new_secret = applications::rotate_secret(&provider, &app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old_secret, new_secret);
|
||||
|
||||
// Old secret fails
|
||||
let old_val =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(matches!(old_val, Err(AppError::Unauthorized)));
|
||||
|
||||
// New secret succeeds
|
||||
let new_val =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &new_secret)
|
||||
.await;
|
||||
assert!(new_val.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_api_endpoints_and_cache_control() {
|
||||
let (app_router, _user_id, token, db_path) = setup_app().await;
|
||||
|
||||
// 1. Create Application API
|
||||
let req_body = serde_json::json!({
|
||||
"name": "API Test App",
|
||||
"slug": "api-test-app",
|
||||
"description": "App built for API testing",
|
||||
"redirect_urls": ["https://app.test/cb"],
|
||||
"scopes": ["openid", "profile"]
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/applications")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::from(serde_json::to_vec(&req_body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app_router.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
|
||||
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let create_resp: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
let app_obj = &create_resp["application"];
|
||||
let client_id = app_obj["client_id"].as_str().unwrap().to_string();
|
||||
let app_id = app_obj["id"].as_str().unwrap().to_string();
|
||||
let client_secret = create_resp["client_secret"].as_str().unwrap().to_string();
|
||||
|
||||
assert!(client_id.starts_with("nx9_app_"));
|
||||
assert!(client_secret.starts_with("nx9_secret_"));
|
||||
|
||||
// 2. GET Application API (Must NOT expose secret or secret hash)
|
||||
let get_req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/api/v1/applications/{app_id}"))
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let get_resp = app_router.clone().oneshot(get_req).await.unwrap();
|
||||
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||
let get_bytes = get_resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let get_json: Value = serde_json::from_slice(&get_bytes).unwrap();
|
||||
let get_app = &get_json["application"];
|
||||
|
||||
assert_eq!(get_app["client_id"], client_id);
|
||||
assert!(get_app.get("client_secret").is_none());
|
||||
assert!(get_app.get("client_secret_hash").is_none());
|
||||
assert_eq!(get_app["credentials_configured"], true);
|
||||
|
||||
// 3. PATCH Application containing `client_id` MUST be rejected by `deny_unknown_fields`
|
||||
let patch_invalid = serde_json::json!({
|
||||
"name": "Updated Name",
|
||||
"slug": "api-test-app",
|
||||
"client_id": "nx9_app_hack_attempt",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let patch_req = Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/api/v1/applications/{app_id}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::from(serde_json::to_vec(&patch_invalid).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let patch_resp = app_router.clone().oneshot(patch_req).await.unwrap();
|
||||
assert!(patch_resp.status().is_client_error()); // 400 / 422 Bad Request due to deny_unknown_fields
|
||||
|
||||
// 4. Rotate Secret API
|
||||
let rotate_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/applications/{app_id}/secret"))
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let rotate_resp = app_router.clone().oneshot(rotate_req).await.unwrap();
|
||||
assert_eq!(rotate_resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
rotate_resp
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
|
||||
let rotate_bytes = rotate_resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let rotate_json: Value = serde_json::from_slice(&rotate_bytes).unwrap();
|
||||
let new_secret = rotate_json["client_secret"].as_str().unwrap();
|
||||
assert!(new_secret.starts_with("nx9_secret_"));
|
||||
assert_ne!(new_secret, client_secret);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_database_migration_backfill_and_upgrade() {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_upgrade_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path).await.unwrap();
|
||||
|
||||
// Execute migrations up to 0016 manually to simulate a v0.3.0 existing database
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0001_create_tenants.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0002_create_users.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0003_create_user_profiles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0004_create_roles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0005_create_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0006_create_role_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0007_create_user_roles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0008_create_sessions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0009_create_api_tokens.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0010_create_service_accounts.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0011_create_applications.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0012_create_audit_logs.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0013_seed_default_tenant.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0014_seed_roles_and_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0015_create_refresh_tokens.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0016_create_groups.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let legacy_id = "30000000-0000-0000-0000-000000000099";
|
||||
sqlx::query("INSERT INTO applications (id, tenant_id, name, slug) VALUES (?, '00000000-0000-0000-0000-000000000001', 'Legacy App', 'legacy-app')")
|
||||
.bind(legacy_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now run migration 0017 and 0018
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0017_update_applications_credentials.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0018_harden_application_credentials.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider: Arc<dyn db::provider::DatabaseProvider> = Arc::new(SqliteProvider::new(pool));
|
||||
let legacy_app = applications::get(&provider, legacy_id).await.unwrap();
|
||||
|
||||
assert_eq!(legacy_app.name, "Legacy App");
|
||||
assert_eq!(legacy_app.slug.as_deref(), Some("legacy-app"));
|
||||
assert!(legacy_app.get_client_id().starts_with("nx9_app_"));
|
||||
assert!(!legacy_app.has_credentials());
|
||||
|
||||
// Administrator performs secret rotation to generate credentials
|
||||
let generated_secret = applications::rotate_secret(&provider, &legacy_app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let updated_legacy = applications::get(&provider, legacy_id).await.unwrap();
|
||||
assert!(updated_legacy.has_credentials());
|
||||
|
||||
// Validate generated credentials
|
||||
let auth_res = applications::validate_client_credentials(
|
||||
&provider,
|
||||
updated_legacy.get_client_id(),
|
||||
&generated_secret,
|
||||
)
|
||||
.await;
|
||||
assert!(auth_res.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_creation_transactional_rollback_on_audit_failure() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
// Force audit log foreign-key failure by passing invalid actor_id
|
||||
let res = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rollback App",
|
||||
"rollback-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("non_existent_actor_id_fk"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify application record was NOT created in DB
|
||||
let app_opt = applications::find_by_slug(&provider, "rollback-app").await;
|
||||
assert!(matches!(app_opt, Err(AppError::NotFound)));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_secret_rotation_transactional_rollback_on_audit_failure() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, old_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rotate Rollback App",
|
||||
"rotate-rollback-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let orig_hash = app.client_secret_hash.clone().unwrap();
|
||||
|
||||
// Force audit insertion failure during rotation
|
||||
let fail_res = applications::rotate_secret(
|
||||
&provider,
|
||||
&app.id,
|
||||
Some("non_existent_actor_id_fk"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(fail_res.is_err());
|
||||
|
||||
// Assert stored client_secret_hash in DB remains UNCHANGED
|
||||
let app_after_failed_rotation = applications::get(&provider, &app.id).await.unwrap();
|
||||
assert_eq!(
|
||||
app_after_failed_rotation
|
||||
.client_secret_hash
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&orig_hash
|
||||
);
|
||||
|
||||
// Assert original secret STILL authenticates successfully
|
||||
let orig_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(orig_auth.is_ok());
|
||||
|
||||
// Perform successful rotation
|
||||
let new_secret = applications::rotate_secret(&provider, &app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Old secret fails, new secret succeeds
|
||||
let old_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(matches!(old_auth, Err(AppError::Unauthorized)));
|
||||
|
||||
let new_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &new_secret)
|
||||
.await;
|
||||
assert!(new_auth.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_authentication_slug_rejection() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Slug Reject App",
|
||||
"slug-reject-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Authentication by slug MUST fail
|
||||
let slug_auth =
|
||||
applications::validate_client_credentials(&provider, "slug-reject-app", &secret).await;
|
||||
assert!(matches!(slug_auth, Err(AppError::Unauthorized)));
|
||||
|
||||
// Authentication by client_id MUST succeed
|
||||
let client_id_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &secret).await;
|
||||
assert!(client_id_auth.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_redirect_uri_structural_validation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
// 1. Malformed URI
|
||||
let malformed = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 1",
|
||||
"app-1",
|
||||
None,
|
||||
Some(vec!["not-a-valid-uri".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(malformed, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 2. Fragment URI
|
||||
let fragment = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 2",
|
||||
"app-2",
|
||||
None,
|
||||
Some(vec!["https://example.com/callback#frag".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(fragment, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 3. Userinfo URI
|
||||
let userinfo = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 3",
|
||||
"app-3",
|
||||
None,
|
||||
Some(vec!["https://user:pass@example.com/callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(userinfo, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 4. Non-loopback HTTP URI (must be rejected)
|
||||
let non_loopback_http = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 4",
|
||||
"app-4",
|
||||
None,
|
||||
Some(vec!["http://example.com/callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(non_loopback_http, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 5. Custom scheme (must be rejected)
|
||||
let custom_scheme = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 5",
|
||||
"app-5",
|
||||
None,
|
||||
Some(vec!["myapp://callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(custom_scheme, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 6. >10 URIs
|
||||
let too_many_uris: Vec<String> = (0..11)
|
||||
.map(|i| format!("https://example{i}.com/cb"))
|
||||
.collect();
|
||||
let too_many = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 6",
|
||||
"app-6",
|
||||
None,
|
||||
Some(too_many_uris),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(too_many, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 7. Valid URIs (https and http loopback)
|
||||
let valid = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Valid App",
|
||||
"valid-app",
|
||||
None,
|
||||
Some(vec![
|
||||
"https://app.example.com/callback".into(),
|
||||
"http://127.0.0.1:8080/callback".into(),
|
||||
"http://localhost:3000/callback".into(),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(valid.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nx9_auth::config::Config;
|
||||
use nx9_auth::runtime::{
|
||||
Application, HookRegistry, RuntimeState, ShutdownHook, ShutdownPriority, WorkerManager,
|
||||
Application, HookRegistry, Lifecycle, RuntimeState, ShutdownCoordinator, ShutdownHook,
|
||||
ShutdownPriority, WorkerManager,
|
||||
};
|
||||
|
||||
struct TestHook {
|
||||
name: &'static str,
|
||||
priority: ShutdownPriority,
|
||||
should_fail: bool,
|
||||
counter: Arc<AtomicUsize>,
|
||||
sequence: Arc<tokio::sync::Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
@@ -28,6 +30,9 @@ impl ShutdownHook for TestHook {
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let mut seq = self.sequence.lock().await;
|
||||
seq.push(self.name);
|
||||
if self.should_fail {
|
||||
anyhow::bail!("deliberate hook failure");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -55,18 +60,21 @@ async fn test_shutdown_hook_execution_order() {
|
||||
let hook_last = TestHook {
|
||||
name: "hook_last",
|
||||
priority: ShutdownPriority::Last,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_first = TestHook {
|
||||
name: "hook_first",
|
||||
priority: ShutdownPriority::First,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_normal = TestHook {
|
||||
name: "hook_normal",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
@@ -85,6 +93,74 @@ async fn test_shutdown_hook_execution_order() {
|
||||
assert_eq!(*seq, vec!["hook_first", "hook_normal", "hook_last"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_priority_hook_registration_order() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let sequence = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let hook_n1 = TestHook {
|
||||
name: "normal_1",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_n2 = TestHook {
|
||||
name: "normal_2",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_n3 = TestHook {
|
||||
name: "normal_3",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
|
||||
let mut registry = HookRegistry::new();
|
||||
registry.register(Box::new(hook_n1));
|
||||
registry.register(Box::new(hook_n2));
|
||||
registry.register(Box::new(hook_n3));
|
||||
|
||||
registry.execute_all().await;
|
||||
let seq = sequence.lock().await;
|
||||
assert_eq!(*seq, vec!["normal_1", "normal_2", "normal_3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hook_failure_resilience() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let sequence = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let failing_hook = TestHook {
|
||||
name: "failing_hook",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: true,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let succeeding_hook = TestHook {
|
||||
name: "succeeding_hook",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
|
||||
let mut registry = HookRegistry::new();
|
||||
registry.register(Box::new(failing_hook));
|
||||
registry.register(Box::new(succeeding_hook));
|
||||
|
||||
registry.execute_all().await;
|
||||
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
let seq = sequence.lock().await;
|
||||
assert_eq!(*seq, vec!["failing_hook", "succeeding_hook"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_manager_lifecycle() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
@@ -102,3 +178,154 @@ async fn test_worker_manager_lifecycle() {
|
||||
assert_eq!(mgr.active_tasks(), 0);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_live_forced_escalation_abort() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
let group = mgr.group("long-worker");
|
||||
|
||||
let worker_started = Arc::new(AtomicBool::new(false));
|
||||
let started = worker_started.clone();
|
||||
|
||||
group.spawn(async move {
|
||||
started.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
|
||||
// Wait for worker to begin execution
|
||||
while !worker_started.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 1);
|
||||
|
||||
let coord = ShutdownCoordinator::new();
|
||||
let coord_clone = coord.clone();
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
let shutdown_handle = tokio::spawn(async move {
|
||||
let mut m = mgr;
|
||||
m.shutdown_all_with_coordinator(Duration::from_secs(10), Some(&coord_clone))
|
||||
.await;
|
||||
m
|
||||
});
|
||||
|
||||
// Short delay to ensure shutdown_all is actively waiting
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
|
||||
// Trigger live second-signal forced escalation
|
||||
coord.cancel_forced();
|
||||
|
||||
let mgr_after = shutdown_handle.await.expect("shutdown task join");
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
assert_eq!(mgr_after.active_tasks(), 0);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1000),
|
||||
"Forced shutdown took {:?}, expected < 1s",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_global_deadline_budget_across_groups() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
mgr.group("group-a").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
mgr.group("group-b").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
mgr.group("group-c").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 3);
|
||||
|
||||
let start = Instant::now();
|
||||
mgr.shutdown_all(Duration::from_millis(200)).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 0);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(800),
|
||||
"Worker budget timeout across 3 groups took {:?}, expected single global deadline (~200ms)",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forced_http_draining_escalation() -> anyhow::Result<()> {
|
||||
use axum::routing::get;
|
||||
|
||||
let router = axum::Router::new().route(
|
||||
"/slow",
|
||||
get(|| async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
"done"
|
||||
}),
|
||||
);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.server.host = "127.0.0.1".to_string();
|
||||
config.server.port = 0;
|
||||
config.database.url = Some("sqlite::memory:".to_string());
|
||||
|
||||
let mut app = Application::builder(config).build().await?;
|
||||
app.router = Some(router);
|
||||
|
||||
let coord = app.shutdown_coordinator().clone();
|
||||
let state_ref = app.state.clone();
|
||||
let port_ref = app.bound_port.clone();
|
||||
|
||||
let app_task = tokio::spawn(async move { app.start().await });
|
||||
|
||||
// Wait for server task to bind and store bound_port
|
||||
while port_ref.load(Ordering::Acquire) == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
let port = port_ref.load(Ordering::Acquire);
|
||||
|
||||
// Send HTTP request to /slow in background task (will take 10s if not aborted)
|
||||
let req_task = tokio::spawn(async move {
|
||||
if let Ok(mut stream) = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stream
|
||||
.write_all(b"GET /slow HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
.await;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Short delay for request to arrive at server
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Trigger 1st signal (graceful shutdown)
|
||||
coord.cancel_graceful();
|
||||
|
||||
// Allow Tokio task executor to process cancellation and transition to Draining
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
// Verify RuntimeState is Draining while request is in-flight
|
||||
assert_eq!(state_ref.load(), RuntimeState::Draining);
|
||||
|
||||
// Trigger 2nd signal (forced escalation)
|
||||
let start = Instant::now();
|
||||
coord.cancel_forced();
|
||||
|
||||
let res = app_task.await?;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(res.is_ok());
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1000),
|
||||
"Forced HTTP shutdown took {:?}, expected < 1s",
|
||||
elapsed
|
||||
);
|
||||
|
||||
req_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user