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,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"));
|
||||
}
|
||||
Reference in New Issue
Block a user