Files
kvitt/server/crates/kvitt-server/tests/cards_api/connect.rs
T
jacobh2 671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
K1: card purchases on the server (schema 4, Enable Banking adapter, E-36 to E-44)
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
2026-10-24 16:43:26 +02:00

457 lines
15 KiB
Rust

//! 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"
);
}