//! 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, } 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; /// 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 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()); }