K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
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:
2026-10-24 16:43:26 +02:00
parent ae73f58079
commit 671c00d0d8
67 changed files with 9515 additions and 47 deletions
@@ -0,0 +1,456 @@
//! 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::<Value>(
&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<String> = 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("<a "), "no link for a used state");
assert_eq!(app.connection(&admin_token()).await, before);
assert_eq!(app.stub.requests_to("/sessions").len(), 1);
}
#[tokio::test]
async fn the_web_client_gets_its_own_link_and_english() {
let app = CardsApp::new().await;
let attempt = app.start_auth(&member_token(), 7, "web").await;
let id = attempt.json["id"].as_str().unwrap().to_owned();
let page = app
.callback(
&format!("state={}&code=c", app.stub.last_state()),
"en-GB,sv;q=0.5",
)
.await;
let html = page.text();
assert!(
html.contains("Card connected") && html.contains("Back to Kvitt"),
"{html}"
);
assert!(
html.contains(&format!("href=\"/app/cards?attempt={id}\"")),
"{html}"
);
}
#[tokio::test]
async fn cancel_unknown_and_malformed_callbacks_show_the_failure_page() {
let app = CardsApp::new().await;
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();
for query in [
"",
"code=x",
"code=x&state=not-a-known-state",
"state=%FF%FE",
"garbage",
] {
let page = app.callback(query, "sv").await;
assert_eq!(page.status, 200, "{query}");
assert!(
page.text().contains("Det gick inte att ansluta kortet"),
"{query}"
);
assert!(
!page.text().contains("<a "),
"{query}: no link for an unknown state"
);
}
let page = app
.callback(&format!("error=access_denied&state={state}"), "sv")
.await;
assert!(
page.text()
.contains("Gå tillbaka till Kvitt och försök igen.")
);
assert!(
page.text()
.contains(&format!("kvitt://cards/connected?attempt={id}")),
"a known attempt links back"
);
let polled = app
.call(
Method::GET,
&format!("/cards/auth/{id}"),
&admin_token(),
None,
)
.await;
assert_eq!(
(
polled.json["status"].as_str(),
polled.json["failure"].as_str()
),
(Some("failed"), Some("cancelled"))
);
assert!(app.stub.requests_to("/sessions").is_empty());
assert_eq!(
app.scalar_i64("SELECT COUNT(*) FROM card_connections")
.await,
0
);
}
#[tokio::test]
async fn an_expired_attempt_is_expired_for_poll_and_callback() {
let app = CardsApp::new().await;
let first = app.start_auth(&admin_token(), 30, "android").await;
let first_id = first.json["id"].as_str().unwrap().to_owned();
// A new attempt for the same user and provider expires the pending older one.
let second = app.start_auth(&admin_token(), 30, "android").await;
let polled = app
.call(
Method::GET,
&format!("/cards/auth/{first_id}"),
&admin_token(),
None,
)
.await;
assert_eq!(polled.json["status"], "expired");
let state = app.stub.last_state();
app.set_now("2026-10-17T12:15:01.000Z");
let page = app.callback(&format!("code=c&state={state}"), "sv").await;
assert!(page.text().contains("Det gick inte att ansluta kortet"));
let id = second.json["id"].as_str().unwrap();
let polled = app
.call(
Method::GET,
&format!("/cards/auth/{id}"),
&admin_token(),
None,
)
.await;
assert_eq!(polled.json["status"], "expired");
assert!(app.stub.requests_to("/sessions").is_empty());
}
#[tokio::test]
async fn a_bank_error_or_no_account_fails_the_attempt() {
let app = CardsApp::new().await;
app.stub
.state
.session_script
.lock()
.unwrap()
.push_back(eb_error(400, "EXPIRED_AUTHORIZATION_CODE"));
app.start_auth(&admin_token(), 30, "android").await;
app.callback(&format!("code=c&state={}", app.stub.last_state()), "sv")
.await;
let failure: String = sqlx::query_scalar("SELECT failure FROM card_auth_attempts")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(failure, "bank_error");
app.stub.state.session_script.lock().unwrap().push_back((
200,
json!({"session_id": "s-empty", "accounts": [],
"access": {"valid_until": "2027-04-15T11:55:00+00:00"}}),
));
let attempt = app.start_auth(&admin_token(), 30, "android").await;
let page = app
.callback(&format!("code=c&state={}", app.stub.last_state()), "sv")
.await;
assert!(page.text().contains("Det gick inte att ansluta kortet"));
let id = attempt.json["id"].as_str().unwrap();
let polled = app
.call(
Method::GET,
&format!("/cards/auth/{id}"),
&admin_token(),
None,
)
.await;
assert_eq!(polled.json["failure"], "no_account");
assert_eq!(
app.stub.requests_to("/sessions/s-empty").len(),
1,
"the empty session is revoked"
);
}
#[tokio::test]
async fn several_accounts_pick_the_card_account() {
let app = CardsApp::new().await;
*app.stub.state.session.lock().unwrap() = json!({
"session_id": "sess-2",
"accounts": [{"uid": "acc-current", "cash_account_type": "CACC"}, {"uid": "acc-card", "cash_account_type": "CARD"}],
"access": {"valid_until": "2027-04-15T11:55:00+00:00"}
});
app.connect(&admin_token(), 7).await;
assert_eq!(
app.stub.transaction_requests()[0].path,
"/accounts/acc-card/transactions"
);
}
#[tokio::test]
async fn renewing_keeps_the_connection_and_its_purchases_and_revokes_the_old_session() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![booked(
"r1",
&days_ago(1),
"50.00",
"Testkaféet Ankan",
)]);
let connection = app.connect(&admin_token(), 30).await;
let purchase = app.purchases(&admin_token()).await[0].clone();
app.call(
Method::PUT,
&format!("/cards/purchases/{}", purchase["id"].as_str().unwrap()),
&admin_token(),
Some(json!({"expense_id": null, "ignored": true})),
)
.await;
*app.stub.state.session.lock().unwrap() = json!({
"session_id": "sess-renewed", "accounts": [{"uid": "acc-secret-1", "cash_account_type": "CARD"}],
"access": {"valid_until": "2027-05-01T10:00:00+00:00"}
});
app.set_now("2026-11-01T12:00:00.000Z");
let renewed = app.connect(&admin_token(), 180).await;
assert_eq!(renewed, connection, "one connection per user and provider");
let c = app.connection(&admin_token()).await;
assert_eq!(
(c["status"].as_str(), c["history_days"].as_i64()),
(Some("active"), Some(180))
);
assert_eq!(c["valid_until"], "2027-05-01T10:00:00.000Z");
assert_eq!(
app.stub.requests_to("/sessions/sess-secret-1").len(),
1,
"the previous session is revoked"
);
let kept = app.purchases(&admin_token()).await;
let same = kept.iter().find(|p| p["id"] == purchase["id"]).unwrap();
assert_eq!(
same["ignored"], true,
"renewal keeps every purchase and its state"
);
}
@@ -0,0 +1,738 @@
//! E-40 to E-44 (SPEC §14.3.4), authorization of §8.4 per endpoint (the other user's ids are `not_found`), the link
//! clearing of §14.4.4 step 7, the 04:00 pruning of attempts and the rate limit of E-39 (§8.6).
use std::time::Duration as StdDuration;
use axum::http::Method;
use kvitt_server::cards::Cards;
use serde_json::{Value, json};
use crate::common::*;
use crate::stub::eb_error;
use crate::support::*;
use crate::sync_fixtures;
async fn with_purchases(n: usize) -> (CardsApp, String) {
let app = CardsApp::new().await;
app.stub.set_transactions(
(0..n)
.map(|i| {
booked(
&format!("r{i}"),
&days_ago(i as u64 + 1),
&format!("{}.00", 100 + i),
&format!("Butik {i}"),
)
})
.collect(),
);
let connection = app.connect(&admin_token(), 30).await;
(app, connection)
}
#[tokio::test]
async fn card_sync_pages_like_sync_and_shows_only_the_callers_rows() {
let (app, _) = with_purchases(4).await;
let all = app.card_sync(&admin_token()).await;
assert_eq!(all["purchases"].as_array().unwrap().len(), 4);
assert_eq!(all["connections"].as_array().unwrap().len(), 1);
let top = all["next_since"].as_i64().unwrap();
let page = app
.call(
Method::GET,
"/cards/sync?since=0&limit=2",
&admin_token(),
None,
)
.await;
let rows = page.json["purchases"].as_array().unwrap().len()
+ page.json["connections"].as_array().unwrap().len();
assert_eq!((rows, page.json["has_more"].as_bool()), (2, Some(true)));
let next = page.json["next_since"].as_i64().unwrap();
let rest = app
.call(
Method::GET,
&format!("/cards/sync?since={next}&limit=1000"),
&admin_token(),
None,
)
.await;
let rest_rows = rest.json["purchases"].as_array().unwrap().len()
+ rest.json["connections"].as_array().unwrap().len();
assert_eq!(
(rest_rows, rest.json["has_more"].as_bool()),
(3, Some(false))
);
assert_eq!(rest.json["next_since"].as_i64(), Some(top));
let empty = app
.call(
Method::GET,
&format!("/cards/sync?since={top}"),
&admin_token(),
None,
)
.await;
assert_eq!(
(
empty.json["next_since"].as_i64(),
empty.json["has_more"].as_bool()
),
(Some(top), Some(false))
);
assert_field_error(
&app.call(
Method::GET,
&format!("/cards/sync?since={}", top + 1),
&admin_token(),
None,
)
.await,
409,
"cursor_ahead",
"since",
);
for bad in [
"/cards/sync",
"/cards/sync?since=-1",
"/cards/sync?since=x",
"/cards/sync?since=0&limit=0",
"/cards/sync?since=0&limit=1001",
] {
assert_error(
&app.call(Method::GET, bad, &admin_token(), None).await,
400,
"invalid_request",
);
}
let member = app.card_sync(&member_token()).await;
assert_eq!(
member["connections"],
json!([]),
"card rows are private (CP-11)"
);
assert_eq!(member["purchases"], json!([]));
// Card rows never touch the household stream.
let household = sync_fixtures::pull(&app, &member_token(), 0, None).await;
assert!(!household.text().contains("Butik 1"));
}
#[tokio::test]
async fn the_history_can_only_be_shortened() {
let (app, connection) = with_purchases(1).await;
let path = format!("/cards/connections/{connection}");
let before = app.connection(&admin_token()).await;
let reply = app
.call(
Method::PATCH,
&path,
&admin_token(),
Some(json!({"history_days": 7})),
)
.await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(reply.json["history_days"], 7);
assert!(reply.json["card_version"].as_i64() > before["card_version"].as_i64());
let same = app
.call(
Method::PATCH,
&path,
&admin_token(),
Some(json!({"history_days": 7})),
)
.await;
assert_eq!(
same.json["card_version"], reply.json["card_version"],
"unchanged: no new version"
);
for longer in [30, 60] {
assert_field_error(
&app.call(
Method::PATCH,
&path,
&admin_token(),
Some(json!({"history_days": longer})),
)
.await,
422,
"validation_failed",
"history_days",
);
}
assert_error(
&app.call(Method::PATCH, &path, &admin_token(), Some(json!({})))
.await,
400,
"invalid_request",
);
assert_eq!(
app.purchases(&admin_token()).await.len(),
1,
"stored purchases stay"
);
}
#[tokio::test]
async fn refresh_follows_the_60_second_rule_and_sends_the_apps_headers() {
let (app, connection) = with_purchases(1).await;
// The callback's fetch started at NOW: within 60 seconds the bank is not contacted.
app.stub.clear();
let reply = app.refresh(&admin_token(), &connection).await;
assert_eq!(reply.status, 200);
assert_eq!(reply.json["fetched"], false);
assert!(app.stub.transaction_requests().is_empty());
let reply = app.refetch(&admin_token(), &connection).await;
assert_eq!(reply.json["fetched"], true);
assert_like_example(
&reply.json,
"E-42-card-refresh/response.json",
&[
"id",
"created_at",
"updated_at",
"last_fetch_at",
"card_version",
"valid_until",
],
);
let fetch = app.stub.transaction_requests().pop().unwrap();
assert_eq!(
fetch.header("psu-user-agent").as_deref(),
Some("okhttp/5.0 Kvitt"),
"the app's own User-Agent"
);
assert_eq!(
fetch.header("psu-ip-address").as_deref(),
Some("192.0.2.10")
);
assert_eq!(fetch.psu_headers(), ["psu-ip-address", "psu-user-agent"]);
}
#[tokio::test]
async fn every_required_psu_header_the_app_request_fills_is_sent() {
let app = CardsApp::new().await;
*app.stub.state.aspsps.lock().unwrap() = json!({"aspsps": [crate::stub::sj_prio_aspsp(
&["PSU-IP-ADDRESS", "psu-accept", "psu-accept-language", "psu-referer", "psu-geo-location"])]});
let connection = app.connect(&admin_token(), 7).await;
app.stub.clear();
app.refetch(&admin_token(), &connection).await;
let fetch = app.stub.transaction_requests().pop().unwrap();
assert_eq!(
fetch.psu_headers(),
[
"psu-accept",
"psu-accept-language",
"psu-ip-address",
"psu-user-agent"
],
"compared case-insensitively; no Referer in the request, never the geo location"
);
assert_eq!(
fetch.header("psu-accept-language").as_deref(),
Some("sv-SE")
);
}
#[tokio::test]
async fn a_slow_bank_answers_with_fetching_and_completes_in_the_background() {
assert_eq!(
Cards::disabled().refresh_wait,
StdDuration::from_secs(25),
"CP-5"
);
let (mut app, connection) = with_purchases(1).await;
let source: std::sync::Arc<dyn kvitt_server::cards::CardSource> = std::sync::Arc::new(
kvitt_server::cards::enable_banking::EnableBanking::new(
APP_ID,
app.key.pem.as_bytes(),
&app.stub.url(),
app.state.clock.clone(),
)
.unwrap(),
);
app.with_cards(
Cards::new(Some(source.clone()), Some(source))
.with_refresh_wait(StdDuration::from_millis(50)),
);
*app.stub.state.delay_ms.lock().unwrap() = 400;
let now = app.state.clock.now();
app.clock.set(now + chrono::Duration::seconds(61));
let slow = app.refresh(&admin_token(), &connection).await;
assert_eq!(
(
slow.json["fetched"].as_bool(),
slow.json["connection"]["fetching"].as_bool()
),
(Some(false), Some(true))
);
let again = app.refresh(&admin_token(), &connection).await;
assert_eq!(again.json["fetched"], false, "one fetch at a time");
app.wait_idle(&connection).await;
let c = app.connection(&admin_token()).await;
assert_eq!(c["fetching"], false);
assert_eq!(c["last_fetch_at"], "2026-10-17T12:01:01.000Z");
}
#[tokio::test]
async fn disconnecting_keeps_handled_purchases_unless_asked_and_revokes_the_session() {
let (app, connection) = with_purchases(3).await;
let purchases = app.purchases(&admin_token()).await;
let expense = app
.insert_expense(ACCOUNT_1, GROCERIES, 10_000, ADMIN_ID, (5000, 5000), false)
.await;
let linked = by_merchant(&purchases, "Butik 0")["id"]
.as_str()
.unwrap()
.to_owned();
let ignored = by_merchant(&purchases, "Butik 1")["id"]
.as_str()
.unwrap()
.to_owned();
app.call(
Method::PUT,
&format!("/cards/purchases/{linked}"),
&admin_token(),
Some(json!({"expense_id": expense, "ignored": false})),
)
.await;
app.call(
Method::PUT,
&format!("/cards/purchases/{ignored}"),
&admin_token(),
Some(json!({"expense_id": null, "ignored": true})),
)
.await;
let path = format!("/cards/connections/{connection}");
assert_field_error(
&app.call(
Method::DELETE,
&format!("{path}?erase_handled=yes"),
&admin_token(),
None,
)
.await,
400,
"invalid_request",
"erase_handled",
);
app.stub
.state
.delete_script
.lock()
.unwrap()
.push_back(eb_error(500, "ASPSP_ERROR"));
let reply = app.call(Method::DELETE, &path, &admin_token(), None).await;
assert_eq!(
reply.status, 200,
"a failed revocation does not stop the disconnect"
);
assert_like_example(
&reply.json,
"E-43-card-disconnect/response.json",
&[
"id",
"created_at",
"updated_at",
"card_version",
"history_days",
],
);
assert_eq!(app.stub.requests_to("/sessions/sess-secret-1").len(), 1);
let row: (Option<String>, Option<String>, String) =
sqlx::query_as("SELECT session_id, account_uid, status FROM card_connections")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(row, (None, None, "disconnected".to_owned()));
let left: Vec<String> = app
.purchases(&admin_token())
.await
.iter()
.map(|p| p["id"].as_str().unwrap().to_owned())
.collect();
assert_eq!(left.len(), 2);
assert!(left.contains(&linked) && left.contains(&ignored));
assert_eq!(
app.scalar_i64("SELECT COUNT(*) FROM expenses WHERE deleted_at IS NULL")
.await,
1,
"expenses stay"
);
let again = app.call(Method::DELETE, &path, &admin_token(), None).await;
assert_eq!(
again.json["card_version"], reply.json["card_version"],
"idempotent"
);
let erased = app
.call(
Method::DELETE,
&format!("{path}?erase_handled=true"),
&admin_token(),
None,
)
.await;
assert_eq!(erased.status, 200);
assert!(
app.purchases(&admin_token()).await.is_empty(),
"erase_handled deletes the handled ones too"
);
assert_error(
&app.refresh(&admin_token(), &connection).await,
409,
"card_consent_expired",
);
}
#[tokio::test]
async fn purchase_updates_follow_the_rules_of_9_6() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![
booked("d1", &days_ago(1), "100.00", "Butik Debet"),
booked("d2", &days_ago(2), "100.00", "Butik Debet Två"),
tx(
Some("c1"),
"BOOK",
"CRDT",
&days_ago(3),
"100.00",
"Insättning",
),
]);
app.connect(&admin_token(), 30).await;
let purchases = app.purchases(&admin_token()).await;
let id = |m: &str| {
by_merchant(&purchases, m)["id"]
.as_str()
.unwrap()
.to_owned()
};
let expense = app
.insert_expense(ACCOUNT_1, GROCERIES, 10_000, MEMBER_ID, (5000, 5000), false)
.await;
let deleted = app
.insert_expense(ACCOUNT_1, GROCERIES, 10_000, ADMIN_ID, (5000, 5000), true)
.await;
let put = |purchase: String, body: Value| {
let path = format!("/cards/purchases/{purchase}");
app.call(Method::PUT, &path, &admin_token(), Some(body))
};
let linked = put(
id("Butik Debet"),
json!({"expense_id": expense, "ignored": false}),
)
.await;
assert_eq!(linked.status, 200, "{}", linked.text());
assert_like_example(
&linked.json,
"E-44-card-purchase-update/response.json",
&[
"id",
"connection_id",
"expense_id",
"created_at",
"updated_at",
"card_version",
"purchase_date",
"booking_date",
"amount_ore",
"merchant",
],
);
let again = put(
id("Butik Debet"),
json!({"expense_id": expense, "ignored": false}),
)
.await;
assert_eq!(
again.json["card_version"], linked.json["card_version"],
"idempotent"
);
assert_field_error(
&put(
id("Butik Debet Två"),
json!({"expense_id": expense, "ignored": false}),
)
.await,
409,
"expense_already_linked",
"expense_id",
);
assert_field_error(
&put(
id("Butik Debet Två"),
json!({"expense_id": expense, "ignored": true}),
)
.await,
422,
"validation_failed",
"ignored",
);
assert_field_error(
&put(
id("Insättning"),
json!({"expense_id": null, "ignored": true}),
)
.await,
422,
"validation_failed",
"ignored",
);
assert_field_error(
&put(
id("Insättning"),
json!({"expense_id": expense, "ignored": false}),
)
.await,
422,
"validation_failed",
"expense_id",
);
assert_eq!(
put(
id("Insättning"),
json!({"expense_id": null, "ignored": false})
)
.await
.status,
200
);
assert_field_error(
&put(
id("Butik Debet Två"),
json!({"expense_id": UNKNOWN_ID, "ignored": false}),
)
.await,
404,
"not_found",
"expense_id",
);
assert_field_error(
&put(
id("Butik Debet Två"),
json!({"expense_id": deleted, "ignored": false}),
)
.await,
404,
"not_found",
"expense_id",
);
assert_error(
&put(id("Butik Debet Två"), json!({"ignored": false})).await,
400,
"invalid_request",
);
let ignored = put(
id("Butik Debet Två"),
json!({"expense_id": null, "ignored": true}),
)
.await;
assert_eq!(ignored.json["ignored"], true);
let unlinked = put(
id("Butik Debet"),
json!({"expense_id": null, "ignored": false}),
)
.await;
assert_eq!(unlinked.json["expense_id"], Value::Null);
assert_error(
&put(
UNKNOWN_ID.to_owned(),
json!({"expense_id": null, "ignored": false}),
)
.await,
404,
"not_found",
);
// Linking is not audited and not pushed.
assert!(sync_fixtures::audit_of(&app, &expense).await.is_empty());
assert!(app.push.0.lock().unwrap().is_empty());
}
#[tokio::test]
async fn the_other_users_ids_are_not_found_on_every_endpoint() {
let app = CardsApp::new().await;
app.stub
.set_transactions(vec![booked("r", &days_ago(1), "10.00", "Butik")]);
let attempt = app.start_auth(&admin_token(), 30, "android").await;
let attempt_id = attempt.json["id"].as_str().unwrap().to_owned();
app.callback(&format!("code=c&state={}", app.stub.last_state()), "sv")
.await;
let polled = app
.call(
Method::GET,
&format!("/cards/auth/{attempt_id}"),
&admin_token(),
None,
)
.await;
let connection = polled.json["connection"]["id"].as_str().unwrap().to_owned();
app.wait_idle(&connection).await;
let purchase = app.purchases(&admin_token()).await[0]["id"]
.as_str()
.unwrap()
.to_owned();
let member = member_token();
assert_error(
&app.call(
Method::GET,
&format!("/cards/auth/{attempt_id}"),
&member,
None,
)
.await,
404,
"not_found",
);
assert_error(
&app.call(
Method::PATCH,
&format!("/cards/connections/{connection}"),
&member,
Some(json!({"history_days": 7})),
)
.await,
404,
"not_found",
);
assert_error(&app.refresh(&member, &connection).await, 404, "not_found");
assert_error(
&app.call(
Method::DELETE,
&format!("/cards/connections/{connection}"),
&member,
None,
)
.await,
404,
"not_found",
);
assert_error(
&app.call(
Method::PUT,
&format!("/cards/purchases/{purchase}"),
&member,
Some(json!({"expense_id": null, "ignored": true})),
)
.await,
404,
"not_found",
);
for path in [
format!("/cards/auth/{UNKNOWN_ID}"),
"/cards/auth/not-a-uuid".to_owned(),
] {
assert_error(
&app.call(Method::GET, &path, &admin_token(), None).await,
404,
"not_found",
);
}
assert_eq!(
app.connection(&admin_token()).await["history_days"],
30,
"nothing changed"
);
assert_eq!(
app.stub.requests_to("/sessions/").len(),
0,
"nothing revoked"
);
// Without a token: 401; without the protocol header: 426.
assert_error(
&app.public(Method::GET, "/cards/providers", None).await,
401,
"unauthorized",
);
}
#[tokio::test]
async fn deleting_a_linked_expense_moves_the_purchase_back_without_audit_or_push() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![booked(
"r",
&days_ago(1),
"412.00",
"ICA Maxi Exempel",
)]);
app.connect(&admin_token(), 30).await;
let expense_id = sync_fixtures::new_id();
let created = sync_fixtures::push_one(
&app,
&admin_token(),
sync_fixtures::exp(sync_fixtures::expense(
&expense_id,
"2026-10-17T11:00:00.000Z",
)),
)
.await;
sync_fixtures::accepted(&created);
let purchase = app.purchases(&admin_token()).await[0].clone();
let linked = app
.call(
Method::PUT,
&format!("/cards/purchases/{}", purchase["id"].as_str().unwrap()),
&admin_token(),
Some(json!({"expense_id": expense_id, "ignored": false})),
)
.await;
assert_eq!(linked.status, 200, "{}", linked.text());
let audits = sync_fixtures::count(&app, "audit_log").await;
let pushes = app.push.0.lock().unwrap().len();
// The other user deletes the expense.
let mut tombstone = sync_fixtures::expense(&expense_id, "2026-10-17T11:30:00.000Z");
tombstone["deleted_at"] = json!("2026-10-17T11:30:00.000Z");
let deleted =
sync_fixtures::push_one(&app, &member_token(), sync_fixtures::exp(tombstone)).await;
sync_fixtures::accepted(&deleted);
let after = app.purchases(&admin_token()).await[0].clone();
assert_eq!(after["id"], purchase["id"]);
assert_eq!(
after["expense_id"],
Value::Null,
"\"Att hantera\" again (CP-9)"
);
assert!(after["card_version"].as_i64() > linked.json["card_version"].as_i64());
assert_eq!(
sync_fixtures::count(&app, "audit_log").await,
audits + 1,
"only the expense delete is audited"
);
assert_eq!(
app.push.0.lock().unwrap().len(),
pushes + 1,
"only the expense_deleted push"
);
}
#[tokio::test]
async fn the_0400_job_prunes_attempts_older_than_7_days() {
let app = CardsApp::new().await;
app.start_auth(&admin_token(), 30, "android").await;
app.set_now("2026-10-24T11:59:59.000Z");
app.start_auth(&admin_token(), 30, "android").await;
app.set_now("2026-10-24T12:00:01.000Z");
kvitt_server::tasks::scheduler::prune_job(&app.state).await;
assert_eq!(
app.scalar_i64("SELECT COUNT(*) FROM card_auth_attempts")
.await,
1
);
}
#[tokio::test]
async fn the_callback_counts_as_unauthenticated_60_per_minute_per_ip() {
let app = CardsApp::new().await;
for _ in 0..60 {
assert_eq!(app.callback("state=x", "sv").await.status, 200);
}
let limited = app.callback("state=x", "sv").await;
assert_error(&limited, 429, "rate_limited");
assert!(limited.headers.contains_key("retry-after"));
}
@@ -0,0 +1,462 @@
//! Fetching (SPEC §9.12 CP-7, CP-8, CP-14; D-178, D-179): the windowed walk, the split at the 100-row cap, the
//! de-duplication across windows, continuation keys, the end of history, the mapping, the matching of pending and
//! booked transactions, deletion of vanished pending purchases and the recorded bank errors.
use axum::http::Method;
use chrono::{Days, NaiveDate};
use serde_json::{Value, json};
use crate::common::*;
use crate::stub::eb_error;
use crate::support::*;
fn date(text: &str) -> NaiveDate {
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
}
fn windows_asked(app: &CardsApp) -> Vec<(String, String)> {
app.stub
.transaction_requests()
.iter()
.map(|r| (r.query["date_from"].clone(), r.query["date_to"].clone()))
.collect()
}
#[tokio::test]
async fn a_180_day_first_fetch_asks_six_windows_newest_first_without_strategy() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![
booked("r-new", &days_ago(1), "10.00", "Testkaféet Ankan"),
booked("r-old", &days_ago(170), "20.00", "Gamla Testbutiken"),
]);
app.connect(&admin_token(), 180).await;
let asked = windows_asked(&app);
assert_eq!(asked.len(), 6, "{asked:?}");
assert_eq!(asked[0], (days_ago(29), days_ago(0)));
assert_eq!(asked[5].0, days_ago(179), "180 days, today included");
for pair in asked.windows(2) {
assert_eq!(
date(&pair[1].1).checked_add_days(Days::new(1)).unwrap(),
date(&pair[0].0),
"consecutive, non-overlapping, newest first"
);
}
for (from, to) in &asked {
assert_eq!((date(to) - date(from)).num_days(), 29, "30 days each");
}
for r in app.stub.transaction_requests() {
assert!(
!r.query.contains_key("strategy"),
"strategy is never sent (D-178)"
);
assert!(!r.query.contains_key("continuation_key"));
}
assert_eq!(app.purchases(&admin_token()).await.len(), 2);
// The next fetch is incremental: 10 days before the latest stored booking date, one window.
app.stub.clear();
let connection = app.connection(&admin_token()).await["id"]
.as_str()
.unwrap()
.to_owned();
app.refetch(&admin_token(), &connection).await;
assert_eq!(windows_asked(&app), [(days_ago(11), days_ago(0))]);
}
#[tokio::test]
async fn a_window_at_the_cap_is_split_down_to_a_truncated_day_and_deletes_nothing() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![pending(
"p-stays",
&days_ago(20),
"77.00",
"Testbutiken Väntar",
)]);
let connection = app.connect(&admin_token(), 30).await;
assert_eq!(app.purchases(&admin_token()).await.len(), 1);
// 150 rows on one day (more than the cap), 5 elsewhere; the pending purchase has vanished.
let mut rows = many("busy", &days_ago(7), 150);
rows.extend(many("calm", &days_ago(27), 5));
app.stub.set_transactions(rows);
app.stub.clear();
app.refetch(&admin_token(), &connection).await;
let asked = windows_asked(&app);
let busy = days_ago(7);
assert_eq!(
asked.first().unwrap(),
&(days_ago(29), days_ago(0)),
"{asked:?}"
);
assert!(
asked.contains(&(busy.clone(), busy.clone())),
"split down to the one day: {asked:?}"
);
assert_eq!(asked.len(), 11, "{asked:?}");
let purchases = app.purchases(&admin_token()).await;
assert_eq!(
purchases.len(),
1 + 100 + 5,
"the truncated day kept as returned"
);
assert!(
purchases
.iter()
.any(|p| p["merchant"] == "Testbutiken Väntar"),
"an incomplete fetch deletes no pending purchase"
);
assert_eq!(
app.connection(&admin_token()).await["last_fetch_error"],
Value::Null
);
}
#[tokio::test]
async fn rows_returned_outside_their_window_are_deduplicated_by_reference() {
let app = CardsApp::new().await;
let mut late = booked("dup", &days_ago(30), "11.00", "Gränsbutiken");
late["_bank_date"] = json!(days_ago(29));
let mut early = booked("dup", &days_ago(30), "99.00", "Gränsbutiken");
early["_bank_date"] = json!(days_ago(30));
app.stub.set_transactions(vec![late, early]);
app.connect(&admin_token(), 90).await;
assert_eq!(windows_asked(&app).len(), 3);
let purchases = app.purchases(&admin_token()).await;
assert_eq!(purchases.len(), 1, "{purchases:#?}");
assert_eq!(
purchases[0]["amount_ore"], 1100,
"the newer window's occurrence wins"
);
assert_eq!(
purchases[0]["purchase_date"],
days_ago(30),
"kept as returned"
);
}
#[tokio::test]
async fn a_continuation_key_is_still_followed() {
let app = CardsApp::new().await;
*app.stub.state.paginate.lock().unwrap() = true;
app.stub.set_transactions(many("page", &days_ago(3), 250));
app.connect(&admin_token(), 7).await;
let requests = app.stub.transaction_requests();
assert_eq!(requests.len(), 3);
assert_eq!(requests[1].query["continuation_key"], "100");
assert_eq!(
requests[2].query["date_from"],
days_ago(6),
"within its window"
);
assert_eq!(app.purchases(&admin_token()).await.len(), 250);
}
#[tokio::test]
async fn wrong_transactions_period_ends_the_walk_back_and_the_fetch_succeeds() {
let app = CardsApp::new().await;
*app.stub.state.history_from.lock().unwrap() =
Some(today().checked_sub_days(Days::new(75)).unwrap());
app.stub.set_transactions(vec![
booked("a", &days_ago(5), "10.00", "Butik A"),
booked("b", &days_ago(65), "20.00", "Butik B"),
]);
app.connect(&admin_token(), 180).await;
assert_eq!(
windows_asked(&app).len(),
4,
"the fourth window answered WRONG_TRANSACTIONS_PERIOD"
);
let c = app.connection(&admin_token()).await;
assert_eq!(c["last_fetch_error"], Value::Null);
assert!(c["last_fetch_at"].is_string());
assert_eq!(app.purchases(&admin_token()).await.len(), 2);
}
#[tokio::test]
async fn transactions_map_per_cp_7_and_personal_fields_are_never_stored_or_served() {
let app = CardsApp::new().await;
let mut fee = booked("fee", &days_ago(2), "25.00", "Årsavgift");
fee["bank_transaction_code"] = json!({"code": "FEE"});
let mut foreign = booked("eur", &days_ago(3), "-12.50", "Museum Example");
foreign["transaction_amount"]["currency"] = json!("EUR");
let mut no_ref = booked("x", &days_ago(4), "8.00", "Utan Referens");
no_ref.as_object_mut().unwrap().remove("entry_reference");
let mut twin = no_ref.clone();
twin["transaction_id"] = json!("tid-twin");
let mut multi = booked("multi", &days_ago(5), "1234.56", "x");
multi["remittance_information"] = json!([" Rad ett ", "rad två"]);
let mut nameless = booked("nameless", &days_ago(5), "3.00", "");
nameless["remittance_information"] = json!([]);
app.stub.set_transactions(vec![
tx(
Some("credit"),
"BOOK",
"CRDT",
&days_ago(1),
"5000.00",
"Inbetalning",
),
fee,
foreign,
no_ref,
twin,
multi,
nameless,
booked("zero", &days_ago(6), "0.00", "Nollbelopp"),
tx(
Some("rjct"),
"RJCT",
"DBIT",
&days_ago(6),
"9.00",
"Avvisad",
),
]);
app.connect(&admin_token(), 30).await;
let purchases = app.purchases(&admin_token()).await;
assert_eq!(
purchases.len(),
7,
"zero and rejected skipped: {purchases:#?}"
);
let credit = by_merchant(&purchases, "Inbetalning");
assert_eq!(
(credit["direction"].as_str(), credit["amount_ore"].as_i64()),
(Some("credit"), Some(500_000))
);
let fee = by_merchant(&purchases, "Årsavgift");
assert_eq!(
fee["direction"], "debit",
"a debit FEE is a purchase to handle (D-179)"
);
let eur = by_merchant(&purchases, "Museum Example");
assert_eq!(
(
&eur["amount_ore"],
&eur["original_amount"],
&eur["original_currency"]
),
(&Value::Null, &json!("12.50"), &json!("EUR"))
);
assert_eq!(
by_merchant(&purchases, "Rad ett rad två")["amount_ore"],
123_456
);
assert!(
purchases.iter().any(|p| p["merchant"] == "Kortköp"),
"no merchant text: Kortköp"
);
assert_eq!(
purchases
.iter()
.filter(|p| p["merchant"] == "Utan Referens")
.count(),
2,
"identical rows without reference are told apart by their index"
);
let refs: Vec<String> = sqlx::query_scalar(
"SELECT provider_ref FROM card_purchases WHERE merchant = 'Utan Referens'",
)
.fetch_all(&app.state.db)
.await
.unwrap();
assert!(
refs.iter().all(|r| r.starts_with("h:") && r.len() == 66),
"{refs:?}"
);
let served = app.card_sync(&admin_token()).await.to_string();
let stored = sqlx::query_scalar::<_, String>(
"SELECT group_concat(quote(provider_ref) || merchant || coalesce(original_amount, ''), '|') FROM card_purchases",
)
.fetch_one(&app.state.db)
.await
.unwrap();
let connection: String =
sqlx::query_scalar("SELECT session_id || account_uid FROM card_connections")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(connection, "sess-secret-1acc-secret-1", "kept server-side");
for never in [
HOLDER_NAME,
CARD_NUMBER,
"4711",
MERCHANT_TOWN,
"tid-",
"PURCHASE",
"FEE",
] {
assert!(!served.contains(never), "served {never}");
assert!(!stored.contains(never), "stored {never}");
}
for never in [
"sess-secret-1",
"acc-secret-1",
"provider_ref",
"entry_reference",
] {
assert!(!served.contains(never), "served {never}");
}
}
#[tokio::test]
async fn pending_turning_booked_keeps_the_purchase_by_reference_or_by_match() {
let app = CardsApp::new().await;
let expense = app
.insert_expense(ACCOUNT_1, GROCERIES, 9900, ADMIN_ID, (4950, 4950), false)
.await;
app.stub.set_transactions(vec![
pending("p1", &days_ago(3), "99.00", "Testbutik Ett"),
pending("p2", &days_ago(4), "45.00", "Bokhandeln Exempel 123"),
pending("p3", &days_ago(4), "45.00", "Annan Butik"),
pending("p4", &days_ago(5), "12.00", "Försvinner"),
pending("p5", &days_ago(5), "13.00", "Försvinner Ignorerad"),
]);
let connection = app.connect(&admin_token(), 30).await;
let before = app.purchases(&admin_token()).await;
let id_of = |m: &str| by_merchant(&before, m)["id"].as_str().unwrap().to_owned();
let app_ref = &app;
let put = |id: String, body: Value| {
let path = format!("/cards/purchases/{id}");
async move {
app_ref
.call(Method::PUT, &path, &admin_token(), Some(body))
.await
}
};
assert_eq!(
put(
id_of("Testbutik Ett"),
json!({"expense_id": expense, "ignored": false})
)
.await
.status,
200
);
assert_eq!(
put(
id_of("Försvinner Ignorerad"),
json!({"expense_id": null, "ignored": true})
)
.await
.status,
200
);
// p1 booked under its own reference; p2 booked under a new reference; p3, p4, p5 no longer reported.
app.stub.set_transactions(vec![
booked("p1", &days_ago(2), "99.00", "Testbutik Ett"),
booked("b2", &days_ago(2), "45.00", "BOKHANDELN Exempel"),
]);
app.refetch(&admin_token(), &connection).await;
let after = app.purchases(&admin_token()).await;
let p1 = after
.iter()
.find(|p| p["id"] == id_of("Testbutik Ett").as_str())
.unwrap();
assert_eq!(
(p1["status"].as_str(), p1["booking_date"].as_str()),
(Some("booked"), Some(days_ago(2).as_str()))
);
assert_eq!(p1["expense_id"], expense.as_str(), "the link is kept");
let p2 = after
.iter()
.find(|p| p["id"] == id_of("Bokhandeln Exempel 123").as_str())
.unwrap();
assert_eq!(
p2["status"], "booked",
"taken over by CP-8's secondary rule"
);
assert_eq!(p2["merchant"], "BOKHANDELN Exempel");
let refs: Vec<String> = sqlx::query_scalar(
"SELECT provider_ref FROM card_purchases WHERE deleted_at IS NULL ORDER BY provider_ref",
)
.fetch_all(&app.state.db)
.await
.unwrap();
assert_eq!(refs, ["b2", "p1", "p5"]);
assert!(
after
.iter()
.any(|p| p["merchant"] == "Försvinner Ignorerad"),
"an ignored pending purchase stays"
);
assert_eq!(
after.len(),
3,
"p3 (other merchant) and p4 vanished and were deleted: {after:#?}"
);
let tombstones = app.card_sync(&admin_token()).await["purchases"]
.as_array()
.unwrap()
.iter()
.filter(|p| !p["deleted_at"].is_null())
.cloned()
.collect::<Vec<_>>();
assert_eq!(tombstones.len(), 2);
for t in tombstones {
assert_eq!(
(t["merchant"].as_str(), t["purchase_date"].as_str()),
(Some(""), Some("1970-01-01"))
);
assert_eq!(
(&t["amount_ore"], &t["expense_id"], &t["ignored"]),
(&Value::Null, &Value::Null, &json!(false))
);
}
}
#[tokio::test]
async fn bank_errors_are_recorded_per_cp_14_and_cleared_by_a_success() {
let app = CardsApp::new().await;
let connection = app.connect(&admin_token(), 7).await;
let first_fetch = app.connection(&admin_token()).await["last_fetch_at"].clone();
for (answer, recorded) in [
(eb_error(500, "ASPSP_ERROR"), "bank_unavailable"),
(eb_error(503, ""), "bank_unavailable"),
(eb_error(429, "ASPSP_RATE_LIMIT_EXCEEDED"), "rate_limited"),
(eb_error(400, "WRONG_REQUEST_PARAMETERS"), "failed"),
] {
app.stub.script_transactions(&[answer]);
let reply = app.refetch(&admin_token(), &connection).await;
assert_eq!(
reply.json["fetched"], true,
"a failed fetch still answers 200"
);
let c = app.connection(&admin_token()).await;
assert_eq!(c["last_fetch_error"], recorded);
assert_eq!(c["status"], "active");
assert_eq!(
c["last_fetch_at"], first_fetch,
"last_fetch_at is the last successful fetch"
);
}
app.refetch(&admin_token(), &connection).await;
assert_eq!(
app.connection(&admin_token()).await["last_fetch_error"],
Value::Null
);
// An expired session at the bank expires the connection; purchases stay; refresh is refused.
app.stub
.set_transactions(vec![booked("k", &days_ago(1), "5.00", "Kvar")]);
app.refetch(&admin_token(), &connection).await;
app.stub
.script_transactions(&[eb_error(401, "EXPIRED_SESSION")]);
app.refetch(&admin_token(), &connection).await;
let c = app.connection(&admin_token()).await;
assert_eq!(
(c["status"].as_str(), c["last_fetch_error"].as_str()),
(Some("expired"), Some("consent_expired"))
);
assert_eq!(app.purchases(&admin_token()).await.len(), 1);
let now = app.state.clock.now();
app.clock.set(now + chrono::Duration::seconds(61));
assert_error(
&app.refresh(&admin_token(), &connection).await,
409,
"card_consent_expired",
);
}
@@ -0,0 +1,143 @@
//! 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<Mutex<Vec<u8>>>);
impl Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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::<serde_json::Value>(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::<Vec<_>>()
.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}");
}
}
@@ -0,0 +1,17 @@
//! Card purchase tests (SPEC §9.12, FR-22, E-36 to E-44) against a local Enable Banking stub: connecting and the
//! callback page, the windowed fetch and its mapping, matching and deletion, the background poller, the endpoints and
//! their authorization, the link clearing on expense delete, the rate limit of the callback and the logging rule.
//! The real Enable Banking API is never called.
#[path = "../common/mod.rs"]
pub mod common;
pub mod stub;
pub mod support;
#[path = "../common/sync_fixtures.rs"]
pub mod sync_fixtures;
mod connect;
mod endpoints;
mod fetch;
mod logging;
mod poller;
@@ -0,0 +1,102 @@
//! The background fetch (SPEC §9.12 CP-4, CP-6): the four Stockholm slots, no user headers, the 2-hour rule, the
//! 4-per-24-hours quota, no backlog after downtime, an ended consent expiring the connection; driven with the
//! scripted clock through `poller::tick`.
use kvitt_server::cards::poller;
use serde_json::json;
use crate::common::*;
use crate::support::*;
#[tokio::test]
async fn slots_run_in_the_background_without_user_headers_and_skip_a_recent_success() {
let app = CardsApp::new().await;
app.stub.set_transactions(vec![booked(
"r1",
&days_ago(1),
"10.00",
"Testkaféet Ankan",
)]);
app.connect(&admin_token(), 30).await; // a user-present fetch at 12:00Z (14:00 local)
app.stub.clear();
let mut last = None;
// 11:45 local is due, but a fetch succeeded less than 2 hours ago.
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert!(app.stub.transaction_requests().is_empty());
// 16:45 local (14:45Z): due.
app.set_now("2026-10-17T14:45:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
let requests = app.stub.transaction_requests();
assert_eq!(
requests.len(),
1,
"one window: 10 days before the latest booking"
);
assert!(
requests[0].psu_headers().is_empty(),
"a background fetch sends no PSU header (CP-5)"
);
// The same slot never runs twice.
app.set_now("2026-10-17T15:30:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
let starts: String = sqlx::query_scalar("SELECT background_fetches FROM card_connections")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(starts, r#"["2026-10-17T14:45:00.000Z"]"#);
}
#[tokio::test]
async fn four_background_fetches_in_24_hours_are_the_limit() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
sqlx::query("UPDATE card_connections SET last_fetch_at = '2026-10-16T00:00:00.000Z', background_fetches = ?")
.bind(json!(["2026-10-16T19:50:00.000Z", "2026-10-17T04:45:00.000Z", "2026-10-17T09:45:00.000Z",
"2026-10-17T14:45:00.000Z"]).to_string())
.execute(&app.state.db)
.await
.unwrap();
app.stub.clear();
let mut last = None;
app.set_now("2026-10-17T19:45:00.000Z");
assert_eq!(
poller::tick(&app.state, &mut last).await,
0,
"4 started in the preceding 24 hours"
);
// The next morning the oldest two have left the window.
app.set_now("2026-10-18T04:46:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
assert_eq!(app.stub.transaction_requests().len(), 1);
}
#[tokio::test]
async fn after_downtime_only_the_due_slot_runs() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
let mut last = None;
app.set_now("2026-10-17T14:45:30.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
app.stub.clear();
// Down for two days and a half: eight slots passed, one runs.
app.set_now("2026-10-20T03:00:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 1);
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert_eq!(app.stub.transaction_requests().len(), 1);
}
#[tokio::test]
async fn an_ended_consent_expires_the_connection_and_nothing_is_fetched() {
let app = CardsApp::new().await;
app.connect(&admin_token(), 7).await;
app.stub.clear();
let mut last = None;
app.set_now("2027-04-15T12:00:00.000Z");
assert_eq!(poller::tick(&app.state, &mut last).await, 0);
assert!(app.stub.transaction_requests().is_empty());
let c = app.connection(&admin_token()).await;
assert_eq!(c["status"], "expired");
}
@@ -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(),
}
}
@@ -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:#?}"))
}