nigig-org/crates/nigig-pay-domain/tests/lifecycle_matrix.rs
andodeki 7fdf436510
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Successful in 2m36s
Payment domain, storage, platform and UI / payment-ui-tests (push) Successful in 3m17s
PDF engine / engine (push) Successful in 46s
PDF engine / makepad-integration (push) Successful in 3m23s
PDF engine / fuzz (push) Has been skipped
test(pay): Phase 5 lifecycle matrix as domain tests (R2.3)
The exit criterion names five scenarios: permission denial, cancellation,
backgrounding, app restart, out-of-order callbacks.

Four of the five are state questions, not hardware questions. A device adds
confidence that Android really emits a given callback sequence; it cannot
tell you how the domain reacts, because the state machine decides that. So
the matrix runs against the real coordinator on every commit instead of when
a phone is free, and a regression names the invariant it broke.

13 tests in crates/nigig-pay-domain/tests/lifecycle_matrix.rs, including the
cases that only exist as races: a success arriving after a cancellation;
backgrounding before a grant (must refuse) versus after one (must be
preserved — the user did authorise); restart before dispatch versus after; a
foreign grant; a replayed grant. Plus a clean-path test so the matrix cannot
pass by refusing everything.

## A coverage hole the matrix found

a_restart_after_dispatch_cannot_redispatch passed with the duplicate-dispatch
budget removed. The state machine refuses Submitted -> Dispatching first, so
the budget was never reached. That is good defence in depth and bad
coverage — nothing proved the budget still worked.

the_dispatch_budget_survives_a_state_machine_walk_back forces the intent back
to Dispatching, exactly as a faulty recovery path would, leaving the budget
as the only guard. It fails when the budget is removed.

The forcing hook is behind a `test-hooks` feature, not #[cfg(test)]: an
integration test is a separate crate and does not see cfg(test), so the
method was simply missing. The isolated runner enables it explicitly,
otherwise that test is silently filtered out and proves nothing.

## Verified by injection

  authorization gate removed  -> 7 of 13 fail
  dispatch budget removed     -> 1 fails (the new one)

## What still needs hardware

That Android actually produces these sequences: permission dialogs,
process-death timing, callback ordering under memory pressure. This file
asserts the response is correct for each sequence; a device confirms the
sequences are the real ones. Different claims, both needed. Tracked as R2.3b.

## Validation

  domain 148 unit + 13 matrix, fmt, clippy -D warnings, bench    pass
  storage 46 / platform 64 / mpesa 29 / pay-ui 78                pass
  clippy -p nigig-pay-ui --no-deps -D warnings                   0 errors
2026-08-02 09:47:52 +00:00

471 lines
18 KiB
Rust

