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:
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(())
+}