//! SPEC §8.7, AC-22.14: the logs of a connection, its fetches and a callback carry the aggregate `card_fetch` line and //! never a merchant text, an amount, a purchase date, the `state`, the authorisation code, the session id, the account //! uid, the holder's name, the card number or a response body of the bank. use std::io::Write; use std::sync::{Arc, Mutex}; use axum::http::Method; use crate::common::*; use crate::stub::eb_error; use crate::support::*; #[derive(Clone, Default)] struct Captured(Arc>>); impl Write for Captured { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().unwrap().extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } #[tokio::test] async fn card_logs_hold_no_card_content() { let captured = Captured::default(); let writer = captured.clone(); let subscriber = tracing_subscriber::fmt() .json() .with_max_level(tracing::Level::TRACE) .with_current_span(true) .with_writer(move || writer.clone()) .finish(); // Global: the other tests of this binary run in parallel and are captured too, which only widens the check. tracing::subscriber::set_global_default(subscriber).unwrap(); let app = CardsApp::new().await; app.stub.set_transactions(vec![ pending( "ref-hemlig-1", "2026-10-09", "4321.09", "Hemligbutiken Ankeborg", ), booked( "ref-hemlig-2", "2026-10-08", "8765.43", "Topphemliga Kaféet", ), ]); let connection = app.connect(&admin_token(), 30).await; let state = app.stub.last_state(); app.stub.set_transactions(vec![ booked( "ref-hemlig-1", "2026-10-10", "4321.09", "Hemligbutiken Ankeborg", ), booked( "ref-hemlig-2", "2026-10-08", "8765.43", "Topphemliga Kaféet", ), ]); app.refetch(&admin_token(), &connection).await; app.stub .script_transactions(&[eb_error(500, "ASPSP_ERROR")]); app.refetch(&admin_token(), &connection).await; app.callback(&format!("code=code-secret-1&state={state}"), "sv") .await; app.call( Method::DELETE, &format!("/cards/connections/{connection}"), &admin_token(), None, ) .await; // Latencies are random numbers that may contain any digits; they are not card data. let logs: String = String::from_utf8(captured.0.lock().unwrap().clone()) .unwrap() .lines() .map( |line| match serde_json::from_str::(line) { Ok(mut v) => { if let Some(fields) = v.get_mut("fields").and_then(|f| f.as_object_mut()) { fields.remove("latency_ms"); } v.to_string() } Err(_) => line.to_owned(), }, ) .collect::>() .join("\n"); assert!(logs.contains("\"route\":\"/cards/callback\""), "{logs}"); let fetch_lines: Vec<&str> = logs.lines().filter(|l| l.contains("card_fetch")).collect(); assert!(fetch_lines.len() >= 3, "{logs}"); assert!( fetch_lines .iter() .any(|l| l.contains("\"booked_by_ref\":1") && l.contains("\"kind\":\"user\"")), "{fetch_lines:#?}" ); assert!( fetch_lines .iter() .any(|l| l.contains("\"error_code\":\"bank_unavailable\"")) ); assert!( fetch_lines .iter() .all(|l| l.contains("\"requests\":") && l.contains("\"windows_split\":")) ); for secret in [ "Hemligbutiken", "Topphemliga", "4321.09", "432109", "8765.43", "876543", "2026-10-09", "2026-10-08", "ref-hemlig", state.as_str(), "code-secret-1", "sess-secret-1", "acc-secret-1", HOLDER_NAME, CARD_NUMBER, MERCHANT_TOWN, APP_ID, "a body the server must never log", ] { assert!(!logs.contains(secret), "log contains {secret}:\n{logs}"); } }