671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error codes and a 502 response, examples for every card body and error code, the match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py (157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists match_card_purchase as not ported until K3 ports it (orchestrator's allowance). Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions, transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping), the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day, de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk), CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45, 4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38 and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub (tests/cards_api/, 34 tests); the real API is never called. Infra: Pi compose mounts secrets/cards read-only and passes the two application ids; .env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases". Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design (contract-first landing; K2 lands next). Server, contracts-check and web gates are green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
457 lines
15 KiB
Rust
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"
|
|
);
|
|
}
|