Files
kvitt/server/crates/kvitt-server/tests/cards_api/endpoints.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

739 lines
21 KiB
Rust

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