671c00d0d8
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
256 lines
8.7 KiB
Rust
256 lines
8.7 KiB
Rust
//! 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:#?}"))
|
|
}
|