K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
Build Kvitt images / build (push) Failing after 8m17s
Build Kvitt images / build (push) Failing after 8m17s
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error codes and a 502 response, examples for every card body and error code, the match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py (157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists match_card_purchase as not ported until K3 ports it (orchestrator's allowance). Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions, transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping), the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day, de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk), CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45, 4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38 and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub (tests/cards_api/, 34 tests); the real API is never called. Infra: Pi compose mounts secrets/cards read-only and passes the two application ids; .env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases". Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design (contract-first landing; K2 lands next). Server, contracts-check and web gates are green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
//! A local stub of the Enable Banking API (`/aspsps`, `/auth`, `/sessions`, `/accounts/{uid}/transactions`,
|
||||
//! `DELETE /sessions/{id}`), shaped after K0's field-presence findings (D-179): at most 100 transactions per response
|
||||
//! and no continuation key unless a test asks for one. Plus a throwaway RSA key generated per test (no key is
|
||||
//! committed) and the public key to verify the JWTs it signs.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aws_lc_rs::encoding::AsDer;
|
||||
use aws_lc_rs::rsa::{KeyPair, KeySize};
|
||||
use aws_lc_rs::signature::KeyPair as _;
|
||||
use axum::Router;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use base64::Engine;
|
||||
use chrono::NaiveDate;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The SJ Prio bank as K0 found it in `GET /aspsps?country=SE`.
|
||||
pub fn sj_prio_aspsp(required: &[&str]) -> Value {
|
||||
json!({"name": "SJ Prio Mastercard", "country": "SE", "beta": false, "maximum_consent_validity": 15552000,
|
||||
"psu_types": ["personal"], "auth_methods": [{"approach": "REDIRECT"}], "required_psu_headers": required})
|
||||
}
|
||||
|
||||
/// One request the stub received.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Received {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query: HashMap<String, String>,
|
||||
pub headers: HeaderMap,
|
||||
pub body: Bytes,
|
||||
}
|
||||
|
||||
impl Received {
|
||||
pub fn json(&self) -> Value {
|
||||
serde_json::from_slice(&self.body).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub fn header(&self, name: &str) -> Option<String> {
|
||||
self.headers
|
||||
.get(name)
|
||||
.map(|v| v.to_str().unwrap().to_owned())
|
||||
}
|
||||
|
||||
/// The PSU headers sent, lower case, sorted.
|
||||
pub fn psu_headers(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self
|
||||
.headers
|
||||
.keys()
|
||||
.map(|k| k.as_str().to_owned())
|
||||
.filter(|k| k.starts_with("psu-"))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubState {
|
||||
pub requests: Mutex<Vec<Received>>,
|
||||
pub aspsps: Mutex<Value>,
|
||||
/// The session `POST /sessions` returns.
|
||||
pub session: Mutex<Value>,
|
||||
/// The bank's transactions; each may carry `_bank_date`, the date the stub filters on (default: booking or
|
||||
/// value date), removed before answering.
|
||||
pub transactions: Mutex<Vec<Value>>,
|
||||
/// Answer windows with a continuation key, 100 rows per page, instead of cutting at 100.
|
||||
pub paginate: Mutex<bool>,
|
||||
/// `WRONG_TRANSACTIONS_PERIOD` for a window whose `date_to` is before this day.
|
||||
pub history_from: Mutex<Option<NaiveDate>>,
|
||||
/// Scripted answers of `GET /accounts/{uid}/transactions`, used in order before the data.
|
||||
pub transaction_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
pub auth_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
pub session_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
pub delete_script: Mutex<VecDeque<(u16, Value)>>,
|
||||
/// Delay of each transactions answer, in milliseconds.
|
||||
pub delay_ms: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl Default for StubState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
aspsps: Mutex::new(json!({"aspsps": [sj_prio_aspsp(&["psu-ip-address"])]})),
|
||||
session: Mutex::new(json!({
|
||||
"session_id": "sess-secret-1",
|
||||
"accounts": [{"uid": "acc-secret-1", "cash_account_type": "CARD", "currency": "SEK",
|
||||
"account_id": {"other": {"identification": "5299 **** **** 4711", "scheme_name": "CPAN"}}}],
|
||||
"aspsp": {"name": "SJ Prio Mastercard", "country": "SE"},
|
||||
"psu_type": "personal",
|
||||
"access": {"valid_until": "2027-04-15T11:55:00.000000+00:00"}
|
||||
})),
|
||||
transactions: Mutex::new(Vec::new()),
|
||||
paginate: Mutex::new(false),
|
||||
history_from: Mutex::new(None),
|
||||
transaction_script: Mutex::new(VecDeque::new()),
|
||||
auth_script: Mutex::new(VecDeque::new()),
|
||||
session_script: Mutex::new(VecDeque::new()),
|
||||
delete_script: Mutex::new(VecDeque::new()),
|
||||
delay_ms: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stub {
|
||||
pub addr: SocketAddr,
|
||||
pub state: Arc<StubState>,
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
pub async fn start() -> Self {
|
||||
let state = Arc::new(StubState::default());
|
||||
let app = Router::new()
|
||||
.route("/aspsps", get(aspsps))
|
||||
.route("/auth", post(auth))
|
||||
.route("/sessions", post(sessions))
|
||||
.route("/sessions/{id}", delete(delete_session))
|
||||
.route("/accounts/{uid}/transactions", get(transactions))
|
||||
.with_state(state.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
Self { addr, state }
|
||||
}
|
||||
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
pub fn requests(&self) -> Vec<Received> {
|
||||
self.state.requests.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn requests_to(&self, path_prefix: &str) -> Vec<Received> {
|
||||
self.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.path.starts_with(path_prefix))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn transaction_requests(&self) -> Vec<Received> {
|
||||
self.requests_to("/accounts/")
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.state.requests.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
pub fn set_transactions(&self, txs: Vec<Value>) {
|
||||
*self.state.transactions.lock().unwrap() = txs;
|
||||
}
|
||||
|
||||
pub fn script_transactions(&self, answers: &[(u16, Value)]) {
|
||||
self.state
|
||||
.transaction_script
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(answers.iter().cloned());
|
||||
}
|
||||
|
||||
/// The `state` the server sent with its last `POST /auth`.
|
||||
pub fn last_state(&self) -> String {
|
||||
self.requests_to("/auth").last().unwrap().json()["state"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn record(
|
||||
state: &StubState,
|
||||
method: &str,
|
||||
path: String,
|
||||
query: HashMap<String, String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) {
|
||||
state.requests.lock().unwrap().push(Received {
|
||||
method: method.to_owned(),
|
||||
path,
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
fn answer(status: u16, body: Value) -> Response {
|
||||
(StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
|
||||
}
|
||||
|
||||
fn error(status: u16, code: &str) -> Value {
|
||||
json!({"code": status, "error": code, "message": "stub", "detail": "a body the server must never log"})
|
||||
}
|
||||
|
||||
pub fn eb_error(status: u16, code: &str) -> (u16, Value) {
|
||||
(status, error(status, code))
|
||||
}
|
||||
|
||||
async fn aspsps(
|
||||
State(s): State<Arc<StubState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
record(&s, "GET", "/aspsps".into(), q, headers, Bytes::new());
|
||||
let body = s.aspsps.lock().unwrap().clone();
|
||||
answer(200, body)
|
||||
}
|
||||
|
||||
async fn auth(State(s): State<Arc<StubState>>, headers: HeaderMap, body: Bytes) -> Response {
|
||||
record(&s, "POST", "/auth".into(), HashMap::new(), headers, body);
|
||||
if let Some((status, body)) = s.auth_script.lock().unwrap().pop_front() {
|
||||
return answer(status, body);
|
||||
}
|
||||
answer(
|
||||
200,
|
||||
json!({"url": "https://bank.example.invalid/authorize?session=example", "authorization_id": "auth-1", "psu_id_hash": "h"}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn sessions(State(s): State<Arc<StubState>>, headers: HeaderMap, body: Bytes) -> Response {
|
||||
record(
|
||||
&s,
|
||||
"POST",
|
||||
"/sessions".into(),
|
||||
HashMap::new(),
|
||||
headers,
|
||||
body,
|
||||
);
|
||||
if let Some((status, body)) = s.session_script.lock().unwrap().pop_front() {
|
||||
return answer(status, body);
|
||||
}
|
||||
let body = s.session.lock().unwrap().clone();
|
||||
answer(200, body)
|
||||
}
|
||||
|
||||
async fn delete_session(
|
||||
State(s): State<Arc<StubState>>,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
record(
|
||||
&s,
|
||||
"DELETE",
|
||||
format!("/sessions/{id}"),
|
||||
HashMap::new(),
|
||||
headers,
|
||||
Bytes::new(),
|
||||
);
|
||||
if let Some((status, body)) = s.delete_script.lock().unwrap().pop_front() {
|
||||
return answer(status, body);
|
||||
}
|
||||
answer(200, json!({"message": "OK"}))
|
||||
}
|
||||
|
||||
fn bank_date(t: &Value) -> NaiveDate {
|
||||
let text = t["_bank_date"]
|
||||
.as_str()
|
||||
.or_else(|| t["booking_date"].as_str())
|
||||
.or_else(|| t["value_date"].as_str())
|
||||
.unwrap();
|
||||
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
async fn transactions(
|
||||
State(s): State<Arc<StubState>>,
|
||||
Path(uid): Path<String>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
record(
|
||||
&s,
|
||||
"GET",
|
||||
format!("/accounts/{uid}/transactions"),
|
||||
q.clone(),
|
||||
headers,
|
||||
Bytes::new(),
|
||||
);
|
||||
let delay = *s.delay_ms.lock().unwrap();
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
if let Some((status, body)) = s.transaction_script.lock().unwrap().pop_front() {
|
||||
return answer(status, body);
|
||||
}
|
||||
let date = |key: &str| {
|
||||
q.get(key)
|
||||
.map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap())
|
||||
};
|
||||
let (Some(from), Some(to)) = (date("date_from"), date("date_to")) else {
|
||||
return answer(422, error(422, "WRONG_REQUEST_PARAMETERS"));
|
||||
};
|
||||
if let Some(first) = *s.history_from.lock().unwrap()
|
||||
&& to < first
|
||||
{
|
||||
return answer(422, error(422, "WRONG_TRANSACTIONS_PERIOD"));
|
||||
}
|
||||
let mut rows: Vec<Value> = s
|
||||
.transactions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|t| (from..=to).contains(&bank_date(t)))
|
||||
.cloned()
|
||||
.collect();
|
||||
rows.sort_by_key(|t| std::cmp::Reverse(bank_date(t)));
|
||||
for row in &mut rows {
|
||||
row.as_object_mut().unwrap().remove("_bank_date");
|
||||
}
|
||||
if *s.paginate.lock().unwrap() {
|
||||
let start: usize = q.get("continuation_key").map_or(0, |k| k.parse().unwrap());
|
||||
let end = (start + 100).min(rows.len());
|
||||
let next = (end < rows.len()).then(|| end.to_string());
|
||||
return answer(
|
||||
200,
|
||||
json!({"transactions": rows[start..end], "continuation_key": next}),
|
||||
);
|
||||
}
|
||||
rows.truncate(100);
|
||||
answer(200, json!({"transactions": rows, "continuation_key": null}))
|
||||
}
|
||||
|
||||
/// A generated RSA key as PEM (PKCS#8) and the public key (DER) to verify the JWTs.
|
||||
pub struct TestKey {
|
||||
pub pem: String,
|
||||
pub public_der: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn test_key() -> TestKey {
|
||||
let pair = KeyPair::generate(KeySize::Rsa2048).unwrap();
|
||||
let der = AsDer::<aws_lc_rs::encoding::Pkcs8V1Der>::as_der(&pair).unwrap();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref());
|
||||
let lines: Vec<&str> = b64
|
||||
.as_bytes()
|
||||
.chunks(64)
|
||||
.map(|c| std::str::from_utf8(c).unwrap())
|
||||
.collect();
|
||||
TestKey {
|
||||
pem: format!(
|
||||
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
|
||||
lines.join("\n")
|
||||
),
|
||||
public_der: pair.public_key().as_ref().to_vec(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user