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