//! Connecting (SPEC §9.12 CP-1, CP-2, CP-6, CP-13; E-36 to E-39): the catalogue, the attempt, the JWT, the //! callback page for success, cancel, unknown, replayed and expired `state`, the account pick, renewal. use axum::http::Method; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use kvitt_server::auth::sha256_hex; use serde_json::{Value, json}; use crate::common::*; use crate::stub::eb_error; use crate::support::*; #[tokio::test] async fn the_catalogue_says_who_can_connect() { let app = CardsApp::build(true, false).await; let admin = app .call(Method::GET, "/cards/providers", &admin_token(), None) .await; assert_like_example(&admin.json, "E-36-card-providers/response.json", &[]); let member = app .call(Method::GET, "/cards/providers", &member_token(), None) .await; assert_eq!( member.json["providers"][0]["available"], false, "CP-13: no application for the member" ); let refused = app.start_auth(&member_token(), 30, "android").await; assert_error(&refused, 409, "cards_unavailable"); assert!(app.stub.requests().is_empty(), "nothing asked of the bank"); } #[tokio::test] async fn starting_an_attempt_asks_the_bank_with_the_cp_2_body_and_a_signed_jwt() { let app = CardsApp::new().await; let reply = app.start_auth(&admin_token(), 30, "android").await; assert_eq!(reply.status, 201, "{}", reply.text()); assert_like_example( &reply.json, "E-37-card-auth/response.json", &["id", "expires_at"], ); assert_eq!( reply.json["expires_at"], "2026-10-17T12:15:00.000Z", "15 minutes" ); let auth = app.stub.requests_to("/auth").pop().unwrap(); let body = auth.json(); let state = body["state"].as_str().unwrap().to_owned(); assert_eq!(state.len(), 43, "32 random bytes, base64url"); assert!( state .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') ); assert_eq!( body, json!({ "access": {"valid_until": "2027-04-15T11:55:00+00:00"}, "aspsp": {"name": "SJ Prio Mastercard", "country": "SE"}, "state": state, "redirect_url": "https://kvitt.example.invalid/cards/callback", "psu_type": "personal" }), "valid_until = now + min(180 days, the bank's maximum) - 5 minutes (CP-6)" ); // The JWT: RS256, kid = the application id, iss and aud of Enable Banking. let jwt = auth .header("authorization") .unwrap() .strip_prefix("Bearer ") .unwrap() .to_owned(); let header = jsonwebtoken::decode_header(&jwt).unwrap(); assert_eq!( (header.alg, header.kid.as_deref()), (Algorithm::RS256, Some(APP_ID)) ); let mut validation = Validation::new(Algorithm::RS256); validation.set_audience(&["api.enablebanking.com"]); validation.set_issuer(&["enablebanking.com"]); validation.validate_exp = false; let claims = jsonwebtoken::decode::( &jwt, &DecodingKey::from_rsa_der(&app.key.public_der), &validation, ) .unwrap() .claims; assert_eq!( claims["exp"].as_i64().unwrap() - claims["iat"].as_i64().unwrap(), 3600 ); // Only the hash of the state is stored (CP-2). let stored: Vec = sqlx::query_scalar("SELECT state_hash FROM card_auth_attempts") .fetch_all(&app.state.db) .await .unwrap(); assert_eq!(stored, [sha256_hex(&state)]); } #[tokio::test] async fn e_37_validates_and_maps_bank_failures() { let app = CardsApp::new().await; let call = |body: Value| app.call(Method::POST, "/cards/auth", &admin_token(), Some(body)); assert_error( &call(json!({"provider_id": "other_card", "history_days": 30, "client": "android"})).await, 404, "not_found", ); assert_field_error( &call( json!({"provider_id": "sj_prio_mastercard", "history_days": 60, "client": "android"}), ) .await, 422, "validation_failed", "history_days", ); assert_error( &call(json!({"provider_id": "sj_prio_mastercard", "history_days": 30, "client": "ios"})) .await, 400, "invalid_request", ); assert_error( &call(json!({"provider_id": "sj_prio_mastercard"})).await, 400, "invalid_request", ); app.stub .state .auth_script .lock() .unwrap() .push_back(eb_error(400, "WRONG_ASPSP_PROVIDED")); let refused = app.start_auth(&admin_token(), 30, "web").await; assert_error(&refused, 502, "card_bank_unavailable"); assert!(!refused.text().contains("a body the server must never log")); let count: i64 = app .scalar_i64("SELECT COUNT(*) FROM card_auth_attempts") .await; assert_eq!(count, 0, "no attempt when the bank refused"); } #[tokio::test] async fn a_successful_callback_connects_and_starts_the_first_fetch_user_present() { let app = CardsApp::new().await; app.stub.set_transactions(vec![booked( "r1", &days_ago(2), "129.00", "Testkaféet Ankan", )]); let attempt = app.start_auth(&admin_token(), 30, "android").await; let id = attempt.json["id"].as_str().unwrap().to_owned(); let state = app.stub.last_state(); let page = app .callback( &format!("code=code-secret-1&state={state}"), "sv-SE,sv;q=0.9", ) .await; assert_eq!(page.status, 200); assert_eq!(page.headers["content-type"], "text/html; charset=utf-8"); assert_eq!(page.headers["cache-control"], "no-store"); assert_eq!(page.headers["referrer-policy"], "no-referrer"); let html = page.text(); assert!( html.contains("Kortet är anslutet") && html.contains("Dina kortköp hämtas nu."), "{html}" ); assert!( html.contains(&format!("href=\"kvitt://cards/connected?attempt={id}\"")), "{html}" ); assert!(!html.contains("code-secret-1") && !html.contains(&state)); assert_eq!( app.stub.requests_to("/sessions").pop().unwrap().json(), json!({"code": "code-secret-1"}) ); let polled = app .call( Method::GET, &format!("/cards/auth/{id}"), &admin_token(), None, ) .await; assert_eq!(polled.json["status"], "connected"); assert_eq!(polled.json["auth_url"], Value::Null); let connection = polled.json["connection"]["id"].as_str().unwrap().to_owned(); assert_eq!( polled.json["connection"]["valid_until"], "2027-04-15T11:55:00.000Z", "the session's valid_until" ); assert_eq!(polled.json["connection"]["history_days"], 30); app.wait_idle(&connection).await; let fetch = app.stub.transaction_requests().pop().unwrap(); assert_eq!(fetch.path, "/accounts/acc-secret-1/transactions"); assert_eq!( fetch.psu_headers(), ["psu-ip-address", "psu-user-agent"], "user present, the bank's set (CP-5)" ); assert_eq!( fetch.header("psu-ip-address").as_deref(), Some("192.0.2.10"), "the callback's client IP" ); assert_eq!(fetch.header("psu-user-agent").as_deref(), Some(BROWSER_UA)); assert_eq!( fetch.query["date_from"], days_ago(29), "30 days, today included" ); assert_eq!(fetch.query["date_to"], days_ago(0)); let purchases = app.purchases(&admin_token()).await; assert_eq!(purchases.len(), 1); assert_eq!(purchases[0]["amount_ore"], 12900); // The attempt is single use: the same state again shows the failure page and changes nothing (CP-2). let before = app.connection(&admin_token()).await; let again = app .callback(&format!("code=code-secret-2&state={state}"), "en") .await; assert!(again.text().contains("The card could not be connected")); assert!(!again.text().contains("