fix(access): only accept END_SESSION from idle so the session cap can't strand funds
The root-level END_SESSION let the 10-minute hard cap (and the End Session button) jump to `locked` from any state, bypassing the money-path guards the machine already has: confirmAbandon with bills stacked, an in-flight dispense, an outbound cash-in payment. Cap fires at minute 10 while a customer's bills sit in the stacker → locked → next unlock resetContext wipes them unpaid; during dispensingCash the done-event is dropped and no transaction record is written. Nothing is lost by scoping it: every transaction terminal state already targets #atm.locked on this branch, so the machine re-locks on its own when the transaction ends. END_SESSION now lives on idle.on only, and useSessionSecurity defers both deadlines until currentState is idle — an expired session re-locks on the first tick back at the menu. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
82fbf12950
commit
5114619fce
3 changed files with 34 additions and 19 deletions
|
|
@ -65,7 +65,8 @@ describe('ATM access control (ADR-003)', () => {
|
|||
// entry-anchored timer can't measure inactivity (it never resets on screen
|
||||
// touches). It's enforced at the DOM layer (useSessionSecurity), which sends
|
||||
// END_SESSION on true idleness / at the hard cap. The machine's contract is
|
||||
// just: END_SESSION re-locks from any unlocked state when the gate is active.
|
||||
// just: END_SESSION re-locks from idle when the gate is active, and is
|
||||
// ignored mid-transaction (a transaction re-locks on its own when it ends).
|
||||
describe('session end (button / inactivity / hard cap all route here)', () => {
|
||||
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
|
||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||
|
|
@ -76,9 +77,10 @@ describe('ATM access control (ADR-003)', () => {
|
|||
expect(actor.getSnapshot().value).toBe('locked')
|
||||
})
|
||||
|
||||
it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => {
|
||||
// The absolute session cap must be able to lock mid-transaction, so the
|
||||
// transition lives at the machine root, not only on `idle`.
|
||||
it('END_SESSION is ignored mid-transaction (never strands funds in flight)', () => {
|
||||
// A root-level END_SESSION would bypass confirmAbandon / an in-flight
|
||||
// dispense. The transition lives on `idle` only; a transaction's own
|
||||
// terminal states return to `locked` when it ends.
|
||||
const rateServices = {
|
||||
getExchangeRate: () => Promise.resolve(2500),
|
||||
getAvailableBalance: () => Promise.resolve(1_000_000),
|
||||
|
|
@ -87,9 +89,11 @@ describe('ATM access control (ADR-003)', () => {
|
|||
actor.start()
|
||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
expect(actor.getSnapshot().value).not.toBe('locked') // now inside cashOut
|
||||
const before = actor.getSnapshot().value
|
||||
expect(before).not.toBe('locked') // now inside cashOut
|
||||
actor.send({ type: 'END_SESSION' })
|
||||
expect(actor.getSnapshot().value).toBe('locked')
|
||||
expect(actor.getSnapshot().value).toEqual(before)
|
||||
expect(actor.getSnapshot().context.accessSession).not.toBeNull()
|
||||
})
|
||||
|
||||
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
|
||||
|
|
|
|||
|
|
@ -513,14 +513,6 @@ export function createATMMachine(
|
|||
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
||||
}),
|
||||
},
|
||||
// Root-level session kill switch (ADR-003). Any unlocked state re-locks
|
||||
// on END_SESSION — the "End session" button, the idle-inactivity timer,
|
||||
// and the absolute session cap all route here (see useSessionSecurity).
|
||||
// Placed at the root so the hard cap can lock mid cash-in/out, not just
|
||||
// from idle. Guarded to the active gate so a gate-disabled machine (which
|
||||
// rests at idle) can't be knocked out of it. `locked`'s entry clears the
|
||||
// access session + loaded card. Fail-closed: locking is always allowed.
|
||||
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
|
||||
},
|
||||
states: {
|
||||
// === ACCESS GATE (ADR-003) ===
|
||||
|
|
@ -546,9 +538,20 @@ export function createATMMachine(
|
|||
// touches (the machine can't see raw pointer events), so it would fire
|
||||
// a fixed countdown regardless of activity. Inactivity is measured at
|
||||
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
|
||||
// idleness. The hard session cap is handled the same way. Both re-lock
|
||||
// via the root-level END_SESSION transition above.
|
||||
// idleness. The hard session cap is handled the same way.
|
||||
on: {
|
||||
// Session kill switch (ADR-003): the "End session" button, the
|
||||
// idle-inactivity timer, and the absolute session cap all route here.
|
||||
// Deliberately handled ONLY from `idle`, not at the machine root: a
|
||||
// root-level END_SESSION would bypass the money-path protections
|
||||
// (`confirmAbandon` with bills stacked, an in-flight dispense, an
|
||||
// outbound payment) and strand the customer's funds. Every
|
||||
// transaction terminal state already returns to `locked` on its own,
|
||||
// so a cap that fires mid-transaction has nothing to gain — the
|
||||
// DOM-layer timers defer until the machine is back at idle. Guarded to
|
||||
// the active gate so a gate-disabled machine (which rests at idle)
|
||||
// can't be knocked out of it. `locked`'s entry clears the session.
|
||||
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
|
||||
SELECT_CASH_IN: {
|
||||
target: 'cashIn',
|
||||
actions: ['setStartTime', 'setCashInFee'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue