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
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
471 lines
18 KiB
Rust
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());
|
|
}
|