kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README

commit fc7d5c43b0512c61d7a185b67d53b3ecf2b0548f
parent c7b6b7afa1c5377076a00640c615a2d5c379bee5
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Wed, 21 Jan 2026 10:48:52 +0100

tests: add unit and integration coverage

Unit tests in src/config.rs (server config validation + parsing helpers).
Unit tests in src/handlers.rs (URL/deeplink safety, accepted issuers parsing,
scope validation, presentation definition building).
CRUD tests in tests/db_integration.rs.
HTTP endpoint integration tests in tests/handlers_integration.rs.

Diffstat:
Mkych_oauth2_gateway/Cargo.toml | 4++++
Mkych_oauth2_gateway/src/config.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mkych_oauth2_gateway/src/handlers.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Akych_oauth2_gateway/tests/db_integration.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akych_oauth2_gateway/tests/handlers_integration.rs | 1873+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 2330 insertions(+), 19 deletions(-)

diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml @@ -57,3 +57,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chro # Templates askama = "0.12" + +[dev-dependencies] +tower = "0.5" +mockito = "1.5" diff --git a/kych_oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs @@ -261,3 +261,134 @@ fn parse_allowed_scopes(raw: &str) -> Result<Vec<String>> { Ok(scopes) } + +fn parse_bracketed_list(value: &str, field_name: &str) -> Result<Vec<String>> { + let trimmed = value.trim(); + if !trimmed.starts_with('{') || !trimmed.ends_with('}') { + anyhow::bail!("invalid {} format: expected {{item1, item2, ...}}", field_name); + } + let inner = &trimmed[1..trimmed.len() - 1]; + let items: Vec<String> = inner + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + Ok(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_validate_tcp_ok() { + let server = ServerConfig { + host: Some("127.0.0.1".to_string()), + port: Some(8080), + socket_path: None, + socket_mode: 0o666, + }; + + assert!(server.validate().is_ok()); + } + + #[test] + fn test_server_validate_unix_ok() { + let server = ServerConfig { + host: None, + port: None, + socket_path: Some("/tmp/kych.sock".to_string()), + socket_mode: 0o666, + }; + + assert!(server.validate().is_ok()); + } + + #[test] + fn test_server_validate_both_err() { + let server = ServerConfig { + host: Some("127.0.0.1".to_string()), + port: Some(8080), + socket_path: Some("/tmp/kych.sock".to_string()), + socket_mode: 0o666, + }; + + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validate_neither_err() { + let server = ServerConfig { + host: None, + port: None, + socket_path: None, + socket_mode: 0o666, + }; + + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validate_missing_port_err() { + let server = ServerConfig { + host: Some("127.0.0.1".to_string()), + port: None, + socket_path: None, + socket_mode: 0o666, + }; + + assert!(server.validate().is_err()); + } + + #[test] + fn test_parse_allowed_scopes_variants() { + let scopes = parse_allowed_scopes("{a, b c}").unwrap(); + assert_eq!(scopes, vec!["a", "b", "c"]); + + let scopes = parse_allowed_scopes(" a b c ").unwrap(); + assert_eq!(scopes, vec!["a", "b", "c"]); + + let scopes = parse_allowed_scopes("a,b,c").unwrap(); + assert_eq!(scopes, vec!["a", "b", "c"]); + } + + #[test] + fn test_parse_allowed_scopes_empty_err() { + assert!(parse_allowed_scopes("").is_err()); + assert!(parse_allowed_scopes(" ").is_err()); + assert!(parse_allowed_scopes("{}").is_err()); + assert!(parse_allowed_scopes("{ }").is_err()); + } + + #[test] + fn test_parse_bracketed_list_valid() { + let items = parse_bracketed_list("{a, b, c}", "TEST").unwrap(); + assert_eq!(items, vec!["a", "b", "c"]); + } + + #[test] + fn test_parse_bracketed_list_single_item() { + let items = parse_bracketed_list("{ES256}", "TEST").unwrap(); + assert_eq!(items, vec!["ES256"]); + } + + #[test] + fn test_parse_bracketed_list_extra_whitespace() { + let items = parse_bracketed_list("{ a , b }", "TEST").unwrap(); + assert_eq!(items, vec!["a", "b"]); + } + + #[test] + fn test_parse_bracketed_list_missing_braces() { + let result = parse_bracketed_list("a, b", "TEST"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("TEST")); + } + + #[test] + fn test_parse_bracketed_list_empty() { + let items = parse_bracketed_list("{}", "TEST").unwrap(); + assert!(items.is_empty()); + } +} diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -1177,27 +1177,103 @@ mod tests { use super::*; #[test] + fn test_is_safe_url() { + assert!(is_safe_url("https://example.com")); + assert!(is_safe_url("HTTPS://EXAMPLE.COM")); + assert!(!is_safe_url("http://example.com")); + assert!(!is_safe_url("javascript:alert(1)")); + } + + #[test] + fn test_is_safe_deeplink() { + assert!(is_safe_deeplink("")); + assert!(is_safe_deeplink("swiyu-verify://wallet/open")); + assert!(is_safe_deeplink("https://example.com/callback")); + assert!(!is_safe_deeplink("http://example.com")); + assert!(!is_safe_deeplink("file:///etc/passwd")); + } + + #[test] + fn test_json_encode_string() { + let raw = "a\"b"; + let encoded = json_encode_string(raw); + let expected = serde_json::to_string(raw).unwrap(); + assert_eq!(encoded, expected); + } + + #[test] + fn test_parse_accepted_issuer_dids() { + let dids = parse_accepted_issuer_dids("{did:example:1, did:example:2}").unwrap(); + assert_eq!(dids, vec!["did:example:1", "did:example:2"]); + + assert!(parse_accepted_issuer_dids("").is_err()); + assert!(parse_accepted_issuer_dids(" ").is_err()); + assert!(parse_accepted_issuer_dids("{ }").is_err()); + } + + #[test] + fn test_validate_scope_claims_valid() { + let valid_claims: HashSet<String> = ["family_name", "age_over_18"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(validate_scope_claims("family_name age_over_18", &valid_claims).is_ok()); + } + + #[test] + fn test_validate_scope_claims_invalid() { + let valid_claims: HashSet<String> = ["family_name", "age_over_18"] + .iter() + .map(|s| s.to_string()) + .collect(); + let result = validate_scope_claims("invalid_claim", &valid_claims); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("invalid_claim")); + } + + #[test] + fn test_validate_scope_claims_mixed() { + let valid_claims: HashSet<String> = ["family_name", "age_over_18"] + .iter() + .map(|s| s.to_string()) + .collect(); + let result = validate_scope_claims("family_name bogus", &valid_claims); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("bogus")); + } + + #[test] + fn test_validate_scope_claims_empty() { + let valid_claims: HashSet<String> = ["family_name"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(validate_scope_claims("", &valid_claims).is_ok()); + } + + #[test] fn test_build_presentation_definition_single_attribute() { let scope = "age_over_18"; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); - // Verify structure assert!(!pd.id.is_empty()); assert_eq!(pd.name, Some("Over 18 Verification".to_string())); assert_eq!(pd.input_descriptors.len(), 1); - // Verify fields: vct filter + requested attribute let fields = &pd.input_descriptors[0].constraints.fields; assert_eq!(fields.len(), 2); - // First field is vct with filter assert_eq!(fields[0].path, vec!["$.vct"]); assert!(fields[0].filter.is_some()); let filter = fields[0].filter.as_ref().unwrap(); assert_eq!(filter.filter_type, "string"); assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); - // Second field is the requested attribute assert_eq!(fields[1].path, vec!["$.age_over_18"]); assert!(fields[1].filter.is_none()); } @@ -1205,13 +1281,17 @@ mod tests { #[test] fn test_build_presentation_definition_multiple_attributes() { let scope = "first_name last_name date_of_birth"; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); let fields = &pd.input_descriptors[0].constraints.fields; - // vct + 3 attributes = 4 fields assert_eq!(fields.len(), 4); - assert_eq!(fields[0].path, vec!["$.vct"]); // vct first + assert_eq!(fields[0].path, vec!["$.vct"]); assert_eq!(fields[1].path, vec!["$.first_name"]); assert_eq!(fields[2].path, vec!["$.last_name"]); assert_eq!(fields[3].path, vec!["$.date_of_birth"]); @@ -1220,11 +1300,14 @@ mod tests { #[test] fn test_build_presentation_definition_extra_whitespace() { let scope = "first_name last_name"; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); let fields = &pd.input_descriptors[0].constraints.fields; - // split_whitespace handles multiple spaces correctly - // vct + 2 attributes = 3 fields assert_eq!(fields.len(), 3); assert_eq!(fields[0].path, vec!["$.vct"]); assert_eq!(fields[1].path, vec!["$.first_name"]); @@ -1234,10 +1317,14 @@ mod tests { #[test] fn test_build_presentation_definition_empty_scope() { let scope = ""; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); let fields = &pd.input_descriptors[0].constraints.fields; - // Only vct field when scope is empty assert_eq!(fields.len(), 1); assert_eq!(fields[0].path, vec!["$.vct"]); } @@ -1245,27 +1332,33 @@ mod tests { #[test] fn test_build_presentation_definition_no_top_level_format() { let scope = "age_over_18"; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); - // No format at top level assert!(pd.format.is_none()); } #[test] fn test_build_presentation_definition_input_descriptor_structure() { let scope = "age_over_18"; - let pd = build_presentation_definition(scope); + let pd = build_presentation_definition( + scope, + "betaid-sdjwt", + "vc+sd-jwt", + &["ES256".to_string()], + ); let descriptor = &pd.input_descriptors[0]; - // Verify descriptor has valid UUID assert!(!descriptor.id.is_empty()); - // Verify no name/purpose at descriptor level assert!(descriptor.name.is_none()); assert!(descriptor.purpose.is_none()); - // Verify format is specified at descriptor level assert!(descriptor.format.is_some()); let format = descriptor.format.as_ref().unwrap(); assert!(format.contains_key("vc+sd-jwt")); @@ -1273,4 +1366,27 @@ mod tests { assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); } + + #[test] + fn test_build_presentation_definition_custom_vc_config() { + let scope = "age_over_18"; + let pd = build_presentation_definition( + scope, + "custom-type", + "custom-format", + &["ES384".to_string(), "ES512".to_string()], + ); + + let filter = pd.input_descriptors[0].constraints.fields[0] + .filter + .as_ref() + .unwrap(); + assert_eq!(filter.const_value, Some("custom-type".to_string())); + + let format = pd.input_descriptors[0].format.as_ref().unwrap(); + assert!(format.contains_key("custom-format")); + let alg = &format["custom-format"]; + assert_eq!(alg.sd_jwt_alg_values, vec!["ES384", "ES512"]); + assert_eq!(alg.kb_jwt_alg_values, vec!["ES384", "ES512"]); + } } diff --git a/kych_oauth2_gateway/tests/db_integration.rs b/kych_oauth2_gateway/tests/db_integration.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use kych_oauth2_gateway_lib::db::{authorization_codes, clients, sessions, tokens}; +use serde_json::json; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use uuid::Uuid; + +async fn get_pool() -> Option<PgPool> { + let url = match std::env::var("DATABASE_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + eprintln!("DATABASE_URL not set; skipping DB integration tests."); + return None; + } + }; + + match PgPoolOptions::new().max_connections(5).connect(&url).await { + Ok(pool) => Some(pool), + Err(err) => { + eprintln!("Failed to connect to DATABASE_URL; skipping tests: {}", err); + None + } + } +} + +struct TestClient { + client: clients::Client, + secret: String, +} + +async fn create_test_client(pool: &PgPool) -> Result<TestClient> { + let suffix = Uuid::new_v4().to_string(); + let client_id = format!("test-client-{}", suffix); + let secret = format!("secret-{}", suffix); + let verifier_url = "https://verifier.example"; + let redirect_uri = "https://example.com/callback"; + let accepted_issuer_dids = Some("did:example:issuer1,did:example:issuer2"); + + let client = clients::register_client( + pool, + &client_id, + &secret, + verifier_url, + None, + redirect_uri, + accepted_issuer_dids, + ) + .await?; + + Ok(TestClient { client, secret }) +} + +#[tokio::test] +async fn test_clients_crud() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let test_client = create_test_client(&pool).await?; + let client = &test_client.client; + + let fetched = clients::get_client_by_id(&pool, &client.client_id).await?; + assert!(fetched.is_some()); + assert_eq!(fetched.as_ref().unwrap().id, client.id); + + let auth_ok = clients::authenticate_client(&pool, &client.client_id, "wrong-secret").await?; + assert!(auth_ok.is_none()); + + let auth_ok = clients::authenticate_client(&pool, &client.client_id, &test_client.secret) + .await?; + assert!(auth_ok.is_some()); + + let updated = clients::update_client( + &pool, + client.id, + Some("https://verifier.example/v2"), + Some("/management/api/verifications"), + Some("https://example.com/redirect2"), + None, + ) + .await?; + assert_eq!(updated.redirect_uri, "https://example.com/redirect2"); + + let all = clients::list_clients(&pool).await?; + assert!(all.iter().any(|c| c.id == client.id)); + + let deleted = clients::delete_client(&pool, client.id).await?; + assert!(deleted); + + Ok(()) +} + +#[tokio::test] +async fn test_sessions_codes_tokens_flow() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let test_client = create_test_client(&pool).await?; + let client = &test_client.client; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + assert_eq!(session.nonce, nonce); + + let scope = "first_name last_name"; + let redirect_uri = "https://example.com/callback"; + let state = "state-123"; + let authorize = sessions::get_session_for_authorize( + &pool, + &nonce, + &client.client_id, + scope, + redirect_uri, + state, + ) + .await? + .expect("session should exist"); + assert_eq!(authorize.scope, scope); + assert_eq!(authorize.redirect_uri.as_deref(), Some(redirect_uri)); + assert_eq!(authorize.state.as_deref(), Some(state)); + + let verification_url = "https://verifier.example/verify/1"; + let request_id = Uuid::new_v4().to_string(); + let authorized = sessions::update_session_authorized( + &pool, + authorize.session_id, + verification_url, + None, + &request_id, + None, + ) + .await?; + assert_eq!(authorized.request_id, request_id); + + let authorization_code = format!("code-{}", Uuid::new_v4()); + let issued_code = sessions::verify_session_and_issue_code( + &pool, + authorize.session_id, + sessions::SessionStatus::Verified, + &authorization_code, + 10, + client.id, + "", + Some(&json!({"vc": "data"})), + ) + .await?; + assert_eq!(issued_code, authorization_code); + + let code_row = authorization_codes::get_code_by_session(&pool, authorize.session_id) + .await? + .expect("code should exist"); + assert!(!code_row.used); + + let exchange = authorization_codes::get_code_for_token_exchange(&pool, &authorization_code) + .await? + .expect("code should be exchangeable"); + assert!(!exchange.was_already_used); + assert_eq!(exchange.session_status, sessions::SessionStatus::Verified); + assert!(exchange.existing_token.is_none()); + + let exchange_again = authorization_codes::get_code_for_token_exchange(&pool, &authorization_code) + .await? + .expect("code still exists"); + assert!(exchange_again.was_already_used); + + let token_value = format!("token-{}", Uuid::new_v4()); + let token = tokens::create_token_and_complete_session( + &pool, + authorize.session_id, + &token_value, + 60, + ) + .await?; + assert_eq!(token.token, token_value); + + let token_data = tokens::get_token_with_session(&pool, &token_value) + .await? + .expect("token should exist"); + assert!(!token_data.revoked); + assert_eq!(token_data.session_status, sessions::SessionStatus::Completed); + + let status_data = sessions::get_session_for_status(&pool, &request_id) + .await? + .expect("status should exist"); + assert_eq!(status_data.status, sessions::SessionStatus::Completed); + + let _ = clients::delete_client(&pool, client.id).await?; + Ok(()) +} diff --git a/kych_oauth2_gateway/tests/handlers_integration.rs b/kych_oauth2_gateway/tests/handlers_integration.rs @@ -0,0 +1,1873 @@ +use anyhow::Result; +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + routing::{get, post}, + Router, +}; +use kych_oauth2_gateway_lib::{ + config::{ClientConfig, Config, CryptoConfig, DatabaseConfig, ServerConfig, VcConfig}, + db::{authorization_codes, clients, sessions}, + handlers, + models::{ + Constraint, Field, Filter, InputDescriptor, PresentationDefinition, SwiyuManagementResponse, + SwiyuVerificationStatus, TokenResponse, + }, + state::AppState, +}; +use std::collections::HashSet; +use mockito::Server; +use serde_json::Value; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use tower::util::ServiceExt; +use uuid::Uuid; + +async fn get_pool() -> Option<PgPool> { + let url = match std::env::var("DATABASE_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + eprintln!("DATABASE_URL not set; skipping handler integration tests."); + return None; + } + }; + + match PgPoolOptions::new().max_connections(5).connect(&url).await { + Ok(pool) => Some(pool), + Err(err) => { + eprintln!("Failed to connect to DATABASE_URL; skipping tests: {}", err); + None + } + } +} + +fn test_config(database_url: &str) -> Config { + Config { + server: ServerConfig { + host: Some("127.0.0.1".to_string()), + port: Some(8080), + socket_path: None, + socket_mode: 0o666, + }, + database: DatabaseConfig { + url: database_url.to_string(), + }, + crypto: CryptoConfig { + nonce_bytes: 32, + token_bytes: 32, + authorization_code_bytes: 32, + authorization_code_ttl_minutes: 10, + }, + vc: VcConfig { + vc_type: "betaid-sdjwt".to_string(), + vc_format: "vc+sd-jwt".to_string(), + vc_algorithms: vec!["ES256".to_string()], + vc_claims: [ + "first_name", + "last_name", + "family_name", + "given_name", + "birth_date", + "age_over_18", + ] + .iter() + .map(|s| s.to_string()) + .collect::<HashSet<String>>(), + }, + allowed_scopes: None, + clients: Vec::<ClientConfig>::new(), + } +} + +fn build_app(state: AppState) -> Router { + Router::new() + .route("/health", get(handlers::health_check)) + .route("/setup/{client_id}", post(handlers::setup)) + .route("/authorize/{nonce}", get(handlers::authorize)) + .route("/token", post(handlers::token)) + .route("/info", get(handlers::info)) + .route("/notification", post(handlers::notification_webhook)) + .route("/status/{verification_id}", get(handlers::status)) + .route("/finalize/{verification_id}", get(handlers::finalize)) + .with_state(state) +} + +struct TestClient { + client: clients::Client, + secret: String, +} + +async fn create_test_client(pool: &PgPool) -> Result<TestClient> { + let suffix = Uuid::new_v4().to_string(); + let client_id = format!("test-client-{}", suffix); + let secret = format!("secret-{}", suffix); + let verifier_url = "https://verifier.example"; + let redirect_uri = "https://example.com/callback"; + let accepted_issuer_dids = Some("did:example:issuer1,did:example:issuer2"); + + let client = clients::register_client( + pool, + &client_id, + &secret, + verifier_url, + None, + redirect_uri, + accepted_issuer_dids, + ) + .await?; + + Ok(TestClient { client, secret }) +} + +async fn create_test_client_with_verifier( + pool: &PgPool, + verifier_url: &str, +) -> Result<TestClient> { + let suffix = Uuid::new_v4().to_string(); + let client_id = format!("test-client-{}", suffix); + let secret = format!("secret-{}", suffix); + let redirect_uri = "https://example.com/callback"; + let accepted_issuer_dids = Some("did:example:issuer1,did:example:issuer2"); + + let client = clients::register_client( + pool, + &client_id, + &secret, + verifier_url, + None, + redirect_uri, + accepted_issuer_dids, + ) + .await?; + + Ok(TestClient { client, secret }) +} + +async fn create_second_client(pool: &PgPool) -> Result<TestClient> { + let suffix = Uuid::new_v4().to_string(); + let client_id = format!("test-client-b-{}", suffix); + let secret = format!("secret-b-{}", suffix); + let verifier_url = "https://verifier.example"; + let redirect_uri = "https://example.com/callback"; + let accepted_issuer_dids = Some("did:example:issuer1,did:example:issuer2"); + + let client = clients::register_client( + pool, + &client_id, + &secret, + verifier_url, + None, + redirect_uri, + accepted_issuer_dids, + ) + .await?; + + Ok(TestClient { client, secret }) +} + +fn sample_presentation_definition() -> PresentationDefinition { + PresentationDefinition { + id: "pd-1".to_string(), + name: None, + purpose: None, + format: None, + input_descriptors: vec![InputDescriptor { + id: "descriptor-1".to_string(), + name: None, + purpose: None, + format: None, + constraints: Constraint { + fields: vec![Field { + path: vec!["$.vct".to_string()], + id: None, + name: None, + purpose: None, + filter: Some(Filter { + filter_type: "string".to_string(), + const_value: Some("betaid-sdjwt".to_string()), + }), + }], + }, + }], + } +} + +async fn get_session_status(pool: &PgPool, session_id: Uuid) -> Result<sessions::SessionStatus> { + let status = sqlx::query_scalar::<_, sessions::SessionStatus>( + r#" + SELECT status + FROM oauth2gw.verification_sessions + WHERE id = $1 + "#, + ) + .bind(session_id) + .fetch_one(pool) + .await?; + + Ok(status) +} + +struct SessionData { + client: clients::Client, + secret: String, + request_id: String, + redirect_uri: String, + state: String, + authorization_code: String, +} + +async fn setup_session_with_status( + pool: &PgPool, + status: sessions::SessionStatus, +) -> Result<SessionData> { + let test_client = create_test_client(pool).await?; + let client = &test_client.client; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(pool, &client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let redirect_uri = "https://example.com/callback".to_string(); + let state = "state-123".to_string(); + let authorize = sessions::get_session_for_authorize( + pool, + &nonce, + &client.client_id, + "first_name", + &redirect_uri, + &state, + ) + .await? + .expect("session should exist"); + + let request_id = Uuid::new_v4().to_string(); + let _ = sessions::update_session_authorized( + pool, + authorize.session_id, + "https://verifier.example/verify/1", + None, + &request_id, + None, + ) + .await?; + + let authorization_code = format!("code-{}", Uuid::new_v4()); + let issued = sessions::verify_session_and_issue_code( + pool, + session.id, + status, + &authorization_code, + 10, + client.id, + "", + Some(&serde_json::json!({"vc": "data"})), + ) + .await?; + assert_eq!(issued, authorization_code); + + Ok(SessionData { + client: client.clone(), + secret: test_client.secret, + request_id, + redirect_uri, + state, + authorization_code, + }) +} + +async fn setup_authorized_session( + pool: &PgPool, + verifier_url: &str, +) -> Result<(TestClient, Uuid, Uuid)> { + let test_client = create_test_client_with_verifier(pool, verifier_url).await?; + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4(); + let _ = sessions::update_session_authorized( + pool, + session.id, + "https://verifier.example/verify/1", + None, + &verification_id.to_string(), + None, + ) + .await?; + + Ok((test_client, session.id, verification_id)) +} + +fn form_body(pairs: &[(&str, &str)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::<Vec<_>>() + .join("&") +} + +async fn assert_error_response( + response: axum::response::Response, + status: StatusCode, + expected_error: &str, +) -> Result<()> { + assert_eq!(response.status(), status); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + let error = json.get("error").and_then(|v| v.as_str()).unwrap_or(""); + assert_eq!(error, expected_error); + Ok(()) +} + +#[tokio::test] +async fn test_health_check() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool)); + + let response = app + .oneshot(Request::builder().uri("/health").body(Body::empty())?) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + Ok(()) +} + +#[tokio::test] +async fn test_setup_unauthorized() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool)); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/setup/unknown-client") + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::UNAUTHORIZED, "unauthorized").await?; + Ok(()) +} + +#[tokio::test] +async fn test_setup_success() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client(&pool).await?; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/setup/{}", test_client.client.client_id)) + .header("authorization", format!("Bearer {}", test_client.secret)) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + let nonce = json + .get("nonce") + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert!(!nonce.is_empty()); + assert!(nonce.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_success_and_info() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let token: TokenResponse = serde_json::from_slice(&bytes)?; + assert!(!token.access_token.is_empty()); + assert_eq!(token.token_type, "Bearer"); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/info") + .header("authorization", format!("Bearer {}", token.access_token)) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + assert_eq!(json.get("vc").and_then(|v| v.as_str()), Some("data")); + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_redirect_uri_mismatch() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", "https://example.com/wrong"), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_grant").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_status_and_finalize() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/status/{}?state={}", + session.request_id, session.state + )) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + assert_eq!(json.get("status").and_then(|v| v.as_str()), Some("verified")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/finalize/{}?state={}", + session.request_id, session.state + )) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::FOUND); + let location = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(location.contains("code=")); + assert!(location.contains("state=")); + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_status_invalid_state() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/status/{}?state={}", + session.request_id, "wrong-state" + )) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::FORBIDDEN, "invalid_state").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_status_not_found() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/status/{}?state=state", Uuid::new_v4())) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::NOT_FOUND, "session_not_found").await?; + Ok(()) +} + +#[tokio::test] +async fn test_finalize_invalid_state() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/finalize/{}?state={}", + session.request_id, "wrong-state" + )) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::FORBIDDEN, "invalid_state").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_finalize_not_verified() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Failed).await?; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/finalize/{}?state={}", + session.request_id, session.state + )) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "not_verified").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_finalize_not_found() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/finalize/{}?state=state", Uuid::new_v4())) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::NOT_FOUND, "session_not_found").await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_invalid_grant_type() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let form = form_body(&[ + ("grant_type", "client_credentials"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "unsupported_grant_type").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_invalid_client() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", "wrong-secret"), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::UNAUTHORIZED, "invalid_client").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_used_code_rejected() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let _ = authorization_codes::get_code_for_token_exchange(&pool, &session.authorization_code) + .await? + .expect("code should exist"); + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_grant").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_wrong_session_status() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Failed).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_grant").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_info_missing_authorization() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/info") + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::UNAUTHORIZED, "invalid_token").await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_code_for_different_client() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + let other_client = create_second_client(&pool).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &other_client.client.client_id), + ("client_secret", &other_client.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_grant").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + let _ = clients::delete_client(&pool, other_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_token_expired_code() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + sqlx::query( + r#" + UPDATE oauth2gw.authorization_codes + SET expires_at = NOW() - INTERVAL '1 minute' + WHERE code = $1 + "#, + ) + .bind(&session.authorization_code) + .execute(&pool) + .await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_grant").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_info_revoked_token() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let session = setup_session_with_status(&pool, sessions::SessionStatus::Verified).await?; + + let form = form_body(&[ + ("grant_type", "authorization_code"), + ("code", &session.authorization_code), + ("client_id", &session.client.client_id), + ("client_secret", &session.secret), + ("redirect_uri", &session.redirect_uri), + ]); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let token: TokenResponse = serde_json::from_slice(&bytes)?; + + sqlx::query( + r#" + UPDATE oauth2gw.access_tokens + SET revoked = TRUE, revoked_at = NOW() + WHERE token = $1 + "#, + ) + .bind(&token.access_token) + .execute(&pool) + .await?; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/info") + .header("authorization", format!("Bearer {}", token.access_token)) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::UNAUTHORIZED, "invalid_token").await?; + + let _ = clients::delete_client(&pool, session.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_success() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4(); + let response_body = SwiyuManagementResponse { + id: verification_id, + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Pending, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + let verification_id_str = json + .get("verificationId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(verification_id_str, verification_id.to_string()); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_html_response() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4(); + let response_body = SwiyuManagementResponse { + id: verification_id, + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Pending, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .header("accept", "text/html") + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(content_type.contains("text/html")); + let csp = response + .headers() + .get("content-security-policy") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(csp.contains("default-src 'self'")); + assert!(csp.contains("script-src 'self' 'unsafe-inline'")); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let body = String::from_utf8(bytes.to_vec())?; + assert!(body.contains("Identity Verification")); + assert!(body.contains("https://verifier.example/verify/1")); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_invalid_verification_url() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let response_body = SwiyuManagementResponse { + id: Uuid::new_v4(), + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Pending, + verification_url: "http://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_GATEWAY, "invalid_verification_url").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_invalid_verification_deeplink() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let response_body = SwiyuManagementResponse { + id: Uuid::new_v4(), + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Pending, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("ftp://bad.example".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_GATEWAY, "invalid_verification_deeplink").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_cached_html_response() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4().to_string(); + let _ = sessions::update_session_authorized( + &pool, + session.id, + "https://verifier.example/verify/1", + Some("swiyu-verify://verify/1"), + &verification_id, + None, + ) + .await?; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .header("accept", "text/html") + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(content_type.contains("text/html")); + let csp = response + .headers() + .get("content-security-policy") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(csp.contains("default-src 'self'")); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let body = String::from_utf8(bytes.to_vec())?; + assert!(body.contains("Identity Verification")); + assert!(body.contains("https://verifier.example/verify/1")); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_invalid_redirect_uri() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, "https://example.com/wrong", "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_redirect_uri").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_session_expired() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + sqlx::query( + r#" + UPDATE oauth2gw.verification_sessions + SET expires_at = NOW() - INTERVAL '1 minute' + WHERE id = $1 + "#, + ) + .bind(session.id) + .execute(&pool) + .await?; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::GONE, "session_expired").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_invalid_response_type() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let uri = format!( + "/authorize/{}?response_type=token&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_request").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_invalid_scope() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + + let mut config = test_config(&std::env::var("DATABASE_URL")?); + config.allowed_scopes = Some(vec!["first_name".to_string(), "last_name".to_string()]); + + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=invalid_scope", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_REQUEST, "invalid_scope").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_session_status_conflict() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + sqlx::query( + r#" + UPDATE oauth2gw.verification_sessions + SET status = 'completed' + WHERE id = $1 + "#, + ) + .bind(session.id) + .execute(&pool) + .await?; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::CONFLICT, "invalid_session_status").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_verifier_error() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(500) + .with_header("content-type", "application/json") + .with_body("{\"error\":\"boom\"}") + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_GATEWAY, "verifier_error").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_verifier_invalid_json() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let _session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let _mock = server + .mock("POST", "/management/api/verifications") + .with_status(200) + .with_header("content-type", "application/json") + .with_body("not-json") + .create_async() + .await; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_error_response(response, StatusCode::BAD_GATEWAY, "verifier_invalid_response").await?; + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_idempotent_cached_response() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let verifier_url = "https://verifier.example".to_string(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4().to_string(); + let _ = sessions::update_session_authorized( + &pool, + session.id, + "https://verifier.example/verify/1", + Some("swiyu-verify://verify/1"), + &verification_id, + None, + ) + .await?; + + let uri = format!( + "/authorize/{}?response_type=code&client_id={}&redirect_uri={}&state={}&scope=first_name", + nonce, test_client.client.client_id, test_client.client.redirect_uri, "state-123" + ); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + let json: Value = serde_json::from_slice(&bytes)?; + let verification_id_str = json + .get("verificationId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(verification_id_str, verification_id); + let verification_url = json + .get("verification_url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(verification_url, "https://verifier.example/verify/1"); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_notification_success() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let test_client = create_test_client_with_verifier(&pool, &verifier_url).await?; + + let nonce = format!("nonce-{}", Uuid::new_v4()); + let session = sessions::create_session(&pool, &test_client.client.client_id, &nonce, 5) + .await? + .expect("client should exist"); + + let verification_id = Uuid::new_v4(); + let _ = sessions::update_session_authorized( + &pool, + session.id, + "https://verifier.example/verify/1", + None, + &verification_id.to_string(), + None, + ) + .await?; + + let response_body = SwiyuManagementResponse { + id: verification_id, + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Success, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: Some(serde_json::json!({"vc": "data"})), + }; + let response_json = serde_json::to_string(&response_body)?; + + let path = format!("/management/api/verifications/{}", verification_id); + let _mock = server + .mock("GET", path.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let webhook = serde_json::json!({ + "verification_id": verification_id, + "timestamp": "2025-01-01T00:00:00Z", + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/notification") + .header("content-type", "application/json") + .body(Body::from(webhook.to_string()))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let status = get_session_status(&pool, session.id).await?; + assert_eq!(status, sessions::SessionStatus::Verified); + + let code = authorization_codes::get_code_by_session(&pool, session.id) + .await? + .expect("authorization code should exist"); + assert!(!code.code.is_empty()); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_notification_pending_ignored() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let (test_client, session_id, verification_id) = + setup_authorized_session(&pool, &verifier_url).await?; + + let response_body = SwiyuManagementResponse { + id: verification_id, + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Pending, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let path = format!("/management/api/verifications/{}", verification_id); + let _mock = server + .mock("GET", path.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let webhook = serde_json::json!({ + "verification_id": verification_id, + "timestamp": "2025-01-01T00:00:00Z", + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/notification") + .header("content-type", "application/json") + .body(Body::from(webhook.to_string()))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let status = get_session_status(&pool, session_id).await?; + assert_eq!(status, sessions::SessionStatus::Authorized); + let code = authorization_codes::get_code_by_session(&pool, session_id).await?; + assert!(code.is_none()); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_notification_verifier_error() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let (test_client, session_id, verification_id) = + setup_authorized_session(&pool, &verifier_url).await?; + + let path = format!("/management/api/verifications/{}", verification_id); + let _mock = server + .mock("GET", path.as_str()) + .with_status(500) + .with_header("content-type", "application/json") + .with_body("{\"error\":\"boom\"}") + .create_async() + .await; + + let webhook = serde_json::json!({ + "verification_id": verification_id, + "timestamp": "2025-01-01T00:00:00Z", + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/notification") + .header("content-type", "application/json") + .body(Body::from(webhook.to_string()))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let status = get_session_status(&pool, session_id).await?; + assert_eq!(status, sessions::SessionStatus::Authorized); + let code = authorization_codes::get_code_by_session(&pool, session_id).await?; + assert!(code.is_none()); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_notification_verifier_invalid_json() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let (test_client, session_id, verification_id) = + setup_authorized_session(&pool, &verifier_url).await?; + + let path = format!("/management/api/verifications/{}", verification_id); + let _mock = server + .mock("GET", path.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("not-json") + .create_async() + .await; + + let webhook = serde_json::json!({ + "verification_id": verification_id, + "timestamp": "2025-01-01T00:00:00Z", + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/notification") + .header("content-type", "application/json") + .body(Body::from(webhook.to_string()))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let status = get_session_status(&pool, session_id).await?; + assert_eq!(status, sessions::SessionStatus::Authorized); + let code = authorization_codes::get_code_by_session(&pool, session_id).await?; + assert!(code.is_none()); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +} + +#[tokio::test] +async fn test_notification_failed_verification() -> Result<()> { + let Some(pool) = get_pool().await else { return Ok(()); }; + + let mut server = Server::new_async().await; + let verifier_url = server.url(); + let config = test_config(&std::env::var("DATABASE_URL")?); + let app = build_app(AppState::new(config, pool.clone())); + + let (test_client, session_id, verification_id) = + setup_authorized_session(&pool, &verifier_url).await?; + + let response_body = SwiyuManagementResponse { + id: verification_id, + request_nonce: Some("req-nonce".to_string()), + state: SwiyuVerificationStatus::Failed, + verification_url: "https://verifier.example/verify/1".to_string(), + verification_deeplink: Some("swiyu-verify://verify/1".to_string()), + presentation_definition: sample_presentation_definition(), + dcql_query: None, + wallet_response: None, + }; + let response_json = serde_json::to_string(&response_body)?; + + let path = format!("/management/api/verifications/{}", verification_id); + let _mock = server + .mock("GET", path.as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_json) + .create_async() + .await; + + let webhook = serde_json::json!({ + "verification_id": verification_id, + "timestamp": "2025-01-01T00:00:00Z", + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/notification") + .header("content-type", "application/json") + .body(Body::from(webhook.to_string()))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + + let status = get_session_status(&pool, session_id).await?; + assert_eq!(status, sessions::SessionStatus::Failed); + + let _ = clients::delete_client(&pool, test_client.client.id).await?; + Ok(()) +}