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,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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user