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,255 @@
|
||||
//! The card test app: the sync household (two accounts, the real audit writer) with an Enable Banking source pointed
|
||||
//! at the local stub, signing with a key generated for the test. Fixtures are invented merchants and amounts with
|
||||
//! K0's field presence (D-179): `entry_reference` on every row, `remittance_information` one line, the holder's
|
||||
//! name, a `CPAN` card number and the merchant's address present (they must never be stored, logged or served).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request};
|
||||
use chrono::{Days, NaiveDate};
|
||||
use kvitt_server::cards::Cards;
|
||||
use kvitt_server::cards::enable_banking::EnableBanking;
|
||||
use kvitt_server::router;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::common::*;
|
||||
use crate::stub::{Stub, TestKey, test_key};
|
||||
use crate::sync_fixtures;
|
||||
|
||||
pub const APP_ID: &str = "0f0f0f0f-test-application";
|
||||
/// The callback's user agent (the bank's redirect in the user's browser).
|
||||
pub const BROWSER_UA: &str = "Mozilla/5.0 (Linux; Android 16) KvittTest";
|
||||
/// The never-stored fields of the fixtures.
|
||||
pub const HOLDER_NAME: &str = "Kortinnehavare Testsson";
|
||||
pub const CARD_NUMBER: &str = "5299 **** **** 4711";
|
||||
pub const MERCHANT_TOWN: &str = "Hemligstad";
|
||||
|
||||
pub struct CardsApp {
|
||||
pub app: TestApp,
|
||||
pub stub: Stub,
|
||||
pub key: TestKey,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for CardsApp {
|
||||
type Target = TestApp;
|
||||
fn deref(&self) -> &TestApp {
|
||||
&self.app
|
||||
}
|
||||
}
|
||||
|
||||
impl CardsApp {
|
||||
/// Both users served by one application (the owner's, D-177).
|
||||
pub async fn new() -> Self {
|
||||
Self::build(true, true).await
|
||||
}
|
||||
|
||||
/// `admin`, `member`: whether that user has a card application.
|
||||
pub async fn build(admin: bool, member: bool) -> Self {
|
||||
let mut app = sync_fixtures::app().await;
|
||||
let stub = Stub::start().await;
|
||||
let key = test_key();
|
||||
let source: Arc<dyn kvitt_server::cards::CardSource> = Arc::new(
|
||||
EnableBanking::new(
|
||||
APP_ID,
|
||||
key.pem.as_bytes(),
|
||||
&stub.url(),
|
||||
app.state.clock.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
app.state.cards = Arc::new(
|
||||
Cards::new(
|
||||
admin.then(|| source.clone()),
|
||||
member.then(|| source.clone()),
|
||||
)
|
||||
.with_refresh_wait(StdDuration::from_secs(10)),
|
||||
);
|
||||
app.router = router::router(app.state.clone());
|
||||
Self { app, stub, key }
|
||||
}
|
||||
|
||||
/// Replaces the card state (and rebuilds the router).
|
||||
pub fn with_cards(&mut self, cards: Cards) {
|
||||
self.app.state.cards = Arc::new(cards);
|
||||
self.app.router = router::router(self.app.state.clone());
|
||||
}
|
||||
|
||||
pub async fn start_auth(&self, tok: &str, history_days: i64, client: &str) -> Reply {
|
||||
self.call(
|
||||
Method::POST,
|
||||
"/cards/auth",
|
||||
tok,
|
||||
Some(json!({"provider_id": "sj_prio_mastercard", "history_days": history_days, "client": client})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /cards/callback?<query>` as the user's browser sends it (no token, a browser user agent).
|
||||
pub async fn callback(&self, query: &str, lang: &str) -> Reply {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/cards/callback?{query}"))
|
||||
.header("user-agent", BROWSER_UA)
|
||||
.header("accept-language", lang)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
self.send(request).await
|
||||
}
|
||||
|
||||
/// Connects the caller's card (E-37, then the bank's callback) and waits for the first fetch; the connection id.
|
||||
pub async fn connect(&self, tok: &str, history_days: i64) -> String {
|
||||
let attempt = self.start_auth(tok, history_days, "android").await;
|
||||
assert_eq!(attempt.status, 201, "{}", attempt.text());
|
||||
let state = self.stub.last_state();
|
||||
let page = self
|
||||
.callback(&format!("code=code-secret-1&state={state}"), "sv")
|
||||
.await;
|
||||
assert!(
|
||||
page.text().contains("Kortet är anslutet"),
|
||||
"{}",
|
||||
page.text()
|
||||
);
|
||||
let id = attempt.json["id"].as_str().unwrap();
|
||||
let polled = self
|
||||
.call(Method::GET, &format!("/cards/auth/{id}"), tok, None)
|
||||
.await;
|
||||
let connection = polled.json["connection"]["id"].as_str().unwrap().to_owned();
|
||||
self.wait_idle(&connection).await;
|
||||
connection
|
||||
}
|
||||
|
||||
pub async fn wait_idle(&self, connection: &str) {
|
||||
let id = uuid::Uuid::parse_str(connection).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !self.state.cards.is_fetching(id) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(10)).await;
|
||||
}
|
||||
panic!("fetch still running");
|
||||
}
|
||||
|
||||
pub async fn refresh(&self, tok: &str, connection: &str) -> Reply {
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/cards/connections/{connection}/refresh"))
|
||||
.header("x-kvitt-protocol", "1")
|
||||
.header("authorization", format!("Bearer {tok}"))
|
||||
.header("user-agent", "okhttp/5.0 Kvitt")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-language", "sv-SE")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
self.send(request).await
|
||||
}
|
||||
|
||||
/// E-40 from 0, one page.
|
||||
pub async fn card_sync(&self, tok: &str) -> Value {
|
||||
let reply = self
|
||||
.call(Method::GET, "/cards/sync?since=0", tok, None)
|
||||
.await;
|
||||
assert_eq!(reply.status, 200, "{}", reply.text());
|
||||
reply.json
|
||||
}
|
||||
|
||||
/// The caller's non-deleted purchases, newest card_version last.
|
||||
pub async fn purchases(&self, tok: &str) -> Vec<Value> {
|
||||
self.card_sync(tok).await["purchases"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| p["deleted_at"].is_null())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn connection(&self, tok: &str) -> Value {
|
||||
self.card_sync(tok).await["connections"][0].clone()
|
||||
}
|
||||
|
||||
/// Runs one fetch of a user-present kind through E-42 (after the 60-second rule has passed).
|
||||
pub async fn refetch(&self, tok: &str, connection: &str) -> Reply {
|
||||
let now = self.state.clock.now();
|
||||
self.clock.set(now + chrono::Duration::seconds(61));
|
||||
let reply = self.refresh(tok, connection).await;
|
||||
assert_eq!(reply.status, 200, "{}", reply.text());
|
||||
self.wait_idle(connection).await;
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
||||
/// Today in the harness (NOW is 2026-10-17 in Stockholm).
|
||||
pub fn today() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
|
||||
}
|
||||
|
||||
/// `n` days before today, `YYYY-MM-DD`.
|
||||
pub fn days_ago(n: u64) -> String {
|
||||
today()
|
||||
.checked_sub_days(Days::new(n))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A transaction as the SJ Prio card reports it (K0).
|
||||
pub fn tx(
|
||||
reference: Option<&str>,
|
||||
status: &str,
|
||||
indicator: &str,
|
||||
date: &str,
|
||||
amount: &str,
|
||||
merchant: &str,
|
||||
) -> Value {
|
||||
let mut t = json!({
|
||||
"transaction_id": format!("tid-{}", reference.unwrap_or("none")),
|
||||
"status": status,
|
||||
"credit_debit_indicator": indicator,
|
||||
"value_date": date,
|
||||
"transaction_amount": {"amount": amount, "currency": "SEK"},
|
||||
"remittance_information": [merchant],
|
||||
"debtor": {"name": HOLDER_NAME},
|
||||
"debtor_account": {"other": {"identification": CARD_NUMBER, "scheme_name": "CPAN"}},
|
||||
"creditor": {"postal_address": {"country": "SE", "town_name": MERCHANT_TOWN}},
|
||||
"bank_transaction_code": {"code": "PURCHASE"},
|
||||
});
|
||||
let o = t.as_object_mut().unwrap();
|
||||
if let Some(r) = reference {
|
||||
o.insert("entry_reference".into(), json!(r));
|
||||
}
|
||||
if status == "BOOK" {
|
||||
o.insert("booking_date".into(), json!(date));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn booked(reference: &str, date: &str, amount: &str, merchant: &str) -> Value {
|
||||
tx(Some(reference), "BOOK", "DBIT", date, amount, merchant)
|
||||
}
|
||||
|
||||
pub fn pending(reference: &str, date: &str, amount: &str, merchant: &str) -> Value {
|
||||
tx(Some(reference), "PDNG", "DBIT", date, amount, merchant)
|
||||
}
|
||||
|
||||
/// `n` booked purchases on `date`, references `<prefix>-<i>`.
|
||||
pub fn many(prefix: &str, date: &str, n: usize) -> Vec<Value> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
booked(
|
||||
&format!("{prefix}-{i}"),
|
||||
date,
|
||||
&format!("{}.00", 10 + i),
|
||||
"Testbutiken Många",
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn by_merchant<'a>(purchases: &'a [Value], merchant: &str) -> &'a Value {
|
||||
purchases
|
||||
.iter()
|
||||
.find(|p| p["merchant"] == merchant)
|
||||
.unwrap_or_else(|| panic!("no purchase {merchant}: {purchases:#?}"))
|
||||
}
|
||||
Reference in New Issue
Block a user