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"
|
||||
);
|
||||
}
|
||||
@@ -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:#?}"))
|
||||
}
|
||||
@@ -376,7 +376,7 @@ fn generated_document_equals_contract() {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
// The contract has 35 operations (SPEC §7, E-1 to E-35); make sure none were lost on both sides.
|
||||
// The contract has 44 operations (SPEC §7, E-1 to E-44); make sure none were lost on both sides.
|
||||
let count = normalise(&generated)["operations"]
|
||||
.as_object()
|
||||
.map(|paths| {
|
||||
@@ -387,7 +387,7 @@ fn generated_document_equals_contract() {
|
||||
.sum::<usize>()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert_eq!(count, 35);
|
||||
assert_eq!(count, 44);
|
||||
}
|
||||
|
||||
// The comparison itself must be strict: each mutation of the contract below must be detected.
|
||||
|
||||
@@ -24,6 +24,9 @@ const STATUS: &[(&str, u16)] = &[
|
||||
("cannot_revoke_current_device", 409),
|
||||
("no_accounts", 409),
|
||||
("cursor_ahead", 409),
|
||||
("cards_unavailable", 409),
|
||||
("card_consent_expired", 409),
|
||||
("expense_already_linked", 409),
|
||||
("payload_too_large", 413),
|
||||
("unsupported_media_type", 415),
|
||||
("validation_failed", 422),
|
||||
@@ -32,6 +35,7 @@ const STATUS: &[(&str, u16)] = &[
|
||||
("protocol_unsupported", 426),
|
||||
("rate_limited", 429),
|
||||
("internal", 500),
|
||||
("card_bank_unavailable", 502),
|
||||
("not_implemented", 501),
|
||||
("unavailable", 503),
|
||||
];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The migrations are the contract texts byte for byte (`0001_init.sql` = `server-v1.sql`, SPEC §14.1;
|
||||
//! `0002_categories_v2.sql` and `0003_device_version_name.sql` = their copies in `contracts/schema/migrations/`,
|
||||
//! §14.1.1), connections carry the §8.9 pragmas, a fresh database holds the fourteen defaults of AC-7.2, a v1
|
||||
//! database in use upgrades to schema 2 and a v2 database to schema 3 without losing or changing a row.
|
||||
//! `0002_categories_v2.sql`, `0003_device_version_name.sql` and `0004_card_purchases.sql` = their copies in
|
||||
//! `contracts/schema/migrations/`, §14.1.1, §14.1.3), connections carry the §8.9 pragmas, a fresh database holds the
|
||||
//! fourteen defaults of AC-7.2, a v1 database in use upgrades to schema 2, a v2 database to schema 3 and a v3
|
||||
//! database to schema 4 without losing or changing a row.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -79,6 +80,19 @@ fn migration_0003_is_byte_identical_to_contract_migration() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_0004_is_byte_identical_to_contract_migration() {
|
||||
let migration = std::fs::read(manifest("migrations/0004_card_purchases.sql")).unwrap();
|
||||
let contract = std::fs::read(manifest(
|
||||
"../../../contracts/schema/migrations/0004_card_purchases.sql",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
migration == contract,
|
||||
"migrations/0004_card_purchases.sql differs from contracts/schema/migrations/0004_card_purchases.sql"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_migrator_holds_exactly_those_migrations() {
|
||||
let v1 = std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap();
|
||||
@@ -90,17 +104,23 @@ fn the_embedded_migrator_holds_exactly_those_migrations() {
|
||||
"../../../contracts/schema/migrations/0003_device_version_name.sql",
|
||||
))
|
||||
.unwrap();
|
||||
let v4 = std::fs::read_to_string(manifest(
|
||||
"../../../contracts/schema/migrations/0004_card_purchases.sql",
|
||||
))
|
||||
.unwrap();
|
||||
let migrations: Vec<_> = db::MIGRATOR.iter().collect();
|
||||
assert_eq!(migrations.len(), 3);
|
||||
assert_eq!(migrations.len(), 4);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(migrations[0].sql.as_ref(), v1.as_str());
|
||||
assert_eq!(migrations[1].version, 2);
|
||||
assert_eq!(migrations[1].sql.as_ref(), v2.as_str());
|
||||
assert_eq!(migrations[2].version, 3);
|
||||
assert_eq!(migrations[2].sql.as_ref(), v3.as_str());
|
||||
assert_eq!(migrations[3].version, 4);
|
||||
assert_eq!(migrations[3].sql.as_ref(), v4.as_str());
|
||||
// sqlx wraps each migration in one transaction unless it opts out (SPEC §14.1.1 relies on it).
|
||||
assert!(migrations.iter().all(|m| !m.no_tx));
|
||||
assert_eq!(db::SCHEMA_VERSION, 3);
|
||||
assert_eq!(db::SCHEMA_VERSION, 4);
|
||||
}
|
||||
|
||||
/// Every row of `table` in a stable order, each rendered with SQLite's `quote()` per column (type-exact).
|
||||
@@ -468,7 +488,7 @@ async fn a_v2_database_in_use_upgrades_to_schema_3_keeping_every_row() {
|
||||
}
|
||||
}
|
||||
|
||||
db::migrate(pool).await.unwrap();
|
||||
db::MIGRATOR.run_to(3, pool).await.unwrap();
|
||||
|
||||
assert_eq!(foreign_key_violations(pool).await, 0);
|
||||
assert_eq!(
|
||||
@@ -515,6 +535,97 @@ async fn a_v2_database_in_use_upgrades_to_schema_3_keeping_every_row() {
|
||||
"33 characters rejected"
|
||||
);
|
||||
|
||||
// A second run is a no-op.
|
||||
db::MIGRATOR.run_to(3, pool).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_v3_database_in_use_upgrades_to_schema_4_keeping_every_row() {
|
||||
let app = TestApp::at_v3().await;
|
||||
let pool = &app.state.db;
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
"3"
|
||||
);
|
||||
fill_v1(&app).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, source, actor_user_id, actor_device_id, \
|
||||
client_at, server_at, changes, server_version) VALUES ('0192f000-0000-7000-8000-000000000001', 'expense', \
|
||||
'0192e000-0000-7000-8000-000000000002', 'create', 'app', '0192a000-0000-7000-8000-000000000002', NULL, \
|
||||
'2026-10-17T09:06:00.000Z', '2026-10-17T09:06:00.000Z', '[]', 24)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE sync_sequence SET value = 24 WHERE id = 1")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE devices SET app_version_code = 41, app_version_name = '1.4', fcm_token = 'fcm-1' WHERE id = ?")
|
||||
.bind(crate::support::ADMIN_DEVICE_ID)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let tables = user_tables(pool).await;
|
||||
let mut before = Vec::new();
|
||||
for table in &tables {
|
||||
if table != "meta" {
|
||||
before.push((table.clone(), dump(pool, table, "").await));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|(t, rows)| t == "audit_log" && rows.len() == 1)
|
||||
);
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|(t, rows)| t == "expenses" && rows.len() == 2)
|
||||
);
|
||||
|
||||
db::migrate(pool).await.unwrap();
|
||||
|
||||
assert_eq!(foreign_key_violations(pool).await, 0);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
"4"
|
||||
);
|
||||
assert_eq!(
|
||||
scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await,
|
||||
24,
|
||||
"0004 writes no synced row"
|
||||
);
|
||||
for (table, rows) in &before {
|
||||
assert_eq!(&dump(pool, table, "").await, rows, "{table} changed");
|
||||
}
|
||||
let mut want = tables.clone();
|
||||
want.extend(
|
||||
[
|
||||
"card_auth_attempts",
|
||||
"card_connections",
|
||||
"card_purchases",
|
||||
"card_sequence",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
want.sort();
|
||||
assert_eq!(
|
||||
user_tables(pool).await,
|
||||
want,
|
||||
"exactly the four card tables added"
|
||||
);
|
||||
for table in ["card_auth_attempts", "card_connections", "card_purchases"] {
|
||||
assert!(dump(pool, table, "").await.is_empty(), "{table} is empty");
|
||||
}
|
||||
assert_eq!(dump(pool, "card_sequence", "").await, ["1|0"]);
|
||||
|
||||
// A second run is a no-op.
|
||||
db::migrate(pool).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ async fn health_reports_ok_with_versions() {
|
||||
assert_eq!(reply.status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
reply.json,
|
||||
json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 3, "db_ok": true})
|
||||
json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 4, "db_ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
reply.headers["x-kvitt-server-version"],
|
||||
@@ -124,7 +124,7 @@ async fn health_is_503_degraded_when_the_database_is_gone() {
|
||||
assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(reply.json["status"], "degraded");
|
||||
assert_eq!(reply.json["db_ok"], false);
|
||||
assert_eq!(reply.json["schema_version"], 3);
|
||||
assert_eq!(reply.json["schema_version"], 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -45,6 +45,11 @@ impl TestApp {
|
||||
Self::migrated_to(Some(2)).await
|
||||
}
|
||||
|
||||
/// A database migrated to schema 3 only (before 0004).
|
||||
pub async fn at_v3() -> Self {
|
||||
Self::migrated_to(Some(3)).await
|
||||
}
|
||||
|
||||
async fn migrated_to(target: Option<i64>) -> Self {
|
||||
let dir = TempDir::new();
|
||||
let config = Config::from_lookup(|k| match k {
|
||||
|
||||
@@ -72,6 +72,17 @@ fn check(name: &str, example: &Value) -> bool {
|
||||
"E-33-settlement-proposal/response.json" => ProposalResponse,
|
||||
"E-34-analytics-trends/response.json" => TrendsResponse,
|
||||
"E-35-analytics-forecast/response.json" => ForecastResponse,
|
||||
"E-36-card-providers/response.json" => CardProviderList,
|
||||
"E-37-card-auth/request.json" => CreateCardAuthRequest,
|
||||
"E-37-card-auth/response.json" => CardAttempt,
|
||||
"E-38-card-attempt/response.json" => CardAttempt,
|
||||
"E-40-card-sync/response.json" => CardSyncResponse,
|
||||
"E-41-card-connection-update/request.json" => PatchCardConnectionRequest,
|
||||
"E-41-card-connection-update/response.json" => CardConnection,
|
||||
"E-42-card-refresh/response.json" => CardRefreshResponse,
|
||||
"E-43-card-disconnect/response.json" => CardConnection,
|
||||
"E-44-card-purchase-update/request.json" => PutCardPurchaseRequest,
|
||||
"E-44-card-purchase-update/response.json" => CardPurchase,
|
||||
"sync/pull-empty-page.json" => SyncPullResponse,
|
||||
"sync/pull-page.json" => SyncPullResponse,
|
||||
"sync/pull-page-categories-v2.json" => SyncPullResponse,
|
||||
@@ -133,4 +144,9 @@ fn absent_and_null_are_distinct_where_the_contract_says_so() {
|
||||
// E-12: target_user_id may be absent.
|
||||
let invite: CreateInviteRequest = serde_json::from_str(r#"{"kind": "partner"}"#).unwrap();
|
||||
assert_eq!(invite.target_user_id, None);
|
||||
// E-44: both keys required, expense_id nullable.
|
||||
assert!(serde_json::from_str::<PutCardPurchaseRequest>(r#"{"ignored": true}"#).is_err());
|
||||
let unlink: PutCardPurchaseRequest =
|
||||
serde_json::from_str(r#"{"expense_id": null, "ignored": false}"#).unwrap();
|
||||
assert_eq!(unlink.expense_id, None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user