//! Phase 5 exit criterion, as an executable matrix (R2.3).
//!
//! The review requires:
//!
//! > Device/integration test matrix proves permission denial, cancellation,
//! > backgrounding, app restart, and out-of-order callbacks cannot bypass
//! > authorization or corrupt an intent.
//!
//! # Why this is not simply "device work"
//!
//! Four of those five are **state questions**, not hardware questions. What a
//! device adds is confidence that Android really does deliver the callback
//! sequence being modelled. What it cannot add is any guarantee about how the
//! domain reacts — that is decided by the state machine, and a device test
//! would only observe it indirectly, slowly, and flakily.
//!
//! So the matrix is written here, against the real `PaymentCoordinator` and
//! `AuthorizationAttempt`, with the platform faked at its documented
//! boundary. Two things follow:
//!
//! - every scenario runs on every commit, not when a phone is free;
//! - a regression names the invariant it broke, rather than surfacing as a
//! payment that behaved oddly on someone's handset.
//!
//! # What still needs a device
//!
//! That Android emits these sequences at all — permission dialogs, process
//! death timing, callback ordering under memory pressure. This file asserts
//! the *response* is correct for each sequence; a device confirms the
//! *sequences* are the real ones. Both are needed and they are different
//! claims. Recorded in the plan as R2.3b.
//!
//! # The single invariant
//!
//! Across every row: **money never moves without a live, unspent grant, and
//! no lifecycle event settles an intent.**
use nigig_pay_domain::{
AuthorizationAttempt, AuthorizationSignal, CoordinatorError, CreatePayment, GatewayError,
Money, PaymentCoordinator, PaymentIntent, PaymentIntentRepository, PaymentState,
ScriptedBiometric, ScriptedGateway,
};
/// Repository that records every persisted snapshot, so a "restart" can be
/// modelled by replaying what was durably written.
#[derive(Default)]
struct RecordingRepository {
saved: Vec<PaymentIntent>,
}
impl PaymentIntentRepository for RecordingRepository {
type Error = String;
fn save(&mut self, intent: &PaymentIntent) -> Result<(), Self::Error> {
self.saved.push(intent.clone());
Ok(())
}
}
impl RecordingRepository {
/// The last durable state for an intent — what a restart would recover.
fn last(&self, id: &str) -> Option<&PaymentIntent> {
self.saved.iter().rev().find(|i| i.id.as_str() == id)
}
}
fn command(requires_biometric: bool) -> CreatePayment {
CreatePayment {
amount: Money::from_major(1_000),
estimated_fee: Money::from_major(23),
recipient_phone: "0712345678".into(),
recipient_name: "Ada".into(),
kind: "Send Money".into(),
category: None,
note: None,
requires_biometric,
}
}
type Coordinator = PaymentCoordinator<ScriptedGateway, ScriptedBiometric, RecordingRepository>;
/// A coordinator whose gateway would succeed if it were ever reached.
fn coordinator() -> Coordinator {
PaymentCoordinator::new(
ScriptedGateway::always_ok("session-1"),
// Deliberately permissive: the matrix must show that dispatch is
// gated by the *attempt*, not by this.
ScriptedBiometric::succeeds(),
RecordingRepository::default(),
)
}
// ── Row 1: permission denial ────────────────────────────────────────────────
/// Android returns `KeyPermanentlyInvalidated`/no-enrolment/permission-denied
/// before any prompt is shown. Nothing may dispatch.
#[test]
fn permission_denial_cannot_dispatch() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::unavailable(id.as_str(), "no enrolled fingerprint");
let result = coordinator.dispatch_with_authorization(&id, &mut attempt);
assert!(
matches!(result, Err(CoordinatorError::NotAuthorized { .. })),
"a permission denial dispatched: {result:?}"
);
assert_eq!(coordinator.dispatch_count(&id), 0, "money left the device");
assert!(matches!(
coordinator.intent(&id).map(|i| &i.state),
Some(PaymentState::Failed { .. })
));
}
// ── Row 2: cancellation ─────────────────────────────────────────────────────
#[test]
fn cancellation_cannot_dispatch() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::PromptShown);
attempt.apply(id.as_str(), AuthorizationSignal::Cancelled);
assert!(coordinator
.dispatch_with_authorization(&id, &mut attempt)
.is_err());
assert_eq!(coordinator.dispatch_count(&id), 0);
}
/// A success arriving *after* the user cancelled must not revive the payment.
/// On a real device this is an in-flight callback racing the dismissal.
#[test]
fn a_success_racing_a_cancellation_cannot_dispatch() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::Cancelled);
// The late success.
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
assert!(coordinator
.dispatch_with_authorization(&id, &mut attempt)
.is_err());
assert_eq!(coordinator.dispatch_count(&id), 0);
}
// ── Row 3: backgrounding ────────────────────────────────────────────────────
/// The app is backgrounded while the prompt is up. The OS tears the prompt
/// down without a terminal callback, so the attempt is abandoned.
#[test]
fn backgrounding_mid_prompt_cannot_dispatch() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::PromptShown);
attempt.apply(id.as_str(), AuthorizationSignal::SensorEngaged);
attempt.abandon(); // backgrounded
let result = coordinator.dispatch_with_authorization(&id, &mut attempt);
assert!(
matches!(result, Err(CoordinatorError::NotAuthorized { .. })),
"an abandoned prompt dispatched: {result:?}"
);
assert_eq!(coordinator.dispatch_count(&id), 0);
}
/// Backgrounding *after* a grant must not revoke it: the user did authorise,
/// and the payment may legitimately continue when the app resumes.
#[test]
fn backgrounding_after_a_grant_preserves_the_authorization() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
attempt.abandon(); // backgrounded after authorising
coordinator
.dispatch_with_authorization(&id, &mut attempt)
.expect("a granted payment survives backgrounding");
assert_eq!(coordinator.dispatch_count(&id), 1);
}
// ── Row 4: app restart ──────────────────────────────────────────────────────
/// Process death after dispatch. The intent is recovered from storage as
/// `Submitted`; it must not be dispatchable again, because the provider may
/// already hold it (review defect B3).
#[test]
fn a_restart_after_dispatch_cannot_redispatch() {
let mut coordinator = coordinator();
let id = coordinator.create(command(false)).expect("intent created");
coordinator
.dispatch_without_biometric(&id)
.expect("first dispatch");
assert_eq!(coordinator.dispatch_count(&id), 1);
// What a restart would recover.
let recovered = coordinator
.repository()
.last(id.as_str())
.expect("a durable snapshot exists")
.clone();
assert_eq!(
recovered.state,
PaymentState::Submitted,
"an in-flight payment must be recovered as Submitted"
);
assert!(
recovered.provider_operation_id.is_some(),
"the correlation id must survive a restart, or the callback is orphaned"
);
// Re-dispatch is refused. Note *which* guard does it: the state machine
// rejects `Submitted -> Dispatching` before the budget is consulted, so
// this assertion alone does not prove the budget works.
let again = coordinator.dispatch_without_biometric(&id);
assert!(
again.is_err(),
"a recovered in-flight payment was dispatched twice"
);
assert_eq!(coordinator.dispatch_count(&id), 1);
}
/// The duplicate-dispatch budget, exercised directly (review defect B3).
///
/// The test above passes even with the budget removed, because the state
/// machine refuses `Submitted -> Dispatching` first. That is good defence in
/// depth and bad test coverage: it hides whether the budget still works.
///
/// Here the intent is walked back to `Dispatching` — the situation a buggy
/// recovery path or a state-machine change could produce — so the budget is
/// the only thing left standing between a restart and a second dispatch.
#[cfg(feature = "test-hooks")]
#[test]
fn the_dispatch_budget_survives_a_state_machine_walk_back() {
let mut coordinator = coordinator();
let id = coordinator.create(command(false)).expect("intent created");
coordinator
.dispatch_without_biometric(&id)
.expect("first dispatch");
assert_eq!(coordinator.dispatch_count(&id), 1);
// Force the intent back to a dispatchable state, bypassing the state
// machine exactly as a faulty recovery would.
coordinator
.force_state_for_test(&id, PaymentState::Dispatching)
.expect("intent exists");
let again = coordinator.dispatch_without_biometric(&id);
assert!(
matches!(again, Err(CoordinatorError::DuplicateDispatchBlocked(_))),
"the dispatch budget did not block a second send: {again:?}"
);
assert_eq!(
coordinator.dispatch_count(&id),
1,
"money was dispatched twice"
);
}
/// Process death *before* dispatch leaves an intent that never reached the
/// provider. It must not be silently settled, and must still require a grant.
#[test]
fn a_restart_before_dispatch_still_requires_authorization() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let recovered = coordinator
.repository()
.last(id.as_str())
.expect("a durable snapshot exists")
.clone();
assert_eq!(recovered.state, PaymentState::AwaitingUserAuthorization);
// A fresh attempt after the restart, never answered.
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::PromptShown);
assert!(coordinator
.dispatch_with_authorization(&id, &mut attempt)
.is_err());
assert_eq!(coordinator.dispatch_count(&id), 0);
}
// ── Row 5: out-of-order callbacks ───────────────────────────────────────────
/// A grant issued for one payment must not authorise another. On a device
/// this is a callback arriving after the user moved to a different payment.
#[test]
fn a_grant_from_another_payment_cannot_dispatch() {
let mut coordinator = coordinator();
let victim = coordinator.create(command(true)).expect("intent created");
let mut foreign = AuthorizationAttempt::new("some-other-intent");
foreign.apply("some-other-intent", AuthorizationSignal::Succeeded);
let result = coordinator.dispatch_with_authorization(&victim, &mut foreign);
assert!(
matches!(result, Err(CoordinatorError::NotAuthorized { .. })),
"a foreign grant dispatched: {result:?}"
);
assert_eq!(coordinator.dispatch_count(&victim), 0);
// The victim is untouched — not even failed by a stranger's callback.
assert_eq!(
coordinator.intent(&victim).map(|i| &i.state),
Some(&PaymentState::AwaitingUserAuthorization)
);
}
/// One grant, one dispatch — a replayed success cannot spend it twice.
#[test]
fn a_replayed_grant_cannot_dispatch_twice() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
coordinator
.dispatch_with_authorization(&id, &mut attempt)
.expect("first dispatch");
// The callback is redelivered.
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
assert!(coordinator
.dispatch_with_authorization(&id, &mut attempt)
.is_err());
assert_eq!(coordinator.dispatch_count(&id), 1);
}
// ── The invariant, over the whole matrix ────────────────────────────────────
/// Every lifecycle interruption, checked in one place: none of them may
/// produce a dispatch, and none may settle an intent.
///
/// Written as a loop so a new lifecycle event added later has to be
/// classified deliberately rather than silently omitted.
#[test]
fn no_lifecycle_interruption_dispatches_or_settles() {
#[allow(clippy::type_complexity)]
let interruptions: Vec<(&str, Box<dyn Fn(&str) -> AuthorizationAttempt>)> = vec![
(
"permission denied",
Box::new(|id: &str| AuthorizationAttempt::unavailable(id, "no hardware")),
),
(
"cancelled",
Box::new(|id: &str| {
let mut a = AuthorizationAttempt::new(id);
a.apply(id, AuthorizationSignal::Cancelled);
a
}),
),
(
"backgrounded mid-prompt",
Box::new(|id: &str| {
let mut a = AuthorizationAttempt::new(id);
a.apply(id, AuthorizationSignal::PromptShown);
a.abandon();
a
}),
),
(
"prompt never answered",
Box::new(|id: &str| {
let mut a = AuthorizationAttempt::new(id);
a.apply(id, AuthorizationSignal::PromptShown);
a
}),
),
(
"hard error",
Box::new(|id: &str| {
let mut a = AuthorizationAttempt::new(id);
a.apply(
id,
AuthorizationSignal::Errored {
reason: "lockout".into(),
},
);
a
}),
),
(
"not recognised, then abandoned",
Box::new(|id: &str| {
let mut a = AuthorizationAttempt::new(id);
a.apply(id, AuthorizationSignal::PromptShown);
a.apply(id, AuthorizationSignal::NotRecognized);
a.abandon();
a
}),
),
];
for (name, build) in interruptions {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = build(id.as_str());
let result = coordinator.dispatch_with_authorization(&id, &mut attempt);
assert!(result.is_err(), "{name}: dispatched");
assert_eq!(coordinator.dispatch_count(&id), 0, "{name}: money moved");
let state = coordinator.intent(&id).map(|i| i.state.clone());
assert!(
!matches!(state, Some(PaymentState::Confirmed)),
"{name}: settled an intent"
);
}
}
/// The counterpart: the one path that *should* work still does, so the matrix
/// is not passing by refusing everything.
#[test]
fn a_clean_authorization_still_dispatches() {
let mut coordinator = coordinator();
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::PromptShown);
attempt.apply(id.as_str(), AuthorizationSignal::SensorEngaged);
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
coordinator
.dispatch_with_authorization(&id, &mut attempt)
.expect("a clean authorisation must dispatch");
assert_eq!(coordinator.dispatch_count(&id), 1);
assert_eq!(
coordinator.intent(&id).map(|i| &i.state),
Some(&PaymentState::Submitted)
);
}
/// A gateway failure must not be mistaken for an authorization failure: the
/// grant was spent, so a retry needs a *new* authorisation.
#[test]
fn a_gateway_failure_still_consumes_the_grant() {
let mut coordinator = PaymentCoordinator::new(
ScriptedGateway::always_err(GatewayError::SessionTimeout {
reason: "no answer".into(),
}),
ScriptedBiometric::succeeds(),
RecordingRepository::default(),
);
let id = coordinator.create(command(true)).expect("intent created");
let mut attempt = AuthorizationAttempt::new(id.as_str());
attempt.apply(id.as_str(), AuthorizationSignal::Succeeded);
let result = coordinator.dispatch_with_authorization(&id, &mut attempt);
assert!(
result.is_err(),
"an ambiguous gateway result reported success"
);
assert!(
attempt.is_consumed(),
"the grant must be spent even on failure"
);
// And the ambiguous outcome is not retryable on the same grant.
assert!(coordinator
.dispatch_with_authorization(&id, &mut attempt)
.is_err());
}