@@ -1730,6 +1730,80 @@ describe("auth duck", () => {
17301730 SENSITIVE_STORAGE_KEYS . AUTH_STATUS ,
17311731 ) ;
17321732 } ) ;
1733+
1734+ // Clearing the stale marker is a cleanup, not the decision. If it were
1735+ // allowed to reach getAuthStatus's outer catch, a known-expired session
1736+ // would answer NOT_AUTHENTICATED and RootNavigator would route an
1737+ // account-bearing wallet to the onboarding auth stack instead of the
1738+ // lock screen that collects the required password re-auth.
1739+ it . each ( [
1740+ [
1741+ "an expired key under a persisted soft lock" ,
1742+ {
1743+ temporaryStore : "encrypted-temp-store" ,
1744+ hashKey : {
1745+ hashKey : "mock-hash-key" ,
1746+ salt : "mock-salt" ,
1747+ generatedAt : Date . now ( ) - 73 * 3600000 ,
1748+ expiresAt : Date . now ( ) - 3600000 ,
1749+ } ,
1750+ } ,
1751+ ] ,
1752+ [
1753+ "a persisted soft lock with no temporary store" ,
1754+ {
1755+ temporaryStore : null ,
1756+ hashKey : {
1757+ hashKey : "mock-hash-key" ,
1758+ salt : "mock-salt" ,
1759+ expiresAt : Date . now ( ) + 3600000 ,
1760+ } ,
1761+ } ,
1762+ ] ,
1763+ ] ) (
1764+ "should still return HASH_KEY_EXPIRED for %s when clearing the stale marker fails" ,
1765+ async ( _label , { temporaryStore, hashKey } ) => {
1766+ const { result } = renderHook ( ( ) => useAuthenticationStore ( ) ) ;
1767+
1768+ act ( ( ) => {
1769+ useAuthenticationStore . setState ( {
1770+ getAuthStatus : originalStoreMethods . getAuthStatus ,
1771+ } ) ;
1772+ } ) ;
1773+
1774+ ( dataStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
1775+ if ( key === STORAGE_KEYS . ACCOUNT_LIST ) {
1776+ return Promise . resolve ( JSON . stringify ( [ mockAccount ] ) ) ;
1777+ }
1778+ return Promise . resolve ( null ) ;
1779+ } ) ;
1780+
1781+ ( secureDataStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
1782+ if ( key === SENSITIVE_STORAGE_KEYS . AUTH_STATUS ) {
1783+ return Promise . resolve ( AUTH_STATUS . LOCKED ) ;
1784+ }
1785+ if ( key === SENSITIVE_STORAGE_KEYS . TEMPORARY_STORE ) {
1786+ return Promise . resolve ( temporaryStore ) ;
1787+ }
1788+ return Promise . resolve ( null ) ;
1789+ } ) ;
1790+
1791+ ( getHashKey as jest . Mock ) . mockResolvedValue ( hashKey ) ;
1792+
1793+ ( secureDataStorage . remove as jest . Mock ) . mockRejectedValue (
1794+ new Error ( "keychain unavailable" ) ,
1795+ ) ;
1796+
1797+ await act ( async ( ) => {
1798+ const status = await result . current . getAuthStatus ( ) ;
1799+ expect ( status ) . toBe ( AUTH_STATUS . HASH_KEY_EXPIRED ) ;
1800+ } ) ;
1801+
1802+ expect ( secureDataStorage . remove ) . toHaveBeenCalledWith (
1803+ SENSITIVE_STORAGE_KEYS . AUTH_STATUS ,
1804+ ) ;
1805+ } ,
1806+ ) ;
17331807 } ) ;
17341808
17351809 describe ( "getAuthStatus with auto-lock timer" , ( ) => {
@@ -1825,6 +1899,36 @@ describe("auth duck", () => {
18251899 ) ;
18261900 } ) ;
18271901
1902+ // Unlike the reads above, secureDataStorage.setItem really does throw on
1903+ // a keychain failure. Letting that reach the outer catch would answer
1904+ // NOT_AUTHENTICATED for a session just decided to be LOCKED, dropping an
1905+ // account-bearing wallet onto the onboarding stack.
1906+ it ( "should still soft-lock when persisting the LOCKED marker fails" , async ( ) => {
1907+ const { result } = renderHook ( ( ) => useAuthenticationStore ( ) ) ;
1908+ restoreGetAuthStatus ( ) ;
1909+
1910+ mockAuthenticatedStorage ( {
1911+ backgroundedAt : Date . now ( ) - 2 * ONE_HOUR_MS ,
1912+ autoLockTimer : AUTO_LOCK_TIMER . ONE_HOUR ,
1913+ } ) ;
1914+
1915+ ( secureDataStorage . setItem as jest . Mock ) . mockRejectedValue (
1916+ new Error ( "Failed to store item in keychain" ) ,
1917+ ) ;
1918+
1919+ await act ( async ( ) => {
1920+ const status = await result . current . getAuthStatus ( ) ;
1921+ expect ( status ) . toBe ( AUTH_STATUS . LOCKED ) ;
1922+ } ) ;
1923+
1924+ // The timestamp is deliberately NOT consumed: with no persisted
1925+ // marker, the next check must re-derive the same lock from elapsed
1926+ // background time rather than resolving AUTHENTICATED.
1927+ expect ( secureDataStorage . remove ) . not . toHaveBeenCalledWith (
1928+ SENSITIVE_STORAGE_KEYS . AUTO_LOCK_BACKGROUNDED_AT ,
1929+ ) ;
1930+ } ) ;
1931+
18281932 it ( "should consume the timestamp and re-anchor a stale hash-key TTL on active use (#924)" , async ( ) => {
18291933 const { result } = renderHook ( ( ) => useAuthenticationStore ( ) ) ;
18301934 restoreGetAuthStatus ( ) ;
@@ -2383,6 +2487,77 @@ describe("auth duck", () => {
23832487 expect ( result . current . isSoftLocked ) . toBe ( true ) ;
23842488 } ) ;
23852489
2490+ // Companion to the module-level "persisting the LOCKED marker fails"
2491+ // test, which starts from NOT_AUTHENTICATED (cold start) and so never
2492+ // reaches softLock. On the AUTHENTICATED -> LOCKED transition the store
2493+ // funnels through softLock, whose deliberate policy is retry-once-then-
2494+ // rethrow. The contract that matters is that the in-memory lock lands
2495+ // first, so a keychain outage surfaces the fault without ever leaving
2496+ // the wallet unlocked.
2497+ it ( "should still land the in-memory soft lock when the keychain write fails throughout" , async ( ) => {
2498+ const { result } = renderHook ( ( ) => useAuthenticationStore ( ) ) ;
2499+ act ( ( ) => {
2500+ useAuthenticationStore . setState ( {
2501+ getAuthStatus : originalStoreMethods . getAuthStatus ,
2502+ softLock : originalStoreMethods . softLock ,
2503+ authStatus : AUTH_STATUS . AUTHENTICATED ,
2504+ isSoftLocked : false ,
2505+ } ) ;
2506+ } ) ;
2507+
2508+ ( dataStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
2509+ if ( key === STORAGE_KEYS . ACCOUNT_LIST ) {
2510+ return Promise . resolve ( JSON . stringify ( [ mockAccount ] ) ) ;
2511+ }
2512+ return Promise . resolve ( null ) ;
2513+ } ) ;
2514+ ( secureDataStorage . getItem as jest . Mock ) . mockImplementation ( ( key ) => {
2515+ if ( key === SENSITIVE_STORAGE_KEYS . TEMPORARY_STORE ) {
2516+ return Promise . resolve ( "encrypted-temp-store" ) ;
2517+ }
2518+ if ( key === SENSITIVE_STORAGE_KEYS . AUTO_LOCK_BACKGROUNDED_AT ) {
2519+ return Promise . resolve ( String ( Date . now ( ) - 7200000 ) ) ; // 2h ago
2520+ }
2521+ if ( key === SENSITIVE_STORAGE_KEYS . AUTO_LOCK_TIMER_SETTING ) {
2522+ return Promise . resolve ( AUTO_LOCK_TIMER . ONE_HOUR ) ;
2523+ }
2524+ return Promise . resolve ( null ) ;
2525+ } ) ;
2526+ ( getHashKey as jest . Mock ) . mockResolvedValue ( {
2527+ hashKey : "mock-hash-key" ,
2528+ salt : "mock-salt" ,
2529+ expiresAt : Date . now ( ) + 3600000 ,
2530+ } ) ;
2531+ ( secureDataStorage . setItem as jest . Mock ) . mockRejectedValue (
2532+ new Error ( "Failed to store item in keychain" ) ,
2533+ ) ;
2534+
2535+ const observedInvalidStates : string [ ] = [ ] ;
2536+ const unsubscribe = useAuthenticationStore . subscribe ( ( state ) => {
2537+ if ( state . authStatus === AUTH_STATUS . LOCKED && ! state . isSoftLocked ) {
2538+ observedInvalidStates . push ( state . authStatus ) ;
2539+ }
2540+ } ) ;
2541+
2542+ await act ( async ( ) => {
2543+ // softLock rethrows after its retry, by design — the module-level
2544+ // swallow moves the failure here rather than hiding it.
2545+ await expect ( result . current . getAuthStatus ( ) ) . rejects . toThrow (
2546+ "Failed to store item in keychain" ,
2547+ ) ;
2548+ } ) ;
2549+
2550+ unsubscribe ( ) ;
2551+ expect ( observedInvalidStates ) . toHaveLength ( 0 ) ;
2552+ expect ( result . current . authStatus ) . toBe ( AUTH_STATUS . LOCKED ) ;
2553+ expect ( result . current . isSoftLocked ) . toBe ( true ) ;
2554+ // The backgrounded-at timestamp survives, so a cold start after the
2555+ // outage re-derives the same lock instead of resolving AUTHENTICATED.
2556+ expect ( secureDataStorage . remove ) . not . toHaveBeenCalledWith (
2557+ SENSITIVE_STORAGE_KEYS . AUTO_LOCK_BACKGROUNDED_AT ,
2558+ ) ;
2559+ } ) ;
2560+
23862561 it ( "should make navigateToLockScreen a no-op while soft-locked" , ( ) => {
23872562 const { result } = renderHook ( ( ) => useAuthenticationStore ( ) ) ;
23882563
0 commit comments