kych

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

commit 6193344eebe378e5203de89387d5d73b1f52e2d1
parent 1bb77bee12268b5102f1dfa5ab7ad065cf83c3b4
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon,  3 Nov 2025 23:15:10 +0100

Merge branch 'oauth2'

Diffstat:
Moauth2_gateway/Cargo.toml | 6++++--
Moauth2_gateway/config.example.ini | 5++---
Moauth2_gateway/scripts/setup_test_db.sh | 10+++++++++-
Aoauth2_gateway/scripts/teardown_test_db.sh | 40++++++++++++++++++++++++++++++++++++++++
Aoauth2_gateway/scripts/test_integration.sh | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Moauth2_gateway/src/config.rs | 8++++----
Moauth2_gateway/src/db/sessions.rs | 2+-
Moauth2_gateway/src/handlers.rs | 312++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Moauth2_gateway/src/models.rs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Moauth2_gateway/tests/api_tests.rs | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Aoauth2_gateway/tests/db_tests.rs | 729+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mswiyu-verifier/api_requests/post_management_api_verifications.sh | 21++++++++++++++++++---
12 files changed, 1564 insertions(+), 83 deletions(-)

diff --git a/oauth2_gateway/Cargo.toml b/oauth2_gateway/Cargo.toml @@ -49,4 +49,6 @@ base64 = "0.22.1" sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } [dev-dependencies] -tempfile = "3.8" -\ No newline at end of file +tempfile = "3.8" +wiremock = "0.6" +serial_test = "3.2" +\ No newline at end of file diff --git a/oauth2_gateway/config.example.ini b/oauth2_gateway/config.example.ini @@ -5,8 +5,8 @@ [server] host = 127.0.0.1 -port = 8080 +port = 9090 [database] # PostgreSQL connection string -url = postgresql://oauth2gw:password@localhost/oauth2gw -\ No newline at end of file +url = postgresql://oauth2gw:password@localhost/oauth2gw diff --git a/oauth2_gateway/scripts/setup_test_db.sh b/oauth2_gateway/scripts/setup_test_db.sh @@ -59,6 +59,14 @@ for patch_file in "$MIGRATIONS_DIR"/oauth2gw-*.sql; do fi done +# Grant schema privileges (minimum required for gateway to operate) +echo "Granting schema privileges..." +$PSQL_CMD -d "$DB_NAME" -c "GRANT USAGE ON SCHEMA oauth2gw TO $DB_USER;" +$PSQL_CMD -d "$DB_NAME" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA oauth2gw TO $DB_USER;" +$PSQL_CMD -d "$DB_NAME" -c "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA oauth2gw TO $DB_USER;" +$PSQL_CMD -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA oauth2gw GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $DB_USER;" +$PSQL_CMD -d "$DB_NAME" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA oauth2gw GRANT USAGE, SELECT ON SEQUENCES TO $DB_USER;" + # Seed test data echo "Seeding test data..." $PSQL_CMD -d "$DB_NAME" <<EOF @@ -66,7 +74,7 @@ INSERT INTO oauth2gw.clients (client_id, client_secret, notification_url, verifi VALUES ( 'test-exchange-001', 'test-secret-123', - 'http://localhost:9000/kyc/webhook', + 'http://localhost:9090/kyc/webhook', 'http://localhost:8080', '/management/api/verifications' ) diff --git a/oauth2_gateway/scripts/teardown_test_db.sh b/oauth2_gateway/scripts/teardown_test_db.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +DB_PORT=5432 +DB_NAME=oauth2gw +DB_USER=oauth2gw +DB_ADMIN=${DB_ADMIN:-} + +echo "Tearing down OAuth2 Gateway test database..." +echo +echo "WARNING: This will destroy all data!" +echo + +if ! command -v psql &> /dev/null +then + echo "psql could not be found, please install PostgreSQL first." + exit 1 +fi + +if ! pg_isready -h localhost -p "$DB_PORT" >/dev/null 2>&1; then + echo "PostgreSQL is not running." + exit 1 +fi + +PSQL_CMD="psql -h localhost -p $DB_PORT" +if [ -n "$DB_ADMIN" ]; then + PSQL_CMD="$PSQL_CMD -U $DB_ADMIN" +fi + +echo "Dropping oauth2gw schema and unregistering all patches..." +MIGRATIONS_DIR="$(dirname "$0")/../migrations" +$PSQL_CMD -d "$DB_NAME" -f "$MIGRATIONS_DIR/drop.sql" + +echo +echo "Teardown completed." +echo +echo "Schema dropped. Database '$DB_NAME' still exists but is empty." +echo "Run scripts/setup_test_db.sh to rebuild schema." +echo +echo "To completely remove the database, run:" +echo " psql -h localhost -p $DB_PORT -d postgres -c \"DROP DATABASE $DB_NAME;\"" diff --git a/oauth2_gateway/scripts/test_integration.sh b/oauth2_gateway/scripts/test_integration.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -e + +GATEWAY_URL="http://localhost:9090" +CLIENT_ID="test-exchange-001" +SCOPE="age_over_18" +QR_CODE_FILE="oauth2gw_qr_code.png" + +echo "================================================================" +echo "OAuth2 Gateway Integration Test" +echo "================================================================" +echo "" +echo "Prerequisites:" +echo " - OAuth2 Gateway running at $GATEWAY_URL" +echo " - Swiyu Verifier running" +echo " - Test database seeded with test-exchange-001 client" +echo " - qrencode installed (for QR code generation)" +echo "" +echo "================================================================" +echo "" + +echo "[1/2] Testing /setup endpoint..." +SETUP_RESPONSE=$(curl -s -X POST "$GATEWAY_URL/setup/$CLIENT_ID" \ + -H "Content-Type: application/json" \ + -d "{\"scope\": \"$SCOPE\"}") + +echo "Response: $SETUP_RESPONSE" + +NONCE=$(echo "$SETUP_RESPONSE" | jq -r '.nonce') +if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then + echo "FAILED: No nonce returned" + exit 1 +fi +echo "SUCCESS: Received nonce: $NONCE" +echo "" + +echo "[2/2] Testing /authorize endpoint..." +AUTHORIZE_RESPONSE=$(curl -s -X GET "$GATEWAY_URL/authorize/$NONCE") + +echo "Response: $AUTHORIZE_RESPONSE" +echo "" + +VERIFICATION_URL=$(echo "$AUTHORIZE_RESPONSE" | jq -r '.verification_url') +VERIFICATION_ID=$(echo "$AUTHORIZE_RESPONSE" | jq -r '.verificationId') + +if [ -z "$VERIFICATION_URL" ] || [ "$VERIFICATION_URL" = "null" ]; then + echo "FAILED: No verification_url returned" + exit 1 +fi + +if [ -z "$VERIFICATION_ID" ] || [ "$VERIFICATION_ID" = "null" ]; then + echo "FAILED: No verificationId returned" + exit 1 +fi + +echo "SUCCESS: Received verification URL: $VERIFICATION_URL" +echo "SUCCESS: Received verification ID: $VERIFICATION_ID" +echo "" + +echo "Generating QR code..." +echo "$VERIFICATION_URL" | tee /dev/tty | xargs qrencode -o "$QR_CODE_FILE" +echo "QR code saved to: $QR_CODE_FILE" +echo "" + +open "$QR_CODE_FILE" + +echo "================================================================" +echo "Integration test completed successfully" +echo "================================================================" diff --git a/oauth2_gateway/src/config.rs b/oauth2_gateway/src/config.rs @@ -37,7 +37,7 @@ impl Config { .to_string(), port: server_section .get("port") - .unwrap_or("8080") + .unwrap_or("9090") .parse() .context("Invalid port")?, }; @@ -108,7 +108,7 @@ url = postgresql://localhost/oauth2gw let config = Config::from_file(temp_file.path()).unwrap(); assert_eq!(config.server.host, "127.0.0.1"); - assert_eq!(config.server.port, 8080); + assert_eq!(config.server.port, 9090); } #[test] @@ -136,7 +136,7 @@ url = postgresql://localhost/oauth2gw r#" [server] host = 127.0.0.1 -port = 8080 +port = 9090 "# ) .unwrap(); @@ -154,7 +154,7 @@ port = 8080 r#" [server] host = 127.0.0.1 -port = 8080 +port = 9090 [database] # url is missing diff --git a/oauth2_gateway/src/db/sessions.rs b/oauth2_gateway/src/db/sessions.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc, Duration}; /// Status of a verification session #[derive(Debug, Clone, sqlx::Type, serde::Serialize, serde::Deserialize, PartialEq)] -#[sqlx(type_name = "text")] +#[sqlx(type_name = "varchar")] pub enum SessionStatus { #[sqlx(rename = "pending")] Pending, diff --git a/oauth2_gateway/src/handlers.rs b/oauth2_gateway/src/handlers.rs @@ -23,39 +23,228 @@ pub async fn health_check() -> impl IntoResponse { // POST /setup/{clientId} pub async fn setup( - State(_state): State<AppState>, + State(state): State<AppState>, Path(client_id): Path<String>, Json(request): Json<SetupRequest>, -) -> impl IntoResponse { +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { tracing::info!("Setup request for client: {}, scope: {}", client_id, request.scope); + // Look up client in database + let client = crate::db::clients::get_client_by_id(&state.pool, &client_id) + .await + .map_err(|e| { + tracing::error!("Database error looking up client {}: {}", client_id, e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + let client = match client { + Some(c) => c, + None => { + tracing::warn!("Client not found: {}", client_id); + return Err((StatusCode::NOT_FOUND, Json(ErrorResponse::new("client_not_found")))); + } + }; + + tracing::debug!("Found client: {} (UUID: {})", client.client_id, client.id); + + // Generate cryptographically secure nonce let nonce = crypto::generate_nonce(); - tracing::info!("Generated nonce: {}", nonce); - - ( - StatusCode::OK, - Json(SetupResponse { nonce }) + + // Create verification session in database + // TODO: Should this be transactional? + let _session = crate::db::sessions::create_session( + &state.pool, + client.id, + &nonce, + &request.scope, + 15, // 15 minutes expiration ) + .await + .map_err(|e| { + tracing::error!("Failed to create session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::info!("Created session for client {} with nonce {}", client_id, nonce); + + Ok((StatusCode::OK, Json(SetupResponse { nonce }))) } // GET /authorize/{nonce} pub async fn authorize( - State(_state): State<AppState>, + State(state): State<AppState>, Path(nonce): Path<String>, -) -> impl IntoResponse { +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { tracing::info!("Authorize request for nonce: {}", nonce); - // TODO: Validate nonce - // TODO: Call the SwiyuVerifier to generate the QR code/verification URL + // Look up session by nonce + let session = crate::db::sessions::get_session_by_nonce(&state.pool, &nonce) + .await + .map_err(|e| { + tracing::error!("Database error looking up session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + let session = match session { + Some(js) => js, + None => { + tracing::warn!("Session not found for nonce: {}", nonce); + return Err((StatusCode::NOT_FOUND, Json(ErrorResponse::new("session_not_found")))); + } + }; + + // Validate pending state + if session.status != crate::db::sessions::SessionStatus::Pending { + tracing::warn!("Session {} is not in pending state: {:?}", session.id, session.status); + return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse::new("invalid_session_state")))); + } + + // Check if session expired + let now = chrono::Utc::now(); + if now > session.expires_at { + tracing::warn!("Session {} has expired", session.id); + return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse::new("session_expired")))); + } + + // Look up client + let client = crate::db::clients::get_client_by_uuid(&state.pool, session.client_id) + .await + .map_err(|e| { + tracing::error!("Database error looking up client: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })? + .ok_or_else(|| { + tracing::error!("Client {} not found for session {}", session.client_id, session.id); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::debug!("Found client {} for session {}", client.client_id, session.id); + + // Build presentation definition from scope + let presentation_definition = build_presentation_definition(&session.scope); + + // Build Swiyu API request + // Note: response_mode, presentation_definition, configuration_override are REQUIRED + let swiyu_request = SwiyuCreateVerificationRequest { + accepted_issuer_dids: None, // Accept all issuers by default + trust_anchors: None, // No trust anchors by default + jwt_secured_authorization_request: Some(true), // Beta requires true for JWT-signed requests + response_mode: ResponseMode::DirectPost, // REQUIRED + presentation_definition, // REQUIRED + configuration_override: ConfigurationOverride::default(), // REQUIRED (empty is OK) + dcql_query: None, // Using Presentation Exchange, not DCQL + }; + + // Call Swiyu Verifier API + let swiyu_url = format!("{}{}", client.verifier_base_url, client.verifier_management_api_path); + tracing::info!("Calling Swiyu Verifier API: {}", swiyu_url); + + let http_client = reqwest::Client::new(); + let swiyu_response = http_client + .post(&swiyu_url) + .json(&swiyu_request) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to call Swiyu Verifier API: {}", e); + (StatusCode::SERVICE_UNAVAILABLE, Json(ErrorResponse::new("verifier_unavailable"))) + })?; + + if !swiyu_response.status().is_success() { + let status = swiyu_response.status(); + let error_body = swiyu_response.text().await.unwrap_or_default(); + tracing::error!("Swiyu Verifier returned error {}: {}", status, error_body); + return Err((StatusCode::BAD_GATEWAY, Json(ErrorResponse::new("verifier_error")))); + } + + let swiyu_verification: SwiyuVerificationResponse = swiyu_response + .json() + .await + .map_err(|e| { + tracing::error!("Failed to parse Swiyu response: {}", e); + (StatusCode::BAD_GATEWAY, Json(ErrorResponse::new("verifier_invalid_response"))) + })?; + + tracing::info!( + "Created Swiyu verification: id={}, url={}", + swiyu_verification.id, + swiyu_verification.verification_url + ); + + // Update session with verification data + crate::db::sessions::update_session_authorized( + &state.pool, + session.id, + &swiyu_verification.verification_url, + &swiyu_verification.id.to_string(), + ) + .await + .map_err(|e| { + tracing::error!("Failed to update session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse::new("internal_error"))) + })?; + + tracing::info!("Session {} updated with verification data", session.id); - // For now, return a mock response let response = AuthorizeResponse { - verification_id: uuid::Uuid::new_v4(), - verification_url: format!("swiyu://verify?nonce={}", nonce), + verification_id: swiyu_verification.id, + verification_url: swiyu_verification.verification_url, }; - - (StatusCode::OK, Json(response)) + + Ok((StatusCode::OK, Json(response))) +} + +/// Build a presentation definition from a space-delimited scope string +/// +/// Example: "first_name last_name date_of_birth" +fn build_presentation_definition(scope: &str) -> PresentationDefinition { + use uuid::Uuid; + use std::collections::HashMap; + + // Parse scope into individual attributes + let attributes: Vec<&str> = scope.split_whitespace().collect(); + + tracing::debug!("Building presentation definition for attributes: {:?}", attributes); + + // Create a field for each attribute + let fields: Vec<Field> = attributes + .iter() + .map(|attr| Field { + path: vec![format!("$.{}", attr)], + id: None, + name: Some(attr.to_string()), + purpose: None, + filter: None, + }) + .collect(); + + // Create format specification for SD-JWT with ES256 + let mut format = HashMap::new(); + format.insert( + "vc+sd-jwt".to_string(), + FormatAlgorithm { + sd_jwt_alg_values: vec!["ES256".to_string()], + kb_jwt_alg_values: vec!["ES256".to_string()], + }, + ); + + // Build input descriptor + let input_descriptor = InputDescriptor { + id: Uuid::new_v4().to_string(), + name: Some("Requested credentials".to_string()), + purpose: Some("KYC verification via OAuth2 Gateway".to_string()), + format: Some(format.clone()), + constraints: Constraint { fields }, + }; + + PresentationDefinition { + id: Uuid::new_v4().to_string(), + name: Some("OAuth2 Gateway KYC Verification".to_string()), + purpose: Some("Verify user credentials for Taler Exchange".to_string()), + format: Some(format), + input_descriptors: vec![input_descriptor], + } } // POST /token @@ -73,7 +262,6 @@ pub async fn token( } // TODO: Validate nonce/code - // TODO: Change to cryptographically secure token let access_token = crypto::generate_nonce(); @@ -128,3 +316,93 @@ pub async fn notification_webhook( StatusCode::OK } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_presentation_definition_single_attribute() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + // Verify structure + assert!(!pd.id.is_empty()); + assert_eq!(pd.name, Some("OAuth2 Gateway KYC Verification".to_string())); + assert_eq!(pd.input_descriptors.len(), 1); + + // Verify fields + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[0].name, Some("first_name".to_string())); + } + + #[test] + fn test_build_presentation_definition_multiple_attributes() { + let scope = "first_name last_name date_of_birth"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 3); + + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[1].path, vec!["$.last_name"]); + assert_eq!(fields[2].path, vec!["$.date_of_birth"]); + } + + #[test] + fn test_build_presentation_definition_extra_whitespace() { + let scope = "first_name last_name"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + // split_whitespace handles multiple spaces correctly + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].path, vec!["$.first_name"]); + assert_eq!(fields[1].path, vec!["$.last_name"]); + } + + #[test] + fn test_build_presentation_definition_empty_scope() { + let scope = ""; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 0); + } + + #[test] + fn test_build_presentation_definition_format() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + // Verify format is present + assert!(pd.format.is_some()); + let format = pd.format.unwrap(); + + // Verify vc+sd-jwt format + assert!(format.contains_key("vc+sd-jwt")); + let alg = &format["vc+sd-jwt"]; + assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); + assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); + } + + #[test] + fn test_build_presentation_definition_input_descriptor_structure() { + let scope = "first_name"; + let pd = build_presentation_definition(scope); + + let descriptor = &pd.input_descriptors[0]; + + // Verify descriptor has valid UUID + assert!(!descriptor.id.is_empty()); + + // Verify descriptor has proper metadata + assert_eq!(descriptor.name, Some("Requested credentials".to_string())); + assert_eq!(descriptor.purpose, Some("KYC verification via OAuth2 Gateway".to_string())); + + // Verify format is specified at descriptor level too + assert!(descriptor.format.is_some()); + } +} diff --git a/oauth2_gateway/src/models.rs b/oauth2_gateway/src/models.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use std::collections::HashMap; // Setup endpoint #[derive(Debug, Deserialize, Serialize)] @@ -65,3 +66,150 @@ impl ErrorResponse { } } } + +// Swiyu Verifier API models + +/// Request body for creating a verification with Swiyu Verifier +/// POST /management/api/verifications +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuCreateVerificationRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub accepted_issuer_dids: Option<Vec<String>>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_anchors: Option<Vec<TrustAnchor>>, + + /// If omitted, Swiyu defaults to true (beta requires true) + #[serde(skip_serializing_if = "Option::is_none")] + pub jwt_secured_authorization_request: Option<bool>, + + /// Response mode: how the wallet sends the response back + /// REQUIRED - will throw NullPointerException if omitted + pub response_mode: ResponseMode, + + pub presentation_definition: PresentationDefinition, + + pub configuration_override: ConfigurationOverride, + + /// Optional - (wallet migration in progress) + #[serde(skip_serializing_if = "Option::is_none")] + pub dcql_query: Option<serde_json::Value>, +} + +/// Trust anchor for credential verification +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TrustAnchor { + pub did: String, + pub trust_registry_uri: String, +} + +/// Response mode type +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMode { + /// Wallet sends a clear text response + DirectPost, + + /// Wallet sends an encrypted response + #[serde(rename = "direct_post.jwt")] + DirectPostJwt, +} + +/// Configuration override for a specific verification +/// Can be empty object with all fields set to None +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConfigurationOverride { + /// Override for the EXTERNAL_URL - the url the wallet should call + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verifier_did: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_method: Option<String>, + + /// ID of the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option<String>, + + /// The pin which protects the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_pin: Option<String>, +} + +/// Presentation Definition according to DIF Presentation Exchange +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PresentationDefinition { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub input_descriptors: Vec<InputDescriptor>, +} + +/// Input descriptor describing required credential attributes +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InputDescriptor { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub constraints: Constraint, +} + +/// Constraints on credential fields +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Constraint { + pub fields: Vec<Field>, +} + +/// Field specification with JSONPath +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Field { + pub path: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option<Filter>, +} + +/// Filter for field constraints +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Filter { + #[serde(rename = "type")] + pub filter_type: String, + #[serde(rename = "const", skip_serializing_if = "Option::is_none")] + pub const_value: Option<String>, +} + +/// Format algorithm specification for SD-JWT +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FormatAlgorithm { + #[serde(rename = "sd-jwt_alg_values")] + pub sd_jwt_alg_values: Vec<String>, + #[serde(rename = "kb-jwt_alg_values")] + pub kb_jwt_alg_values: Vec<String>, +} + +/// Response from Swiyu Verifier after creating verification +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuVerificationResponse { + #[serde(rename = "verificationId")] + pub id: Uuid, + pub verification_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_deeplink: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option<String>, +} diff --git a/oauth2_gateway/tests/api_tests.rs b/oauth2_gateway/tests/api_tests.rs @@ -2,25 +2,68 @@ use axum::{routing::*, Router}; use axum_test::TestServer; use oauth2_gateway::{config::*, db, handlers, models::*, state::AppState}; use serde_json::json; +use wiremock::{MockServer, Mock, ResponseTemplate}; +use wiremock::matchers::{method, path}; +use serial_test::serial; -/// Create a test app with mock configuration -/// Note: These tests use handlers that return mock data (no real DB operations yet) -async fn create_test_app() -> Router { + +// API endpoint tests with Mocked Swiyu API and Database + +/// Helper to get test database URL +fn get_test_database_url() -> String { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost:5432/oauth2gw".to_string()) +} + +/// Helper to setup test database and clean data +async fn setup_test_db() -> sqlx::PgPool { + let pool = db::create_pool(&get_test_database_url()) + .await + .expect("Failed to connect to test database"); + clean_test_data(&pool).await; + pool +} + +/// Clean all test data (in correct FK order) +async fn clean_test_data(pool: &sqlx::PgPool) { + sqlx::query("DELETE FROM oauth2gw.notification_logs") + .execute(pool) + .await + .expect("Failed to clean notification_logs"); + sqlx::query("DELETE FROM oauth2gw.webhook_logs") + .execute(pool) + .await + .expect("Failed to clean webhook_logs"); + sqlx::query("DELETE FROM oauth2gw.access_tokens") + .execute(pool) + .await + .expect("Failed to clean access_tokens"); + sqlx::query("DELETE FROM oauth2gw.verification_sessions") + .execute(pool) + .await + .expect("Failed to clean verification_sessions"); + sqlx::query("DELETE FROM oauth2gw.clients") + .execute(pool) + .await + .expect("Failed to clean clients"); +} + +/// Helper to teardown test database +async fn teardown_test_db(pool: &sqlx::PgPool) { + clean_test_data(pool).await; +} + +/// Create test app with custom verifier base URL (for mock server) +async fn create_test_app(pool: sqlx::PgPool) -> Router { let config = Config { server: ServerConfig { host: "127.0.0.1".to_string(), - port: 8080, + port: 9090, }, database: DatabaseConfig { - // Use a test db - url: std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgresql://localhost/oauth2gw_test".to_string()), + url: get_test_database_url(), }, }; - - let pool = db::create_pool(&config.database.url) - .await - .expect("Failed to connect to test database"); let state = AppState::new(config, pool); @@ -35,85 +78,234 @@ async fn create_test_app() -> Router { } #[tokio::test] -async fn test_health_check() { - let app = create_test_app().await; +#[serial] +async fn test_setup_with_real_database() { + let pool = setup_test_db().await; + + db::clients::register_client( + &pool, + "test-client-1", + "secret123", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .expect("Failed to register client"); + + let app = create_test_app(pool.clone()).await; let server = TestServer::new(app).unwrap(); - let response = server.get("/health").await; + let response = server + .post("/setup/test-client-1") + .json(&json!({"scope": "first_name last_name date_of_birth"})) + .await; response.assert_status_ok(); - response.assert_json(&json!({ - "status": "healthy", - "service": "oauth2-gateway", - // "version": env!("CARGO_PKG_VERSION") - })); + let body: SetupResponse = response.json(); + assert!(!body.nonce.is_empty()); + + let session = db::sessions::get_session_by_nonce(&pool, &body.nonce) + .await + .expect("Failed to query session") + .expect("Session not found"); + + assert_eq!(session.scope, "first_name last_name date_of_birth"); + assert_eq!(session.status, db::sessions::SessionStatus::Pending); + + teardown_test_db(&pool).await; } #[tokio::test] -async fn test_setup_endpoint() { - let app = create_test_app().await; +#[serial] +async fn test_setup_with_nonexistent_client() { + let pool = setup_test_db().await; + let app = create_test_app(pool.clone()).await; let server = TestServer::new(app).unwrap(); let response = server - .post("/setup/test-client") - .json(&json!({ - "scope": "first_name last_name age_over_18" - })) + .post("/setup/nonexistent-client") + .json(&json!({"scope": "test"})) .await; - response.assert_status_ok(); + response.assert_status(axum::http::StatusCode::NOT_FOUND); + let body: ErrorResponse = response.json(); + assert_eq!(body.error, "client_not_found"); - // Check response has a nonce - let body: SetupResponse = response.json(); - assert!(!body.nonce.is_empty()); - println!("Generated nonce: {}", body.nonce); + teardown_test_db(&pool).await; } #[tokio::test] -async fn test_setup_different_clients() { - let app = create_test_app().await; +#[serial] +async fn test_authorize_successful_flow_with_mocked_swiyu() { + let pool = setup_test_db().await; + + let mock_server = MockServer::start().await; + + db::clients::register_client( + &pool, + "test-client-2", + "secret123", + "https://client.example.com/notify", + &mock_server.uri(), // Use mock verifier + Some("/management/api/verifications"), + ) + .await + .expect("Failed to register client"); + + // mock Swiyu response + Mock::given(method("POST")) + .and(path("/management/api/verifications")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "verificationId": "550e8400-e29b-41d4-a716-446655440000", + "verification_url": "https://wallet.example.com/verify?request=abc123", + "verification_deeplink": "swiyu://verify/abc123", + "state": "PENDING" + }))) + .mount(&mock_server) + .await; + + let app = create_test_app(pool.clone()).await; let server = TestServer::new(app).unwrap(); - let response1 = server - .post("/setup/client-1") - .json(&json!({"scope": "first_name"})) + let setup_response = server + .post("/setup/test-client-2") + .json(&json!({"scope": "first_name last_name"})) + .await; + + setup_response.assert_status_ok(); + let setup: SetupResponse = setup_response.json(); + + // Call authorize + let response = server + .get(&format!("/authorize/{}", setup.nonce)) .await; - let response2 = server - .post("/setup/client-2") - .json(&json!({"scope": "last_name"})) + response.assert_status_ok(); + let body: AuthorizeResponse = response.json(); + + assert_eq!(body.verification_url, "https://wallet.example.com/verify?request=abc123"); + assert_eq!(body.verification_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); + + let session = db::sessions::get_session_by_nonce(&pool, &setup.nonce) + .await + .expect("Failed to query session") + .expect("Session not found"); + + assert_eq!(session.status, db::sessions::SessionStatus::Authorized); + assert_eq!(session.verification_url, Some("https://wallet.example.com/verify?request=abc123".to_string())); + assert_eq!(session.request_id, Some("550e8400-e29b-41d4-a716-446655440000".to_string())); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_authorize_with_invalid_nonce() { + let pool = setup_test_db().await; + let app = create_test_app(pool.clone()).await; + let server = TestServer::new(app).unwrap(); + + let response = server + .get("/authorize/invalid-nonce-12345") .await; - response1.assert_status_ok(); - response2.assert_status_ok(); + response.assert_status(axum::http::StatusCode::NOT_FOUND); + let body: ErrorResponse = response.json(); + assert_eq!(body.error, "session_not_found"); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_authorize_with_expired_session() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "test-client-3", + "secret123", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .expect("Failed to register client"); + + // Create expired session (negative expiration) + sqlx::query( + r#" + INSERT INTO oauth2gw.verification_sessions (client_id, nonce, scope, expires_at, status) + VALUES ($1, $2, $3, CURRENT_TIMESTAMP - INTERVAL '1 hour', 'pending') + "# + ) + .bind(client.id) + .bind("expired-nonce") + .bind("test") + .execute(&pool) + .await + .expect("Failed to create expired session"); - let nonce1: SetupResponse = response1.json(); - let nonce2: SetupResponse = response2.json(); + let app = create_test_app(pool.clone()).await; + let server = TestServer::new(app).unwrap(); - assert_ne!(nonce1.nonce, nonce2.nonce); + let response = server + .get("/authorize/expired-nonce") + .await; + + response.assert_status(axum::http::StatusCode::BAD_REQUEST); + let body: ErrorResponse = response.json(); + assert_eq!(body.error, "session_expired"); + + teardown_test_db(&pool).await; } #[tokio::test] -async fn test_authorize_endpoint() { - let app = create_test_app().await; +#[serial] +async fn test_authorize_with_swiyu_api_error() { + let pool = setup_test_db().await; + let mock_server = MockServer::start().await; + + + db::clients::register_client( + &pool, + "test-client-4", + "secret123", + "https://client.example.com/notify", + &mock_server.uri(), + Some("/management/api/verifications"), + ) + .await + .expect("Failed to register client"); + + // mock Swiyu response + Mock::given(method("POST")) + .and(path("/management/api/verifications")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": "internal_server_error" + }))) + .mount(&mock_server) + .await; + + let app = create_test_app(pool.clone()).await; let server = TestServer::new(app).unwrap(); - // Get a nonce from setup + // Setup and authorize let setup_response = server - .post("/setup/test-client") + .post("/setup/test-client-4") .json(&json!({"scope": "test"})) .await; let setup: SetupResponse = setup_response.json(); - // Authorize with that nonce let response = server .get(&format!("/authorize/{}", setup.nonce)) .await; - response.assert_status_ok(); + response.assert_status(axum::http::StatusCode::BAD_GATEWAY); + let body: ErrorResponse = response.json(); + assert_eq!(body.error, "verifier_error"); - let body: AuthorizeResponse = response.json(); - assert!(!body.verification_url.is_empty()); - println!("Verification URL: {}", body.verification_url); + teardown_test_db(&pool).await; } \ No newline at end of file diff --git a/oauth2_gateway/tests/db_tests.rs b/oauth2_gateway/tests/db_tests.rs @@ -0,0 +1,729 @@ +// Database integration tests for OAuth2 Gateway +// +// These tests require a PostgreSQL database to be running and already migrated. +// Run scripts/setup_test_db.sh before running tests. +// Set the TEST_DATABASE_URL environment variable or use the default. + +use oauth2_gateway::db; +use sqlx::PgPool; +use serial_test::serial; + +// Test database URL - can be overridden with TEST_DATABASE_URL env var +fn get_test_database_url() -> String { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgresql://oauth2gw:password@localhost/oauth2gw".to_string()) +} + +/// Setup test database: create pool and clean existing data +async fn setup_test_db() -> PgPool { + let database_url = get_test_database_url(); + let pool = db::create_pool(&database_url) + .await + .expect("Failed to connect to test database"); + + // Clean up test data (but keep schema) + clean_test_data(&pool).await; + + pool +} + +/// Clean all data from tables (but keep schema) +async fn clean_test_data(pool: &PgPool) { + // Delete in order to respect foreign key constraints + let _ = sqlx::query("DELETE FROM oauth2gw.notification_logs").execute(pool).await; + let _ = sqlx::query("DELETE FROM oauth2gw.webhook_logs").execute(pool).await; + let _ = sqlx::query("DELETE FROM oauth2gw.access_tokens").execute(pool).await; + let _ = sqlx::query("DELETE FROM oauth2gw.verification_sessions").execute(pool).await; + let _ = sqlx::query("DELETE FROM oauth2gw.clients").execute(pool).await; +} + +/// Teardown test database: clean test data +async fn teardown_test_db(pool: &PgPool) { + clean_test_data(pool).await; +} + +// ============================================================================ +// Test 1: Client Management +// ============================================================================ + +#[tokio::test] +#[serial] +async fn test_client_registration() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "test-client-1", + "secret123", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .expect("Failed to register client"); + + assert_eq!(client.client_id, "test-client-1"); + assert_eq!(client.client_secret, "secret123"); + assert_eq!(client.notification_url, "https://client.example.com/notify"); + assert_eq!(client.verifier_base_url, "https://verifier.example.com"); + assert_eq!(client.verifier_management_api_path, "/management/api/verifications"); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_client_lookup() { + let pool = setup_test_db().await; + + let registered_client = db::clients::register_client( + &pool, + "lookup-test-client", + "secret456", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + // Lookup by client_id + let found_client = db::clients::get_client_by_id(&pool, "lookup-test-client") + .await + .unwrap() + .expect("Client not found"); + + assert_eq!(found_client.id, registered_client.id); + assert_eq!(found_client.client_id, "lookup-test-client"); + + // Lookup by UUID + let found_by_uuid = db::clients::get_client_by_uuid(&pool, registered_client.id) + .await + .unwrap() + .expect("Client not found by UUID"); + + assert_eq!(found_by_uuid.client_id, "lookup-test-client"); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_client_authentication() { + let pool = setup_test_db().await; + + db::clients::register_client( + &pool, + "auth-test-client", + "correct-password", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + // Authenticate with correct password + let auth_success = db::clients::authenticate_client(&pool, + "auth-test-client", + "correct-password") + .await + .unwrap(); + assert!(auth_success.is_some(), "Authentication should succeed"); + + // Authenticate with wrong password + let auth_fail = db::clients::authenticate_client(&pool, + "auth-test-client", + "wrong-password") + .await + .unwrap(); + assert!(auth_fail.is_none(), "Authentication should fail with wrong password"); + + // Authenticate non-existent client + let auth_not_found = db::clients::authenticate_client(&pool, + "non-existent", + "password") + .await + .unwrap(); + assert!(auth_not_found.is_none(), "Authentication should fail for non-existent client"); + + teardown_test_db(&pool).await; +} + +// ============================================================================ +// Test 3: Verification Session Lifecycle +// ============================================================================ + +#[tokio::test] +#[serial] +async fn test_session_creation() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "session-test-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + // Create session + let session = db::sessions::create_session( + &pool, + client.id, + "test-nonce-123", + "first_name last_name age_over_18", + 15, // 15 minutes expiration + ) + .await + .expect("Failed to create session"); + + assert_eq!(session.nonce, "test-nonce-123"); + assert_eq!(session.scope, "first_name last_name age_over_18"); + assert_eq!(session.status, db::sessions::SessionStatus::Pending); + assert!(session.verification_url.is_none()); + assert!(session.request_id.is_none()); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_session_lookup_by_nonce() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "nonce-lookup-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let created_session = db::sessions::create_session( + &pool, + client.id, + "unique-nonce-456", + "scope", + 15, + ) + .await + .unwrap(); + + // Lookup by nonce + let found_session = db::sessions::get_session_by_nonce(&pool, + "unique-nonce-456") + .await + .unwrap() + .expect("Session not found"); + + assert_eq!(found_session.id, created_session.id); + assert_eq!(found_session.nonce, "unique-nonce-456"); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_session_status_transitions() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "status-test-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "status-nonce-789", + "scope", + 15, + ) + .await + .unwrap(); + + // Initial status: pending + assert_eq!(session.status, db::sessions::SessionStatus::Pending); + + // Transition to authorized + db::sessions::update_session_authorized( + &pool, + session.id, + "https://verifier.example.com/verify?request=abc", + "swiyu-request-id-123", + ) + .await + .unwrap(); + + let updated = db::sessions::get_session_by_nonce(&pool, + "status-nonce-789") + .await + .unwrap() + .unwrap(); + assert_eq!(updated.status, db::sessions::SessionStatus::Authorized); + assert_eq!(updated.verification_url.unwrap(), "https://verifier.example.com/verify?request=abc"); + assert_eq!(updated.request_id.unwrap(), "swiyu-request-id-123"); + assert!(updated.authorized_at.is_some()); + + // Transition to verified + db::sessions::mark_session_verified(&pool, session.id) + .await + .unwrap(); + + let verified = db::sessions::get_session_by_nonce(&pool, + "status-nonce-789") + .await + .unwrap() + .unwrap(); + assert_eq!(verified.status, db::sessions::SessionStatus::Verified); + assert!(verified.verified_at.is_some()); + + // Transition to completed + db::sessions::mark_session_completed(&pool, session.id) + .await + .unwrap(); + + let completed = db::sessions::get_session_by_nonce(&pool, + "status-nonce-789") + .await + .unwrap() + .unwrap(); + assert_eq!(completed.status, db::sessions::SessionStatus::Completed); + assert!(completed.completed_at.is_some()); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_session_lookup_by_request_id() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "request-id-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "request-id-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + // Update with request_id + db::sessions::update_session_authorized( + &pool, + session.id, + "https://verify.url", + "swiyu-request-xyz", + ) + .await + .unwrap(); + + // Lookup by request_id + let found = db::sessions::get_session_by_request_id(&pool, "swiyu-request-xyz") + .await + .unwrap() + .expect("Session not found by request_id"); + + assert_eq!(found.id, session.id); + assert_eq!(found.request_id.unwrap(), "swiyu-request-xyz"); + + teardown_test_db(&pool).await; +} + +// ============================================================================ +// Test 4: Access Token Management +// ============================================================================ + +#[tokio::test] +#[serial] +async fn test_access_token_creation() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "token-test-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "token-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + // Create access token + let token = db::tokens::create_access_token( + &pool, + session.id, + "bearer-token-abc123", + 3600, // 1 hour + ) + .await + .unwrap(); + + assert_eq!(token.token, "bearer-token-abc123"); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.session_id, session.id); + assert_eq!(token.revoked, false); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_access_token_verification() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "verify-token-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "verify-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + // Create token + db::tokens::create_access_token( + &pool, + session.id, + "valid-token-xyz", + 3600, + ) + .await + .unwrap(); + + // Verify valid token + let verified = db::tokens::verify_access_token(&pool, + "valid-token-xyz") + .await + .unwrap(); + assert!(verified.is_some(), "Token should be valid"); + + // Verify non-existent token + let not_found = db::tokens::verify_access_token(&pool, + "non-existent-token") + .await + .unwrap(); + assert!(not_found.is_none(), "Token should not be found"); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_access_token_revocation() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "revoke-token-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "revoke-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + db::tokens::create_access_token( + &pool, + session.id, + "token-to-revoke", + 3600, + ) + .await + .unwrap(); + + // Token should be valid initially + let valid = db::tokens::verify_access_token(&pool, "token-to-revoke") + .await + .unwrap(); + assert!(valid.is_some()); + + // Revoke token + let revoked = db::tokens::revoke_token(&pool, "token-to-revoke") + .await + .unwrap(); + assert!(revoked, "Token should be revoked"); + + // Token should be invalid after revocation + let invalid = db::tokens::verify_access_token(&pool, "token-to-revoke") + .await + .unwrap(); + assert!(invalid.is_none(), "Revoked token should be invalid"); + + teardown_test_db(&pool).await; +} + +// ============================================================================ +// Test 5: Audit Logging +// ============================================================================ + +#[tokio::test] +#[serial] +async fn test_webhook_logging() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "webhook-log-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "webhook-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + let payload = serde_json::json!({ + "nonce": "webhook-nonce", + "verification_complete": true + }); + + // Log webhook received + let log_id = db::logs::log_webhook_received( + &pool, + Some("swiyu-req-123"), + Some(session.id), + &payload, + ) + .await + .unwrap(); + + // Mark as processed + db::logs::mark_webhook_processed(&pool, log_id, 200) + .await + .unwrap(); + + // Query logs + let logs = db::logs::get_webhook_logs_for_session(&pool, session.id) + .await + .unwrap(); + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].processed, true); + assert_eq!(logs[0].status_code.unwrap(), 200); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_notification_logging() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "notif-log-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "notif-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + let payload = serde_json::json!({ + "nonce": "notif-nonce", + "verification_complete": true + }); + + // Log notification sent + let log_id = db::logs::log_notification_sent( + &pool, + session.id, + client.id, + "https://client.example.com/notify", + &payload, + ) + .await + .unwrap(); + + // Mark as successful + db::logs::mark_notification_success(&pool, log_id, 200) + .await + .unwrap(); + + // Query logs + let logs = db::logs::get_notification_logs_for_session(&pool, session.id) + .await + .unwrap(); + + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].success, true); + assert_eq!(logs[0].status_code.unwrap(), 200); + + teardown_test_db(&pool).await; +} + +// ============================================================================ +// Test 6: Garbage Collection +// ============================================================================ + +#[tokio::test] +#[serial] +async fn test_expired_session_marking() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "gc-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + // Create session with negative expiration (already expired) + db::sessions::create_session( + &pool, + client.id, + "expired-nonce", + "scope", + -10, // Expired 10 minutes ago + ) + .await + .unwrap(); + + // Mark expired sessions + let marked = db::sessions::mark_expired_sessions(&pool) + .await + .unwrap(); + + assert!(marked > 0, "Should mark at least one session as expired"); + + // Verify session is marked as expired + let session = db::sessions::get_session_by_nonce(&pool, "expired-nonce") + .await + .unwrap() + .unwrap(); + assert_eq!(session.status, db::sessions::SessionStatus::Expired); + + teardown_test_db(&pool).await; +} + +#[tokio::test] +#[serial] +async fn test_old_session_deletion() { + let pool = setup_test_db().await; + + let client = db::clients::register_client( + &pool, + "delete-gc-client", + "secret", + "https://client.example.com/notify", + "https://verifier.example.com", + None, + ) + .await + .unwrap(); + + let session = db::sessions::create_session( + &pool, + client.id, + "old-nonce", + "scope", + 15, + ) + .await + .unwrap(); + + // Mark as completed + db::sessions::mark_session_completed(&pool, session.id) + .await + .unwrap(); + + // Delete sessions older than 0 days (should delete everything) + let deleted = db::sessions::delete_old_sessions(&pool, 0) + .await + .unwrap(); + + assert!(deleted > 0, "Should delete at least one session"); + + // Verify session is deleted + let not_found = db::sessions::get_session_by_nonce(&pool, "old-nonce") + .await + .unwrap(); + assert!(not_found.is_none(), "Session should be deleted"); + + teardown_test_db(&pool).await; +} diff --git a/swiyu-verifier/api_requests/post_management_api_verifications.sh b/swiyu-verifier/api_requests/post_management_api_verifications.sh @@ -10,12 +10,27 @@ if [ $# -eq 0 ]; then exit 1 fi +# Determine the input file (add .json if not present and file doesn't exist) +if [ -f "$1" ]; then + input_file="$1" +elif [ -f "${1}.json" ]; then + input_file="${1}.json" +else + echo "Error: File '$1' or '${1}.json' not found" + exit 1 +fi + +base_name="${input_file%.json}" + +response_file="${base_name}_response.json" +qr_code_file="${base_name}_qr_code.png" + curl -X POST http://localhost:8080/management/api/verifications \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ - -d @"$1" \ - | jq -r '.verification_url' | tee /dev/tty | xargs qrencode -o qr_code.png + -d @"$input_file" \ + | tee "$response_file" | jq -r '.verification_url' | tee /dev/tty | xargs qrencode -o "$qr_code_file" # open .png with default image viewer app -open qr_code.png +open "$qr_code_file"