@oxy.so/core 1.18.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/cjs/HttpService.js +159 -111
- package/dist/cjs/OxyServices.errors.js +48 -1
- package/dist/cjs/OxyServices.js +263 -60
- package/dist/cjs/api/accounts.js +231 -0
- package/dist/cjs/api/agency.js +102 -0
- package/dist/cjs/api/appData.js +45 -0
- package/dist/cjs/api/apps.js +197 -0
- package/dist/cjs/api/assets.js +329 -0
- package/dist/cjs/api/auth.js +900 -0
- package/dist/cjs/api/billing.js +44 -0
- package/dist/cjs/api/civic.js +289 -0
- package/dist/cjs/api/contacts.js +16 -0
- package/dist/cjs/api/devices.js +351 -0
- package/dist/cjs/api/follows.js +240 -0
- package/dist/cjs/api/identity.js +569 -0
- package/dist/cjs/api/linkedAccounts.js +48 -0
- package/dist/cjs/api/nodes.js +75 -0
- package/dist/cjs/api/notifications.js +75 -0
- package/dist/cjs/api/privacy.js +105 -0
- package/dist/cjs/api/reputation.js +74 -0
- package/dist/cjs/api/session.js +177 -0
- package/dist/cjs/api/store.js +199 -0
- package/dist/cjs/api/topics.js +66 -0
- package/dist/cjs/api/users.js +309 -0
- package/dist/cjs/boot/sessionColdBoot.js +4 -4
- package/dist/cjs/civic/index.js +16 -0
- package/dist/cjs/civic/payloads.js +156 -0
- package/dist/cjs/client/context.js +2 -0
- package/dist/cjs/crypto/identityLink.js +5 -5
- package/dist/cjs/crypto/index.js +44 -0
- package/dist/cjs/crypto/internal.js +37 -0
- package/dist/cjs/crypto/keyManager.js +2 -2
- package/dist/cjs/crypto/polyfill.js +9 -80
- package/dist/cjs/crypto/recoveryPhrase.js +1 -0
- package/dist/cjs/crypto/registrationPow.js +1 -1
- package/dist/cjs/crypto/signatureService.js +1 -0
- package/dist/cjs/i18n/accountCategoryLabels.js +3 -3
- package/dist/cjs/i18n/accountRoleLabels.js +7 -7
- package/dist/cjs/i18n/index.js +130 -52
- package/dist/cjs/i18n/locales/{ar-SA.json → ar-SA.js} +134 -52
- package/dist/cjs/i18n/locales/{ca-ES.json → ca-ES.js} +134 -52
- package/dist/{esm/i18n/locales/de-DE.json → cjs/i18n/locales/de-DE.js} +136 -54
- package/dist/{esm/i18n/locales/en-US.json → cjs/i18n/locales/en-US.js} +163 -245
- package/dist/{esm/i18n/locales/es-ES.json → cjs/i18n/locales/es-ES.js} +163 -245
- package/dist/cjs/i18n/locales/{fr-FR.json → fr-FR.js} +134 -52
- package/dist/cjs/i18n/locales/{it-IT.json → it-IT.js} +134 -52
- package/dist/{esm/i18n/locales/ja-JP.json → cjs/i18n/locales/ja-JP.js} +134 -52
- package/dist/cjs/i18n/locales/{ko-KR.json → ko-KR.js} +134 -52
- package/dist/{esm/i18n/locales/pt-PT.json → cjs/i18n/locales/pt-PT.js} +136 -54
- package/dist/{esm/i18n/locales/zh-CN.json → cjs/i18n/locales/zh-CN.js} +135 -53
- package/dist/cjs/i18n/reputationCategoryLabels.js +2 -2
- package/dist/cjs/i18n/trustTierLabels.js +2 -2
- package/dist/cjs/index.js +55 -263
- package/dist/cjs/inference/index.js +21 -0
- package/dist/cjs/server/OxyServer.js +521 -0
- package/dist/cjs/server/auth.js +2 -2
- package/dist/cjs/server/index.js +23 -6
- package/dist/cjs/server/middleware.js +1154 -0
- package/dist/cjs/server/namespaces.js +214 -0
- package/dist/cjs/server/rateLimit.js +2 -2
- package/dist/cjs/server/requesterAssertion.js +5 -5
- package/dist/cjs/server/securityHeaders.js +3 -3
- package/dist/cjs/server/traffic.js +7 -6
- package/dist/cjs/server/trafficNodeHttp.js +9 -3
- package/dist/cjs/server/userInvalidation.js +5 -5
- package/dist/cjs/session/accountDialogController.js +23 -23
- package/dist/cjs/session/authStateStore.js +2 -2
- package/dist/cjs/session/identitySession.js +3 -3
- package/dist/cjs/session/index.js +131 -0
- package/dist/cjs/session/refresh.js +37 -30
- package/dist/cjs/session/sessionClientHost.js +5 -5
- package/dist/cjs/session/sharedDeviceCredential.js +3 -2
- package/dist/cjs/shared/utils/networkUtils.js +2 -2
- package/dist/cjs/utils/accountCacheSweep.js +11 -15
- package/dist/cjs/utils/asyncUtils.js +22 -163
- package/dist/cjs/utils/authHelpers.js +2 -2
- package/dist/cjs/utils/authWebUrl.js +3 -4
- package/dist/cjs/utils/avatarUtils.js +2 -2
- package/dist/cjs/utils/cache.js +32 -2
- package/dist/cjs/utils/cacheKey.js +5 -10
- package/dist/cjs/utils/deviceManager.js +17 -16
- package/dist/cjs/utils/edgeRegion.js +2 -3
- package/dist/cjs/utils/identityCacheSweep.js +6 -10
- package/dist/cjs/utils/oxyServiceEnvironment.js +3 -7
- package/dist/cjs/utils/requestUtils.js +40 -33
- package/dist/cjs/utils/sessionUtils.js +0 -2
- package/dist/cjs/utils/tokenClaims.js +34 -0
- package/dist/esm/HttpService.js +160 -112
- package/dist/esm/OxyServices.errors.js +45 -0
- package/dist/esm/OxyServices.js +262 -57
- package/dist/esm/api/accounts.js +218 -0
- package/dist/esm/api/agency.js +95 -0
- package/dist/esm/api/appData.js +40 -0
- package/dist/esm/api/apps.js +191 -0
- package/dist/esm/api/assets.js +324 -0
- package/dist/esm/api/auth.js +858 -0
- package/dist/esm/api/billing.js +40 -0
- package/dist/esm/api/civic.js +250 -0
- package/dist/esm/api/contacts.js +12 -0
- package/dist/esm/api/devices.js +346 -0
- package/dist/esm/api/follows.js +236 -0
- package/dist/esm/api/identity.js +527 -0
- package/dist/esm/api/linkedAccounts.js +44 -0
- package/dist/esm/api/nodes.js +71 -0
- package/dist/esm/api/notifications.js +71 -0
- package/dist/esm/api/privacy.js +101 -0
- package/dist/esm/api/reputation.js +70 -0
- package/dist/esm/api/session.js +173 -0
- package/dist/esm/api/store.js +192 -0
- package/dist/esm/api/topics.js +62 -0
- package/dist/esm/api/users.js +272 -0
- package/dist/esm/boot/sessionColdBoot.js +4 -4
- package/dist/esm/civic/index.js +10 -0
- package/dist/esm/civic/payloads.js +118 -0
- package/dist/esm/client/context.js +1 -0
- package/dist/esm/crypto/identityLink.js +5 -5
- package/dist/esm/crypto/index.js +24 -0
- package/dist/esm/crypto/internal.js +19 -0
- package/dist/esm/crypto/keyManager.js +2 -2
- package/dist/esm/crypto/polyfill.js +10 -81
- package/dist/esm/crypto/recoveryPhrase.js +1 -0
- package/dist/esm/crypto/registrationPow.js +1 -1
- package/dist/esm/crypto/signatureService.js +1 -0
- package/dist/esm/i18n/accountCategoryLabels.js +2 -2
- package/dist/esm/i18n/accountRoleLabels.js +1 -1
- package/dist/esm/i18n/index.js +93 -52
- package/dist/esm/i18n/locales/{ar-SA.json → ar-SA.js} +132 -52
- package/dist/esm/i18n/locales/{ca-ES.json → ca-ES.js} +132 -52
- package/dist/{cjs/i18n/locales/de-DE.json → esm/i18n/locales/de-DE.js} +134 -54
- package/dist/{cjs/i18n/locales/en-US.json → esm/i18n/locales/en-US.js} +161 -245
- package/dist/{cjs/i18n/locales/es-ES.json → esm/i18n/locales/es-ES.js} +161 -245
- package/dist/esm/i18n/locales/{fr-FR.json → fr-FR.js} +132 -52
- package/dist/esm/i18n/locales/{it-IT.json → it-IT.js} +132 -52
- package/dist/{cjs/i18n/locales/ja-JP.json → esm/i18n/locales/ja-JP.js} +132 -52
- package/dist/esm/i18n/locales/{ko-KR.json → ko-KR.js} +132 -52
- package/dist/{cjs/i18n/locales/pt-PT.json → esm/i18n/locales/pt-PT.js} +134 -54
- package/dist/{cjs/i18n/locales/zh-CN.json → esm/i18n/locales/zh-CN.js} +133 -53
- package/dist/esm/i18n/reputationCategoryLabels.js +1 -1
- package/dist/esm/i18n/trustTierLabels.js +1 -1
- package/dist/esm/index.js +30 -166
- package/dist/esm/inference/index.js +13 -0
- package/dist/esm/server/OxyServer.js +516 -0
- package/dist/esm/server/auth.js +2 -2
- package/dist/esm/server/index.js +10 -5
- package/dist/esm/server/middleware.js +1147 -0
- package/dist/esm/server/namespaces.js +206 -0
- package/dist/esm/server/rateLimit.js +2 -2
- package/dist/esm/server/requesterAssertion.js +5 -5
- package/dist/esm/server/securityHeaders.js +3 -3
- package/dist/esm/server/traffic.js +7 -6
- package/dist/esm/server/trafficNodeHttp.js +9 -3
- package/dist/esm/server/userInvalidation.js +5 -5
- package/dist/esm/session/accountDialogController.js +23 -23
- package/dist/esm/session/authStateStore.js +2 -2
- package/dist/esm/session/identitySession.js +3 -3
- package/dist/esm/session/index.js +82 -0
- package/dist/esm/session/refresh.js +37 -30
- package/dist/esm/session/sessionClientHost.js +5 -5
- package/dist/esm/session/sharedDeviceCredential.js +3 -2
- package/dist/esm/shared/utils/networkUtils.js +2 -2
- package/dist/esm/utils/accountCacheSweep.js +11 -15
- package/dist/esm/utils/asyncUtils.js +22 -151
- package/dist/esm/utils/authHelpers.js +2 -2
- package/dist/esm/utils/authWebUrl.js +3 -4
- package/dist/esm/utils/avatarUtils.js +2 -2
- package/dist/esm/utils/cache.js +32 -2
- package/dist/esm/utils/cacheKey.js +5 -10
- package/dist/esm/utils/deviceManager.js +17 -16
- package/dist/esm/utils/edgeRegion.js +1 -2
- package/dist/esm/utils/identityCacheSweep.js +6 -10
- package/dist/esm/utils/oxyServiceEnvironment.js +3 -7
- package/dist/esm/utils/requestUtils.js +40 -33
- package/dist/esm/utils/sessionUtils.js +0 -2
- package/dist/esm/utils/tokenClaims.js +31 -0
- package/dist/types/HttpService.d.ts +26 -9
- package/dist/types/OxyServices.d.ts +160 -131
- package/dist/types/OxyServices.errors.d.ts +42 -0
- package/dist/types/api/accounts.d.ts +415 -0
- package/dist/types/api/agency.d.ts +135 -0
- package/dist/types/api/appData.d.ts +27 -0
- package/dist/types/api/apps.d.ts +433 -0
- package/dist/types/api/assets.d.ts +174 -0
- package/dist/types/api/auth.d.ts +766 -0
- package/dist/types/api/billing.d.ts +113 -0
- package/dist/types/api/civic.d.ts +203 -0
- package/dist/types/api/contacts.d.ts +31 -0
- package/{src/mixins/OxyServices.deviceBoot.ts → dist/types/api/devices.d.ts} +130 -110
- package/dist/types/api/follows.d.ts +236 -0
- package/dist/types/api/identity.d.ts +275 -0
- package/dist/types/api/linkedAccounts.d.ts +48 -0
- package/dist/types/api/nodes.d.ts +102 -0
- package/dist/types/api/notifications.d.ts +77 -0
- package/dist/types/api/privacy.d.ts +43 -0
- package/dist/types/api/reputation.d.ts +50 -0
- package/dist/types/api/session.d.ts +85 -0
- package/dist/types/api/store.d.ts +278 -0
- package/dist/types/api/topics.d.ts +34 -0
- package/dist/types/api/users.d.ts +134 -0
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/civic/index.d.ts +10 -0
- package/dist/types/civic/payloads.d.ts +83 -0
- package/dist/types/client/context.d.ts +33 -0
- package/dist/types/crypto/identityLink.d.ts +5 -5
- package/dist/types/crypto/index.d.ts +24 -0
- package/dist/types/crypto/internal.d.ts +19 -0
- package/dist/types/crypto/polyfill.d.ts +6 -9
- package/dist/types/crypto/registrationPow.d.ts +1 -1
- package/dist/types/i18n/accountCategoryLabels.d.ts +1 -1
- package/dist/types/i18n/accountRoleLabels.d.ts +1 -1
- package/dist/types/i18n/index.d.ts +20 -1
- package/dist/types/i18n/locales/ar-SA.d.ts +386 -0
- package/dist/types/i18n/locales/ca-ES.d.ts +386 -0
- package/dist/types/i18n/locales/de-DE.d.ts +386 -0
- package/dist/types/i18n/locales/en-US.d.ts +2034 -0
- package/dist/types/i18n/locales/es-ES.d.ts +2034 -0
- package/dist/types/i18n/locales/fr-FR.d.ts +386 -0
- package/dist/types/i18n/locales/it-IT.d.ts +386 -0
- package/dist/types/i18n/locales/ja-JP.d.ts +386 -0
- package/dist/types/i18n/locales/ko-KR.d.ts +386 -0
- package/dist/types/i18n/locales/pt-PT.d.ts +386 -0
- package/dist/types/i18n/locales/zh-CN.d.ts +386 -0
- package/dist/types/index.d.ts +46 -95
- package/dist/types/inference/index.d.ts +22 -0
- package/dist/types/models/interfaces.d.ts +1 -22
- package/dist/types/server/OxyServer.d.ts +183 -0
- package/dist/types/server/auth.d.ts +10 -7
- package/dist/types/server/index.d.ts +10 -4
- package/dist/types/server/middleware.d.ts +291 -0
- package/dist/types/server/namespaces.d.ts +98 -0
- package/dist/types/server/rateLimit.d.ts +7 -5
- package/dist/types/server/requesterAssertion.d.ts +14 -11
- package/dist/types/server/securityHeaders.d.ts +4 -5
- package/dist/types/server/userInvalidation.d.ts +10 -8
- package/dist/types/session/accountDelegationAccess.d.ts +1 -1
- package/dist/types/session/accountDialogController.d.ts +8 -8
- package/dist/types/session/accountSwitchTargets.d.ts +1 -1
- package/dist/types/session/authStateStore.d.ts +7 -6
- package/dist/types/session/index.d.ts +40 -0
- package/dist/types/utils/accountCacheSweep.d.ts +7 -10
- package/dist/types/utils/apiUtils.d.ts +3 -3
- package/dist/types/utils/asyncUtils.d.ts +11 -58
- package/dist/types/utils/authWebUrl.d.ts +3 -4
- package/dist/types/utils/avatarUtils.d.ts +5 -6
- package/dist/types/utils/cache.d.ts +11 -3
- package/dist/types/utils/commonsApproval.d.ts +1 -1
- package/dist/types/utils/edgeRegion.d.ts +1 -2
- package/dist/types/utils/identityCacheSweep.d.ts +6 -4
- package/dist/types/utils/oauthPkce.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +3 -7
- package/dist/types/utils/requestUtils.d.ts +18 -14
- package/dist/types/utils/tokenClaims.d.ts +9 -0
- package/package.json +68 -7
- package/src/HttpService.ts +166 -131
- package/src/OxyServices.errors.ts +73 -0
- package/src/OxyServices.ts +289 -164
- package/src/api/accounts.ts +579 -0
- package/src/api/agency.ts +277 -0
- package/src/api/appData.ts +63 -0
- package/src/api/apps.ts +596 -0
- package/src/api/assets.ts +416 -0
- package/src/api/auth.ts +1457 -0
- package/src/api/billing.ts +144 -0
- package/src/api/civic.ts +428 -0
- package/src/api/contacts.ts +41 -0
- package/src/api/devices.ts +438 -0
- package/src/api/follows.ts +414 -0
- package/src/api/identity.ts +727 -0
- package/src/api/linkedAccounts.ts +78 -0
- package/src/api/nodes.ts +169 -0
- package/src/api/notifications.ts +135 -0
- package/src/api/privacy.ts +120 -0
- package/src/api/reputation.ts +134 -0
- package/src/api/session.ts +210 -0
- package/src/api/store.ts +435 -0
- package/src/api/topics.ts +83 -0
- package/src/api/users.ts +357 -0
- package/src/boot/sessionColdBoot.ts +4 -4
- package/src/civic/index.ts +12 -0
- package/src/civic/payloads.ts +175 -0
- package/src/client/context.ts +34 -0
- package/src/crypto/identityLink.ts +5 -5
- package/src/crypto/index.ts +39 -0
- package/src/crypto/internal.ts +20 -0
- package/src/crypto/keyManager.ts +2 -2
- package/src/crypto/polyfill.ts +9 -89
- package/src/crypto/recoveryPhrase.ts +1 -0
- package/src/crypto/registrationPow.ts +1 -1
- package/src/crypto/signatureService.ts +1 -0
- package/src/i18n/accountCategoryLabels.ts +2 -2
- package/src/i18n/accountRoleLabels.ts +2 -2
- package/src/i18n/index.ts +105 -54
- package/src/i18n/locales/{ar-SA.json → ar-SA.ts} +132 -52
- package/src/i18n/locales/{ca-ES.json → ca-ES.ts} +132 -52
- package/{dist/cjs/i18n/locales/locales/de-DE.json → src/i18n/locales/de-DE.ts} +134 -54
- package/src/i18n/locales/{en-US.json → en-US.ts} +161 -245
- package/{dist/cjs/i18n/locales/locales/es-ES.json → src/i18n/locales/es-ES.ts} +161 -245
- package/src/i18n/locales/{fr-FR.json → fr-FR.ts} +132 -52
- package/src/i18n/locales/{it-IT.json → it-IT.ts} +132 -52
- package/{dist/cjs/i18n/locales/locales/ja-JP.json → src/i18n/locales/ja-JP.ts} +132 -52
- package/src/i18n/locales/{ko-KR.json → ko-KR.ts} +132 -52
- package/src/i18n/locales/{pt-PT.json → pt-PT.ts} +134 -54
- package/src/i18n/locales/{zh-CN.json → zh-CN.ts} +133 -53
- package/src/i18n/reputationCategoryLabels.ts +1 -1
- package/src/i18n/trustTierLabels.ts +1 -1
- package/src/index.ts +170 -501
- package/src/inference/index.ts +48 -0
- package/src/models/interfaces.ts +1 -22
- package/src/server/OxyServer.ts +637 -0
- package/src/server/auth.ts +12 -8
- package/src/server/index.ts +28 -5
- package/src/server/middleware.ts +1520 -0
- package/src/server/namespaces.ts +257 -0
- package/src/server/rateLimit.ts +10 -9
- package/src/server/requesterAssertion.ts +13 -10
- package/src/server/securityHeaders.ts +7 -8
- package/src/server/traffic.ts +6 -6
- package/src/server/trafficNodeHttp.ts +7 -3
- package/src/server/userInvalidation.ts +9 -9
- package/src/session/accountDelegationAccess.ts +1 -1
- package/src/session/accountDialogController.ts +24 -24
- package/src/session/accountSwitchTargets.ts +1 -1
- package/src/session/authStateStore.ts +9 -8
- package/src/session/identitySession.ts +3 -3
- package/src/session/index.ts +210 -0
- package/src/session/refresh.ts +36 -30
- package/src/session/sessionClientHost.ts +5 -5
- package/src/session/sharedDeviceCredential.ts +3 -2
- package/src/shared/utils/networkUtils.ts +2 -2
- package/src/utils/accountCacheSweep.ts +11 -15
- package/src/utils/apiUtils.ts +3 -3
- package/src/utils/asyncUtils.ts +38 -235
- package/src/utils/authHelpers.ts +2 -2
- package/src/utils/authWebUrl.ts +3 -4
- package/src/utils/avatarUtils.ts +4 -7
- package/src/utils/cache.ts +36 -5
- package/src/utils/cacheKey.ts +5 -9
- package/src/utils/commonsApproval.ts +1 -1
- package/src/utils/deviceManager.ts +17 -16
- package/src/utils/edgeRegion.ts +1 -2
- package/src/utils/identityCacheSweep.ts +7 -12
- package/src/utils/oauthPkce.ts +2 -2
- package/src/utils/oxyServiceEnvironment.ts +3 -7
- package/src/utils/requestUtils.ts +48 -44
- package/src/utils/sessionUtils.ts +0 -2
- package/src/utils/tokenClaims.ts +38 -0
- package/dist/cjs/.tsbuildinfo +0 -1
- package/dist/cjs/OxyServices.base.js +0 -392
- package/dist/cjs/i18n/locales/locales/ar-SA.json +0 -306
- package/dist/cjs/i18n/locales/locales/ca-ES.json +0 -306
- package/dist/cjs/i18n/locales/locales/en-US.json +0 -2127
- package/dist/cjs/i18n/locales/locales/fr-FR.json +0 -306
- package/dist/cjs/i18n/locales/locales/it-IT.json +0 -306
- package/dist/cjs/i18n/locales/locales/ko-KR.json +0 -306
- package/dist/cjs/i18n/locales/locales/pt-PT.json +0 -306
- package/dist/cjs/i18n/locales/locales/zh-CN.json +0 -306
- package/dist/cjs/mixins/OxyServices.accounts.js +0 -555
- package/dist/cjs/mixins/OxyServices.agency.js +0 -154
- package/dist/cjs/mixins/OxyServices.analytics.js +0 -49
- package/dist/cjs/mixins/OxyServices.appData.js +0 -108
- package/dist/cjs/mixins/OxyServices.assets.js +0 -854
- package/dist/cjs/mixins/OxyServices.auth.js +0 -1495
- package/dist/cjs/mixins/OxyServices.chains.js +0 -73
- package/dist/cjs/mixins/OxyServices.civic.js +0 -611
- package/dist/cjs/mixins/OxyServices.connectedApps.js +0 -95
- package/dist/cjs/mixins/OxyServices.contacts.js +0 -50
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -191
- package/dist/cjs/mixins/OxyServices.devices.js +0 -116
- package/dist/cjs/mixins/OxyServices.features.js +0 -335
- package/dist/cjs/mixins/OxyServices.followGraph.js +0 -221
- package/dist/cjs/mixins/OxyServices.identity.js +0 -562
- package/dist/cjs/mixins/OxyServices.identityBackup.js +0 -172
- package/dist/cjs/mixins/OxyServices.inference.js +0 -59
- package/dist/cjs/mixins/OxyServices.language.js +0 -125
- package/dist/cjs/mixins/OxyServices.linkedAccounts.js +0 -79
- package/dist/cjs/mixins/OxyServices.location.js +0 -43
- package/dist/cjs/mixins/OxyServices.nodes.js +0 -175
- package/dist/cjs/mixins/OxyServices.notifications.js +0 -63
- package/dist/cjs/mixins/OxyServices.payment.js +0 -158
- package/dist/cjs/mixins/OxyServices.privacy.js +0 -199
- package/dist/cjs/mixins/OxyServices.reputation.js +0 -289
- package/dist/cjs/mixins/OxyServices.security.js +0 -89
- package/dist/cjs/mixins/OxyServices.store.js +0 -266
- package/dist/cjs/mixins/OxyServices.topics.js +0 -137
- package/dist/cjs/mixins/OxyServices.user.js +0 -954
- package/dist/cjs/mixins/OxyServices.utility.js +0 -1402
- package/dist/cjs/mixins/index.js +0 -136
- package/dist/cjs/mixins/mixinHelpers.js +0 -53
- package/dist/cjs/utils/webauthnOrigin.js +0 -51
- package/dist/esm/.tsbuildinfo +0 -1
- package/dist/esm/OxyServices.base.js +0 -388
- package/dist/esm/i18n/locales/locales/ar-SA.json +0 -306
- package/dist/esm/i18n/locales/locales/ca-ES.json +0 -306
- package/dist/esm/i18n/locales/locales/de-DE.json +0 -306
- package/dist/esm/i18n/locales/locales/en-US.json +0 -2127
- package/dist/esm/i18n/locales/locales/es-ES.json +0 -2127
- package/dist/esm/i18n/locales/locales/fr-FR.json +0 -306
- package/dist/esm/i18n/locales/locales/it-IT.json +0 -306
- package/dist/esm/i18n/locales/locales/ja-JP.json +0 -306
- package/dist/esm/i18n/locales/locales/ko-KR.json +0 -306
- package/dist/esm/i18n/locales/locales/pt-PT.json +0 -306
- package/dist/esm/i18n/locales/locales/zh-CN.json +0 -306
- package/dist/esm/mixins/OxyServices.accounts.js +0 -543
- package/dist/esm/mixins/OxyServices.agency.js +0 -151
- package/dist/esm/mixins/OxyServices.analytics.js +0 -46
- package/dist/esm/mixins/OxyServices.appData.js +0 -103
- package/dist/esm/mixins/OxyServices.assets.js +0 -851
- package/dist/esm/mixins/OxyServices.auth.js +0 -1455
- package/dist/esm/mixins/OxyServices.chains.js +0 -70
- package/dist/esm/mixins/OxyServices.civic.js +0 -605
- package/dist/esm/mixins/OxyServices.connectedApps.js +0 -92
- package/dist/esm/mixins/OxyServices.contacts.js +0 -47
- package/dist/esm/mixins/OxyServices.deviceBoot.js +0 -186
- package/dist/esm/mixins/OxyServices.devices.js +0 -113
- package/dist/esm/mixins/OxyServices.features.js +0 -332
- package/dist/esm/mixins/OxyServices.followGraph.js +0 -218
- package/dist/esm/mixins/OxyServices.identity.js +0 -558
- package/dist/esm/mixins/OxyServices.identityBackup.js +0 -169
- package/dist/esm/mixins/OxyServices.inference.js +0 -56
- package/dist/esm/mixins/OxyServices.language.js +0 -122
- package/dist/esm/mixins/OxyServices.linkedAccounts.js +0 -76
- package/dist/esm/mixins/OxyServices.location.js +0 -40
- package/dist/esm/mixins/OxyServices.nodes.js +0 -172
- package/dist/esm/mixins/OxyServices.notifications.js +0 -60
- package/dist/esm/mixins/OxyServices.payment.js +0 -155
- package/dist/esm/mixins/OxyServices.privacy.js +0 -196
- package/dist/esm/mixins/OxyServices.reputation.js +0 -286
- package/dist/esm/mixins/OxyServices.security.js +0 -86
- package/dist/esm/mixins/OxyServices.store.js +0 -263
- package/dist/esm/mixins/OxyServices.topics.js +0 -134
- package/dist/esm/mixins/OxyServices.user.js +0 -951
- package/dist/esm/mixins/OxyServices.utility.js +0 -1397
- package/dist/esm/mixins/index.js +0 -133
- package/dist/esm/mixins/mixinHelpers.js +0 -48
- package/dist/esm/utils/webauthnOrigin.js +0 -48
- package/dist/types/.tsbuildinfo +0 -1
- package/dist/types/OxyServices.base.d.ts +0 -189
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -868
- package/dist/types/mixins/OxyServices.agency.d.ts +0 -176
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -72
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -108
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -371
- package/dist/types/mixins/OxyServices.auth.d.ts +0 -998
- package/dist/types/mixins/OxyServices.chains.d.ts +0 -156
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -520
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -184
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -100
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +0 -185
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -103
- package/dist/types/mixins/OxyServices.features.d.ts +0 -250
- package/dist/types/mixins/OxyServices.followGraph.d.ts +0 -223
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -337
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +0 -129
- package/dist/types/mixins/OxyServices.inference.d.ts +0 -95
- package/dist/types/mixins/OxyServices.language.d.ts +0 -87
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +0 -107
- package/dist/types/mixins/OxyServices.location.d.ts +0 -70
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -198
- package/dist/types/mixins/OxyServices.notifications.d.ts +0 -143
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -117
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -146
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -202
- package/dist/types/mixins/OxyServices.security.d.ts +0 -84
- package/dist/types/mixins/OxyServices.store.d.ts +0 -334
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -110
- package/dist/types/mixins/OxyServices.user.d.ts +0 -600
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -526
- package/dist/types/mixins/index.d.ts +0 -87
- package/dist/types/mixins/mixinHelpers.d.ts +0 -31
- package/dist/types/utils/webauthnOrigin.d.ts +0 -32
- package/src/OxyServices.base.ts +0 -480
- package/src/__tests__/authSocket.test.ts +0 -96
- package/src/__tests__/fixEsmImports.test.ts +0 -132
- package/src/__tests__/httpServiceAbort.test.ts +0 -308
- package/src/__tests__/httpServiceActivityId.test.ts +0 -49
- package/src/__tests__/httpServiceAuthSelfAwait.test.ts +0 -108
- package/src/__tests__/httpServiceCache.test.ts +0 -318
- package/src/__tests__/httpServiceFormEncoded.test.ts +0 -139
- package/src/__tests__/httpServiceQueueDeadlock.test.ts +0 -128
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +0 -160
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +0 -115
- package/src/__tests__/httpServiceWrites.test.ts +0 -175
- package/src/__tests__/inSessionRefresh.test.ts +0 -313
- package/src/__tests__/linkedClient.test.ts +0 -363
- package/src/__tests__/nearExpiryMintStorm.test.ts +0 -165
- package/src/__tests__/parseHttpErrorBody.test.ts +0 -116
- package/src/__tests__/publicInterfaceParity.test.ts +0 -75
- package/src/__tests__/serverValueImportsDeclared.test.ts +0 -120
- package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +0 -159
- package/src/__tests__/serviceTokenWorkloadFallback.test.ts +0 -85
- package/src/__tests__/userIdentity.test.ts +0 -143
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +0 -393
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +0 -325
- package/src/boot/__tests__/sessionColdBoot.test.ts +0 -570
- package/src/crypto/__tests__/backupMaterial.test.ts +0 -86
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +0 -226
- package/src/crypto/__tests__/identityLink.test.ts +0 -18
- package/src/crypto/__tests__/identityMocks.ts +0 -125
- package/src/crypto/__tests__/identityProof.test.ts +0 -81
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +0 -272
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +0 -175
- package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +0 -310
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +0 -217
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +0 -179
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +0 -138
- package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +0 -150
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +0 -227
- package/src/crypto/__tests__/keyManager.test.ts +0 -329
- package/src/crypto/__tests__/polyfill.test.ts +0 -109
- package/src/crypto/__tests__/polyfillOrder.test.ts +0 -114
- package/src/crypto/__tests__/recoveryPhrase.test.ts +0 -61
- package/src/crypto/__tests__/registrationPow.test.ts +0 -61
- package/src/crypto/__tests__/scopedSeed.test.ts +0 -126
- package/src/crypto/__tests__/signChallengeShared.test.ts +0 -63
- package/src/crypto/__tests__/signedRecord.test.ts +0 -230
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +0 -62
- package/src/i18n/__tests__/accountRoleLabels.test.ts +0 -54
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +0 -56
- package/src/i18n/__tests__/trustTierLabels.test.ts +0 -47
- package/src/i18n/locales/de-DE.json +0 -306
- package/src/i18n/locales/es-ES.json +0 -2127
- package/src/i18n/locales/ja-JP.json +0 -306
- package/src/inference/__tests__/OxyInferenceClient.test.ts +0 -899
- package/src/logger/__tests__/logger.test.ts +0 -207
- package/src/mixins/OxyServices.accounts.ts +0 -1330
- package/src/mixins/OxyServices.agency.ts +0 -353
- package/src/mixins/OxyServices.analytics.ts +0 -53
- package/src/mixins/OxyServices.appData.ts +0 -158
- package/src/mixins/OxyServices.assets.ts +0 -964
- package/src/mixins/OxyServices.auth.ts +0 -2083
- package/src/mixins/OxyServices.chains.ts +0 -134
- package/src/mixins/OxyServices.civic.ts +0 -945
- package/src/mixins/OxyServices.connectedApps.ts +0 -212
- package/src/mixins/OxyServices.contacts.ts +0 -73
- package/src/mixins/OxyServices.devices.ts +0 -120
- package/src/mixins/OxyServices.features.ts +0 -454
- package/src/mixins/OxyServices.followGraph.ts +0 -293
- package/src/mixins/OxyServices.identity.ts +0 -787
- package/src/mixins/OxyServices.identityBackup.ts +0 -250
- package/src/mixins/OxyServices.inference.ts +0 -57
- package/src/mixins/OxyServices.language.ts +0 -144
- package/src/mixins/OxyServices.linkedAccounts.ts +0 -129
- package/src/mixins/OxyServices.location.ts +0 -46
- package/src/mixins/OxyServices.nodes.ts +0 -337
- package/src/mixins/OxyServices.notifications.ts +0 -142
- package/src/mixins/OxyServices.payment.ts +0 -163
- package/src/mixins/OxyServices.privacy.ts +0 -219
- package/src/mixins/OxyServices.reputation.ts +0 -436
- package/src/mixins/OxyServices.security.ts +0 -110
- package/src/mixins/OxyServices.store.ts +0 -585
- package/src/mixins/OxyServices.topics.ts +0 -169
- package/src/mixins/OxyServices.user.ts +0 -1208
- package/src/mixins/OxyServices.utility.ts +0 -1803
- package/src/mixins/__tests__/OxyServices.civic.test.ts +0 -1095
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +0 -124
- package/src/mixins/__tests__/OxyServices.identity.test.ts +0 -301
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +0 -341
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +0 -323
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +0 -192
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +0 -135
- package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +0 -139
- package/src/mixins/__tests__/accountEvents.test.ts +0 -158
- package/src/mixins/__tests__/accounts.test.ts +0 -727
- package/src/mixins/__tests__/agency.test.ts +0 -120
- package/src/mixins/__tests__/appData.test.ts +0 -203
- package/src/mixins/__tests__/assetCredentials.test.ts +0 -47
- package/src/mixins/__tests__/assetUpload.test.ts +0 -191
- package/src/mixins/__tests__/authRefusalObservability.test.ts +0 -396
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +0 -130
- package/src/mixins/__tests__/chains.test.ts +0 -113
- package/src/mixins/__tests__/commonsSignIn.test.ts +0 -1147
- package/src/mixins/__tests__/connectedApps.test.ts +0 -146
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +0 -113
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +0 -265
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +0 -173
- package/src/mixins/__tests__/followGraph.test.ts +0 -147
- package/src/mixins/__tests__/followGraphPagination.test.ts +0 -271
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +0 -346
- package/src/mixins/__tests__/getFollowStatuses.test.ts +0 -95
- package/src/mixins/__tests__/getUsersByIds.test.ts +0 -176
- package/src/mixins/__tests__/identityBackup.test.ts +0 -315
- package/src/mixins/__tests__/identityLink.test.ts +0 -89
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +0 -407
- package/src/mixins/__tests__/inferenceFactory.test.ts +0 -58
- package/src/mixins/__tests__/linkedAccounts.test.ts +0 -109
- package/src/mixins/__tests__/notifications.read.test.ts +0 -82
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -129
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +0 -284
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +0 -155
- package/src/mixins/__tests__/reputation.test.ts +0 -522
- package/src/mixins/__tests__/serviceAuth.test.ts +0 -889
- package/src/mixins/__tests__/serviceTokenJwks.test.ts +0 -208
- package/src/mixins/__tests__/store.test.ts +0 -304
- package/src/mixins/__tests__/topics.test.ts +0 -155
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +0 -129
- package/src/mixins/__tests__/userTokenAuth.test.ts +0 -746
- package/src/mixins/__tests__/verifyChallenge.test.ts +0 -161
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +0 -167
- package/src/mixins/__tests__/webauthnAuth.test.ts +0 -279
- package/src/mixins/index.ts +0 -203
- package/src/mixins/mixinHelpers.ts +0 -69
- package/src/server/__tests__/auth.test.ts +0 -78
- package/src/server/__tests__/capabilityTicket.test.ts +0 -111
- package/src/server/__tests__/cors.socket.test.ts +0 -225
- package/src/server/__tests__/cors.test.ts +0 -158
- package/src/server/__tests__/rateLimit.test.ts +0 -257
- package/src/server/__tests__/requesterAssertion.test.ts +0 -359
- package/src/server/__tests__/safeFetch.test.ts +0 -232
- package/src/server/__tests__/securityHeaders.test.ts +0 -387
- package/src/server/__tests__/serviceTokenAttribution.test.ts +0 -396
- package/src/server/__tests__/traffic.test.ts +0 -180
- package/src/server/__tests__/trafficNodeHttp.test.ts +0 -38
- package/src/server/__tests__/userInvalidation.test.ts +0 -203
- package/src/server/__tests__/verifySecret.test.ts +0 -40
- package/src/server/__tests__/workloadIdentity.client.test.ts +0 -16
- package/src/server/__tests__/workloadIdentity.test.ts +0 -128
- package/src/session/__tests__/SessionClient.additive.test.ts +0 -153
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +0 -114
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +0 -91
- package/src/session/__tests__/SessionClient.directory.test.ts +0 -688
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +0 -65
- package/src/session/__tests__/SessionClient.identityPin.test.ts +0 -175
- package/src/session/__tests__/SessionClient.rest.test.ts +0 -119
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +0 -72
- package/src/session/__tests__/SessionClient.socket.test.ts +0 -202
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +0 -81
- package/src/session/__tests__/SessionClient.state.test.ts +0 -78
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +0 -170
- package/src/session/__tests__/accountDelegationAccess.test.ts +0 -104
- package/src/session/__tests__/accountDialogController.test.ts +0 -2502
- package/src/session/__tests__/accountDialogShape.test.ts +0 -120
- package/src/session/__tests__/accountSwitchTargets.test.ts +0 -137
- package/src/session/__tests__/authStateStore.test.ts +0 -451
- package/src/session/__tests__/deviceDirectory.test.ts +0 -422
- package/src/session/__tests__/deviceSwitcherRows.test.ts +0 -223
- package/src/session/__tests__/identityPin.test.ts +0 -131
- package/src/session/__tests__/identitySession.test.ts +0 -194
- package/src/session/__tests__/projectSessionState.test.ts +0 -105
- package/src/session/__tests__/refresh.identityPin.test.ts +0 -307
- package/src/session/__tests__/refresh.test.ts +0 -606
- package/src/session/__tests__/sessionIntegration.test.ts +0 -209
- package/src/session/__tests__/sharedDeviceCredential.test.ts +0 -300
- package/src/utils/__tests__/accountUtils.test.ts +0 -151
- package/src/utils/__tests__/activityId.test.ts +0 -45
- package/src/utils/__tests__/asyncUtils.test.ts +0 -279
- package/src/utils/__tests__/authWebUrl.test.ts +0 -15
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/cacheLifecycle.test.ts +0 -65
- package/src/utils/__tests__/coldBoot.test.ts +0 -496
- package/src/utils/__tests__/commonsApproval.test.ts +0 -60
- package/src/utils/__tests__/identityCacheSweep.test.ts +0 -151
- package/src/utils/__tests__/languageUtils.test.ts +0 -244
- package/src/utils/__tests__/oauthPkce.test.ts +0 -303
- package/src/utils/__tests__/officialOrigins.test.ts +0 -31
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +0 -7
- package/src/utils/__tests__/profileLinks.test.ts +0 -180
- package/src/utils/__tests__/redactUrl.test.ts +0 -33
- package/src/utils/__tests__/registrableApex.test.ts +0 -62
- package/src/utils/__tests__/requestUtilsAbort.test.ts +0 -155
- package/src/utils/__tests__/textNormalization.test.ts +0 -205
- package/src/utils/__tests__/validationUtils.test.ts +0 -703
- package/src/utils/__tests__/webauthnOrigin.test.ts +0 -83
- package/src/utils/webauthnOrigin.ts +0 -52
|
@@ -1,2502 +0,0 @@
|
|
|
1
|
-
import type { DeviceSessionState } from '@oxy.so/contracts';
|
|
2
|
-
import type { OxyServices } from '../../OxyServices';
|
|
3
|
-
import type { User } from '../../models/interfaces';
|
|
4
|
-
import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
|
|
5
|
-
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
6
|
-
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
7
|
-
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
8
|
-
import { logger } from '../../logger';
|
|
9
|
-
import { setPlatformOS } from '../../utils/platform';
|
|
10
|
-
import {
|
|
11
|
-
AccountDialogController,
|
|
12
|
-
createAccountDialogController,
|
|
13
|
-
} from '../accountDialogController';
|
|
14
|
-
|
|
15
|
-
// A SessionClient whose applied state can be driven directly (applyState is
|
|
16
|
-
// protected on the base) — mirrors the existing TestClient pattern.
|
|
17
|
-
class TestSessionClient extends SessionClient {
|
|
18
|
-
static readonly instances = new Set<TestSessionClient>();
|
|
19
|
-
|
|
20
|
-
constructor(clientHost: SessionClientHost) {
|
|
21
|
-
super(clientHost);
|
|
22
|
-
TestSessionClient.instances.add(this);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
set(state: DeviceSessionState): void {
|
|
26
|
-
this.applyState(state);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
for (const client of TestSessionClient.instances) client.stop();
|
|
32
|
-
TestSessionClient.instances.clear();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A valid, empty directory for the host below.
|
|
37
|
-
*
|
|
38
|
-
* `refresh()` reads `GET /session/device/directory` now, so the shared host has
|
|
39
|
-
* to answer it with something the contract accepts — otherwise every test in
|
|
40
|
-
* this file logs "discarded invalid device directory" and the ones asserting on
|
|
41
|
-
* the logger fail for a reason that has nothing to do with their subject.
|
|
42
|
-
*
|
|
43
|
-
* Its `revision` sits deliberately ahead of every state revision these tests
|
|
44
|
-
* use, so the directory-settle re-read (which has its own suite) never fires
|
|
45
|
-
* here and each test's request count stays about the thing it is testing.
|
|
46
|
-
*/
|
|
47
|
-
const EMPTY_DIRECTORY = {
|
|
48
|
-
deviceId: 'device-1',
|
|
49
|
-
revision: 9_000,
|
|
50
|
-
activeContextId: null,
|
|
51
|
-
principals: [],
|
|
52
|
-
updatedAt: 1_720_000_000_000,
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
interface RecordingHost {
|
|
56
|
-
host: SessionClientHost;
|
|
57
|
-
/** Every path the client has requested, in order. */
|
|
58
|
-
urls: string[];
|
|
59
|
-
/** Make the next and every subsequent directory read reject with `error`. */
|
|
60
|
-
failDirectory: (error: unknown) => void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function host(): RecordingHost {
|
|
64
|
-
const urls: string[] = [];
|
|
65
|
-
let directoryError: unknown = null;
|
|
66
|
-
return {
|
|
67
|
-
urls,
|
|
68
|
-
failDirectory: (error: unknown) => {
|
|
69
|
-
directoryError = error;
|
|
70
|
-
},
|
|
71
|
-
host: {
|
|
72
|
-
makeRequest: jest.fn(async (_method: string, url: string) => {
|
|
73
|
-
urls.push(url);
|
|
74
|
-
if (url === '/session/device/directory') {
|
|
75
|
-
if (directoryError !== null) throw directoryError;
|
|
76
|
-
return EMPTY_DIRECTORY;
|
|
77
|
-
}
|
|
78
|
-
return undefined;
|
|
79
|
-
}),
|
|
80
|
-
getBaseURL: () => 'http://test.invalid',
|
|
81
|
-
getAccessToken: () => 'token',
|
|
82
|
-
getDeviceCredential: () => null,
|
|
83
|
-
onTokensChanged: () => () => undefined,
|
|
84
|
-
setTokens: jest.fn(),
|
|
85
|
-
getCurrentAccountId: () => null,
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function state(
|
|
91
|
-
accounts: Array<{ accountId: string; sessionId: string }>,
|
|
92
|
-
activeAccountId: string | null,
|
|
93
|
-
revision = 1,
|
|
94
|
-
): DeviceSessionState {
|
|
95
|
-
return {
|
|
96
|
-
deviceId: 'device-1',
|
|
97
|
-
accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: 0 })),
|
|
98
|
-
activeAccountId,
|
|
99
|
-
revision,
|
|
100
|
-
updatedAt: 1_720_000_000_000,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function user(id: string, over: Partial<User> = {}): User {
|
|
105
|
-
return {
|
|
106
|
-
id,
|
|
107
|
-
publicKey: `pk_${id}`,
|
|
108
|
-
username: `user_${id}`,
|
|
109
|
-
name: { displayName: `User ${id}` },
|
|
110
|
-
...over,
|
|
111
|
-
} as User;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
115
|
-
return {
|
|
116
|
-
accountId: id,
|
|
117
|
-
kind: 'organization',
|
|
118
|
-
parentAccountId: null,
|
|
119
|
-
account: user(id),
|
|
120
|
-
relationship: 'owner',
|
|
121
|
-
callerMembership: null,
|
|
122
|
-
...over,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface OxyMock {
|
|
127
|
-
getAccessToken: jest.Mock;
|
|
128
|
-
getBaseURL: jest.Mock;
|
|
129
|
-
onTokensChanged: jest.Mock;
|
|
130
|
-
listAccounts: jest.Mock;
|
|
131
|
-
getUsersByIds: jest.Mock;
|
|
132
|
-
getFileDownloadUrl: jest.Mock;
|
|
133
|
-
switchToAccount: jest.Mock;
|
|
134
|
-
startCommonsSignIn: jest.Mock;
|
|
135
|
-
deliverCommonsSignIn: jest.Mock;
|
|
136
|
-
pollCommonsSignIn: jest.Mock;
|
|
137
|
-
denyCommonsSignIn: jest.Mock;
|
|
138
|
-
claimSessionByToken: jest.Mock;
|
|
139
|
-
signInWithSharedIdentity: jest.Mock;
|
|
140
|
-
/** Plant / clear the bearer — both fire `onTokensChanged`, like `OxyServices`. */
|
|
141
|
-
setTokens: jest.Mock;
|
|
142
|
-
clearTokens: jest.Mock;
|
|
143
|
-
/**
|
|
144
|
-
* Test helper: set the current access token and fire every registered
|
|
145
|
-
* `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
|
|
146
|
-
* With no listener yet registered (before `start()`), it just sets the token.
|
|
147
|
-
*/
|
|
148
|
-
emitTokenChange: (token: string | null) => void;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function makeOxy(): OxyMock {
|
|
152
|
-
const tokenListeners = new Set<(token: string | null) => void>();
|
|
153
|
-
// Authenticated by default (mirrors a warm start with a planted bearer).
|
|
154
|
-
let currentToken: string | null = 'access-token';
|
|
155
|
-
const emitTokenChange = (token: string | null) => {
|
|
156
|
-
currentToken = token;
|
|
157
|
-
for (const listener of tokenListeners) {
|
|
158
|
-
listener(token);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
return {
|
|
162
|
-
getAccessToken: jest.fn(() => currentToken),
|
|
163
|
-
getBaseURL: jest.fn(() => 'http://test.invalid'),
|
|
164
|
-
onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
|
|
165
|
-
tokenListeners.add(listener);
|
|
166
|
-
return () => tokenListeners.delete(listener);
|
|
167
|
-
}),
|
|
168
|
-
listAccounts: jest.fn().mockResolvedValue([]),
|
|
169
|
-
getUsersByIds: jest.fn().mockResolvedValue([]),
|
|
170
|
-
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
171
|
-
switchToAccount: jest.fn(),
|
|
172
|
-
startCommonsSignIn: jest.fn(),
|
|
173
|
-
pollCommonsSignIn: jest.fn(),
|
|
174
|
-
// Default: no capable Commons installation is registered. A NORMAL outcome
|
|
175
|
-
// that resolves the primary route to QR — never an error.
|
|
176
|
-
deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
|
|
177
|
-
denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
|
|
178
|
-
claimSessionByToken: jest.fn(),
|
|
179
|
-
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
180
|
-
setTokens: jest.fn((token: string) => emitTokenChange(token)),
|
|
181
|
-
clearTokens: jest.fn(() => emitTokenChange(null)),
|
|
182
|
-
emitTokenChange,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Flush pending microtasks (a `start()`-triggered `refresh()` cannot be awaited directly). */
|
|
187
|
-
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
188
|
-
|
|
189
|
-
interface Harness {
|
|
190
|
-
controller: AccountDialogController;
|
|
191
|
-
oxy: OxyMock;
|
|
192
|
-
sc: TestSessionClient;
|
|
193
|
-
commitSession: jest.Mock;
|
|
194
|
-
onSignedIn: jest.Mock;
|
|
195
|
-
/** Every path the session client has requested, in order. */
|
|
196
|
-
urls: string[];
|
|
197
|
-
/** Make every directory read reject with `error`. */
|
|
198
|
-
failDirectory: (error: unknown) => void;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function makeHarness(
|
|
202
|
-
over: Partial<{
|
|
203
|
-
clientId: string | null;
|
|
204
|
-
platform: import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
205
|
-
openUrl: (url: string) => void;
|
|
206
|
-
canOpenApp: (url: string) => Promise<boolean>;
|
|
207
|
-
}> = {},
|
|
208
|
-
): Harness {
|
|
209
|
-
const oxy = makeOxy();
|
|
210
|
-
const recording = host();
|
|
211
|
-
const sc = new TestSessionClient(recording.host);
|
|
212
|
-
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
213
|
-
const onSignedIn = jest.fn();
|
|
214
|
-
const controller = createAccountDialogController({
|
|
215
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
216
|
-
sessionClient: sc,
|
|
217
|
-
clientId: 'clientId' in over ? over.clientId : 'oxy_dk_test',
|
|
218
|
-
commitSession,
|
|
219
|
-
onSignedIn,
|
|
220
|
-
pollIntervalMs: 1000,
|
|
221
|
-
platform: over.platform,
|
|
222
|
-
openUrl: over.openUrl,
|
|
223
|
-
canOpenApp: over.canOpenApp,
|
|
224
|
-
});
|
|
225
|
-
return {
|
|
226
|
-
controller,
|
|
227
|
-
oxy,
|
|
228
|
-
sc,
|
|
229
|
-
commitSession,
|
|
230
|
-
onSignedIn,
|
|
231
|
-
urls: recording.urls,
|
|
232
|
-
failDirectory: recording.failDirectory,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** How many times the device directory has been read. */
|
|
237
|
-
const directoryReads = (urls: string[]): number =>
|
|
238
|
-
urls.filter((url) => url === '/session/device/directory').length;
|
|
239
|
-
|
|
240
|
-
describe('AccountDialogController — back, and the menu needs a session', () => {
|
|
241
|
-
/** A harness with no bearer — nobody is signed in on this device. */
|
|
242
|
-
function signedOut(): Harness {
|
|
243
|
-
const harness = makeHarness();
|
|
244
|
-
harness.oxy.emitTokenChange(null);
|
|
245
|
-
return harness;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
it('signed out, Back from "Create your account" returns to the sign-in entry, not the account menu', () => {
|
|
249
|
-
// The Android QA repro (OxyHQ/oxy#1375 item 1): Sign In → "New to Oxy?
|
|
250
|
-
// Create one" → ‹. The host's back used to be a hard-coded
|
|
251
|
-
// `setView('accounts')`, which opened "Switch account … Sign out" for nobody.
|
|
252
|
-
const { controller } = signedOut();
|
|
253
|
-
controller.setView('signin');
|
|
254
|
-
controller.startSignup();
|
|
255
|
-
expect(controller.getSnapshot().backView).toBe('signin');
|
|
256
|
-
|
|
257
|
-
expect(controller.back()).toBe(true);
|
|
258
|
-
|
|
259
|
-
expect(controller.getSnapshot().view).toBe('signin');
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('signed out, the sign-in entry is the first view: back reports nothing to go back to', () => {
|
|
263
|
-
const { controller } = signedOut();
|
|
264
|
-
controller.setView('signin');
|
|
265
|
-
const before = controller.getSnapshot();
|
|
266
|
-
expect(before.backView).toBeNull();
|
|
267
|
-
|
|
268
|
-
expect(controller.back()).toBe(false);
|
|
269
|
-
|
|
270
|
-
expect(controller.getSnapshot()).toBe(before);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('signed out, the account menu is never the view — it resolves to the sign-in entry', async () => {
|
|
274
|
-
const { controller } = signedOut();
|
|
275
|
-
// A controller is constructed on `accounts`; started with no bearer, it
|
|
276
|
-
// shows the entry.
|
|
277
|
-
controller.start();
|
|
278
|
-
await flush();
|
|
279
|
-
expect(controller.getSnapshot().view).toBe('signin');
|
|
280
|
-
|
|
281
|
-
controller.setView('qr');
|
|
282
|
-
controller.setView('accounts');
|
|
283
|
-
expect(controller.getSnapshot().view).toBe('signin');
|
|
284
|
-
|
|
285
|
-
controller.add();
|
|
286
|
-
expect(controller.getSnapshot().backView).toBeNull();
|
|
287
|
-
expect(controller.back()).toBe(false);
|
|
288
|
-
expect(controller.getSnapshot().view).toBe('add');
|
|
289
|
-
controller.destroy();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('signed out, Back from the QR request withdraws it and returns to the entry', async () => {
|
|
293
|
-
const { controller, oxy } = signedOut();
|
|
294
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
295
|
-
sessionToken: 'secret-tok',
|
|
296
|
-
authorizeCode: 'AUTH-CODE',
|
|
297
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
298
|
-
expiresAt: Date.now() + 300_000,
|
|
299
|
-
status: 'pending',
|
|
300
|
-
});
|
|
301
|
-
controller.setView('signin');
|
|
302
|
-
await controller.showQr();
|
|
303
|
-
expect(controller.getSnapshot().view).toBe('qr');
|
|
304
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
305
|
-
|
|
306
|
-
expect(controller.back()).toBe(true);
|
|
307
|
-
|
|
308
|
-
const snap = controller.getSnapshot();
|
|
309
|
-
expect(snap.view).toBe('signin');
|
|
310
|
-
expect(snap.signIn.phase).toBe('idle');
|
|
311
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it('signed in, back walks signup → add → accounts, and the menu is the first view', () => {
|
|
315
|
-
const { controller } = makeHarness();
|
|
316
|
-
controller.add();
|
|
317
|
-
controller.startSignup();
|
|
318
|
-
expect(controller.getSnapshot().backView).toBe('add');
|
|
319
|
-
|
|
320
|
-
expect(controller.back()).toBe(true);
|
|
321
|
-
expect(controller.getSnapshot().view).toBe('add');
|
|
322
|
-
expect(controller.getSnapshot().backView).toBe('accounts');
|
|
323
|
-
|
|
324
|
-
expect(controller.back()).toBe(true);
|
|
325
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
326
|
-
expect(controller.getSnapshot().backView).toBeNull();
|
|
327
|
-
|
|
328
|
-
expect(controller.back()).toBe(false);
|
|
329
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('signing out while the account menu is open leaves the menu for the sign-in entry', async () => {
|
|
333
|
-
const { controller, oxy } = makeHarness();
|
|
334
|
-
controller.start();
|
|
335
|
-
await flush();
|
|
336
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
337
|
-
|
|
338
|
-
oxy.emitTokenChange(null);
|
|
339
|
-
|
|
340
|
-
expect(controller.getSnapshot().view).toBe('signin');
|
|
341
|
-
controller.destroy();
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
describe('AccountDialogController — initial + views', () => {
|
|
346
|
-
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
347
|
-
const { controller } = makeHarness();
|
|
348
|
-
const snap = controller.getSnapshot();
|
|
349
|
-
expect(snap.view).toBe('accounts');
|
|
350
|
-
expect(snap.directory).toBeNull();
|
|
351
|
-
expect(snap.activeContext).toBeNull();
|
|
352
|
-
expect(snap.loading).toBe(false);
|
|
353
|
-
expect(snap.activatingContextId).toBeNull();
|
|
354
|
-
expect(snap.removingContextId).toBeNull();
|
|
355
|
-
expect(snap.removingPrincipalId).toBeNull();
|
|
356
|
-
expect(snap.signIn.phase).toBe('idle');
|
|
357
|
-
expect(snap.signIn.route).toBeNull();
|
|
358
|
-
expect(snap.signIn.routeFailed).toBe(false);
|
|
359
|
-
expect(snap.signIn.pushSentAt).toBeNull();
|
|
360
|
-
expect(snap.signIn.openedAt).toBeNull();
|
|
361
|
-
expect(snap.signIn.progress).toBe('idle');
|
|
362
|
-
expect(snap.commonsAvailability).toBe('unknown');
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('setView / add move between views and notify subscribers', () => {
|
|
366
|
-
const { controller } = makeHarness();
|
|
367
|
-
const seen: string[] = [];
|
|
368
|
-
controller.subscribe((s) => seen.push(s.view));
|
|
369
|
-
|
|
370
|
-
controller.add();
|
|
371
|
-
expect(controller.getSnapshot().view).toBe('add');
|
|
372
|
-
controller.setView('signin');
|
|
373
|
-
expect(controller.getSnapshot().view).toBe('signin');
|
|
374
|
-
controller.setView('accounts');
|
|
375
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
376
|
-
|
|
377
|
-
expect(seen).toEqual(['add', 'signin', 'accounts']);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it('startSignup moves to the signup view and notifies subscribers', () => {
|
|
381
|
-
const { controller } = makeHarness();
|
|
382
|
-
const seen: string[] = [];
|
|
383
|
-
controller.subscribe((s) => seen.push(s.view));
|
|
384
|
-
|
|
385
|
-
controller.startSignup();
|
|
386
|
-
expect(controller.getSnapshot().view).toBe('signup');
|
|
387
|
-
controller.setView('accounts');
|
|
388
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
389
|
-
|
|
390
|
-
expect(seen).toEqual(['signup', 'accounts']);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('getSnapshot returns a stable reference until a change occurs', () => {
|
|
394
|
-
const { controller } = makeHarness();
|
|
395
|
-
const a = controller.getSnapshot();
|
|
396
|
-
expect(controller.getSnapshot()).toBe(a);
|
|
397
|
-
controller.setView('add');
|
|
398
|
-
expect(controller.getSnapshot()).not.toBe(a);
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
describe('AccountDialogController — auth-gated directory read', () => {
|
|
403
|
-
it('start() while signed out makes no directory call and does not error', async () => {
|
|
404
|
-
const { controller, oxy, sc, urls } = makeHarness();
|
|
405
|
-
// Cold boot: no bearer planted yet (no listeners registered pre-start).
|
|
406
|
-
oxy.emitTokenChange(null);
|
|
407
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
408
|
-
|
|
409
|
-
controller.start();
|
|
410
|
-
await flush();
|
|
411
|
-
|
|
412
|
-
// The directory read is a PRIVATE call. Issuing it before the cold-boot
|
|
413
|
-
// restore plants the bearer 401s, which clears the token and signs the user
|
|
414
|
-
// out — the failure this gate exists for.
|
|
415
|
-
expect(urls).not.toContain('/session/device/directory');
|
|
416
|
-
const snap = controller.getSnapshot();
|
|
417
|
-
expect(snap.error).toBeNull();
|
|
418
|
-
expect(snap.loading).toBe(false);
|
|
419
|
-
controller.destroy();
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it('start() while authenticated reads the directory exactly once', async () => {
|
|
423
|
-
const { controller, sc, urls } = makeHarness();
|
|
424
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
425
|
-
|
|
426
|
-
controller.start();
|
|
427
|
-
await flush();
|
|
428
|
-
|
|
429
|
-
expect(directoryReads(urls)).toBe(1);
|
|
430
|
-
controller.destroy();
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it('reads the directory once when the bearer is planted after a signed-out start', async () => {
|
|
434
|
-
const { controller, oxy, urls } = makeHarness();
|
|
435
|
-
oxy.emitTokenChange(null);
|
|
436
|
-
controller.start();
|
|
437
|
-
await flush();
|
|
438
|
-
expect(urls).not.toContain('/session/device/directory');
|
|
439
|
-
|
|
440
|
-
// Cold-boot restore plants the token → onTokensChanged → one read.
|
|
441
|
-
oxy.emitTokenChange('access-token');
|
|
442
|
-
await flush();
|
|
443
|
-
expect(directoryReads(urls)).toBe(1);
|
|
444
|
-
controller.destroy();
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('never reconstructs the account graph — no listAccounts, no getUsersByIds', async () => {
|
|
448
|
-
const { controller, oxy, sc } = makeHarness();
|
|
449
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
450
|
-
|
|
451
|
-
controller.start();
|
|
452
|
-
await flush();
|
|
453
|
-
// A device change used to refetch profiles for any newly-seen account id.
|
|
454
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a2', 2));
|
|
455
|
-
await flush();
|
|
456
|
-
|
|
457
|
-
// ADR 0002's whole point: the client holds ONE caller's account graph and
|
|
458
|
-
// cannot enumerate another principal's, so a switcher assembled from it is
|
|
459
|
-
// one person's answer presented as the device's. The directory carries the
|
|
460
|
-
// rows AND the display metadata, so neither private call is made at all.
|
|
461
|
-
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
462
|
-
expect(oxy.getUsersByIds).not.toHaveBeenCalled();
|
|
463
|
-
controller.destroy();
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it('does not loop when the directory read rejects — one call per refresh, no re-trigger on device changes', async () => {
|
|
467
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
468
|
-
const { controller, sc, urls, failDirectory } = makeHarness();
|
|
469
|
-
failDirectory(new Error('directory boom'));
|
|
470
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
471
|
-
|
|
472
|
-
controller.start();
|
|
473
|
-
await flush();
|
|
474
|
-
expect(directoryReads(urls)).toBe(1);
|
|
475
|
-
expect(controller.getSnapshot().error).toBe('directory boom');
|
|
476
|
-
|
|
477
|
-
// A subsequent device push must not re-trigger it (the auth edge did not
|
|
478
|
-
// move → `reconcileAuth` is a no-op → no storm).
|
|
479
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
|
|
480
|
-
await flush();
|
|
481
|
-
expect(directoryReads(urls)).toBe(1);
|
|
482
|
-
controller.destroy();
|
|
483
|
-
warnSpy.mockRestore();
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it('treats a 401 from the directory read as the signed-out edge — debug, no surfaced error, no warn', async () => {
|
|
487
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
488
|
-
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
489
|
-
const { controller, failDirectory } = makeHarness();
|
|
490
|
-
failDirectory(
|
|
491
|
-
Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
|
|
492
|
-
);
|
|
493
|
-
|
|
494
|
-
await controller.refresh();
|
|
495
|
-
|
|
496
|
-
// A signed-out device is a normal state, not a warning.
|
|
497
|
-
expect(controller.getSnapshot().error).toBeNull();
|
|
498
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
499
|
-
expect(debugSpy).toHaveBeenCalledWith(
|
|
500
|
-
'[AccountDialogController] directory unauthorized (signed out)',
|
|
501
|
-
{ component: 'AccountDialogController' },
|
|
502
|
-
expect.objectContaining({ status: 401 }),
|
|
503
|
-
);
|
|
504
|
-
warnSpy.mockRestore();
|
|
505
|
-
debugSpy.mockRestore();
|
|
506
|
-
});
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
describe('AccountDialogController — sign in with Oxy', () => {
|
|
510
|
-
it('completes silently when a shared identity mints a session', async () => {
|
|
511
|
-
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
512
|
-
const session: SessionLoginResponse = {
|
|
513
|
-
sessionId: 'sess-shared',
|
|
514
|
-
deviceId: 'device-1',
|
|
515
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
516
|
-
accessToken: 'access-shared',
|
|
517
|
-
user: { id: 'a1', username: 'user_a1', name: { displayName: 'User a1' } },
|
|
518
|
-
};
|
|
519
|
-
oxy.signInWithSharedIdentity.mockResolvedValue(session);
|
|
520
|
-
|
|
521
|
-
await controller.signInWithOxy();
|
|
522
|
-
|
|
523
|
-
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
524
|
-
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
525
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
526
|
-
// Terminal SUCCESS — the surface gets one frame to show "Identity confirmed".
|
|
527
|
-
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
528
|
-
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
529
|
-
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
530
|
-
|
|
531
|
-
// …and a new intention clears it, so the next entry never opens on the
|
|
532
|
-
// previous sign-in's terminal state.
|
|
533
|
-
controller.add();
|
|
534
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
535
|
-
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it('falls through to the QR handoff when no shared identity is present', async () => {
|
|
539
|
-
const { controller, oxy } = makeHarness();
|
|
540
|
-
oxy.signInWithSharedIdentity.mockResolvedValue(null);
|
|
541
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
542
|
-
sessionToken: 'secret-tok',
|
|
543
|
-
authorizeCode: 'AUTH-CODE',
|
|
544
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
545
|
-
expiresAt: Date.now() + 300_000,
|
|
546
|
-
status: 'pending',
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
await controller.signInWithOxy();
|
|
550
|
-
|
|
551
|
-
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
552
|
-
const snap = controller.getSnapshot();
|
|
553
|
-
expect(snap.view).toBe('qr');
|
|
554
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
555
|
-
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
556
|
-
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
557
|
-
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
558
|
-
expect(snap.signIn.route).toBe('qr');
|
|
559
|
-
controller.cancelSignIn();
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
it('selects await-push when delivery succeeds for a signed-in user', async () => {
|
|
563
|
-
const { controller, oxy } = makeHarness();
|
|
564
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
565
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
566
|
-
sessionToken: 'secret-tok',
|
|
567
|
-
authorizeCode: 'AUTH-CODE',
|
|
568
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
569
|
-
expiresAt: Date.now() + 300_000,
|
|
570
|
-
status: 'pending',
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
await controller.showQr();
|
|
574
|
-
|
|
575
|
-
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
576
|
-
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
577
|
-
controller.cancelSignIn();
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('skips deliver when no bearer is planted', async () => {
|
|
581
|
-
const { controller, oxy } = makeHarness();
|
|
582
|
-
oxy.emitTokenChange(null);
|
|
583
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
584
|
-
sessionToken: 'secret-tok',
|
|
585
|
-
authorizeCode: 'AUTH-CODE',
|
|
586
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
587
|
-
expiresAt: Date.now() + 300_000,
|
|
588
|
-
status: 'pending',
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
await controller.showQr();
|
|
592
|
-
|
|
593
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
594
|
-
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
595
|
-
controller.cancelSignIn();
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it('errors when showQr is called without a clientId', async () => {
|
|
599
|
-
const { controller } = makeHarness({ clientId: null });
|
|
600
|
-
await controller.showQr();
|
|
601
|
-
const snap = controller.getSnapshot();
|
|
602
|
-
expect(snap.signIn.phase).toBe('error');
|
|
603
|
-
expect(snap.signIn.error).toMatch(/clientId/);
|
|
604
|
-
expect(snap.signIn.failure).toBe('not-configured');
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it('polls, claims, and commits when the QR flow is authorized', async () => {
|
|
608
|
-
jest.useFakeTimers();
|
|
609
|
-
try {
|
|
610
|
-
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
611
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
612
|
-
sessionToken: 'secret-tok',
|
|
613
|
-
authorizeCode: 'AUTH-CODE',
|
|
614
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
615
|
-
expiresAt: Date.now() + 600_000,
|
|
616
|
-
status: 'pending',
|
|
617
|
-
});
|
|
618
|
-
oxy.pollCommonsSignIn
|
|
619
|
-
.mockResolvedValueOnce({ authorized: false, status: 'pending' })
|
|
620
|
-
.mockResolvedValueOnce({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
621
|
-
oxy.claimSessionByToken.mockResolvedValue({
|
|
622
|
-
accessToken: 'access-1',
|
|
623
|
-
sessionId: 'sess-1',
|
|
624
|
-
deviceId: 'device-1',
|
|
625
|
-
deviceSecret: 'claimed-secret',
|
|
626
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
627
|
-
user: user('a1'),
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
await controller.showQr();
|
|
631
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
632
|
-
|
|
633
|
-
await jest.advanceTimersByTimeAsync(1000); // first poll → pending
|
|
634
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
635
|
-
|
|
636
|
-
await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
|
|
637
|
-
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
|
|
638
|
-
expect(commitSession).toHaveBeenCalledWith(
|
|
639
|
-
expect.objectContaining({
|
|
640
|
-
sessionId: 'sess-1',
|
|
641
|
-
accessToken: 'access-1',
|
|
642
|
-
deviceSecret: 'claimed-secret',
|
|
643
|
-
}),
|
|
644
|
-
);
|
|
645
|
-
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
646
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
647
|
-
} finally {
|
|
648
|
-
jest.useRealTimers();
|
|
649
|
-
}
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
it('surfaces a denied QR authorization as an error and stops polling', async () => {
|
|
653
|
-
jest.useFakeTimers();
|
|
654
|
-
try {
|
|
655
|
-
const { controller, oxy } = makeHarness();
|
|
656
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
657
|
-
sessionToken: 'secret-tok',
|
|
658
|
-
authorizeCode: 'AUTH-CODE',
|
|
659
|
-
qrPayload: 'oxycommons://approve',
|
|
660
|
-
expiresAt: Date.now() + 600_000,
|
|
661
|
-
status: 'pending',
|
|
662
|
-
});
|
|
663
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
664
|
-
|
|
665
|
-
await controller.showQr();
|
|
666
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
667
|
-
|
|
668
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
669
|
-
expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
|
|
670
|
-
expect(controller.getSnapshot().signIn.failure).toBe('denied');
|
|
671
|
-
|
|
672
|
-
// No further polls after the terminal error.
|
|
673
|
-
await jest.advanceTimersByTimeAsync(5000);
|
|
674
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
675
|
-
} finally {
|
|
676
|
-
jest.useRealTimers();
|
|
677
|
-
}
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
it('cancelSignIn stops the poll and resets to idle', async () => {
|
|
681
|
-
jest.useFakeTimers();
|
|
682
|
-
try {
|
|
683
|
-
const { controller, oxy } = makeHarness();
|
|
684
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
685
|
-
sessionToken: 'secret-tok',
|
|
686
|
-
authorizeCode: 'AUTH-CODE',
|
|
687
|
-
qrPayload: 'oxycommons://approve',
|
|
688
|
-
expiresAt: Date.now() + 600_000,
|
|
689
|
-
status: 'pending',
|
|
690
|
-
});
|
|
691
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
692
|
-
|
|
693
|
-
await controller.showQr();
|
|
694
|
-
controller.cancelSignIn();
|
|
695
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
696
|
-
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
697
|
-
// Cancellation converges server-side too: the abandoned request is
|
|
698
|
-
// withdrawn so a stale QR can never be approved later.
|
|
699
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
700
|
-
|
|
701
|
-
await jest.advanceTimersByTimeAsync(5000);
|
|
702
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
703
|
-
} finally {
|
|
704
|
-
jest.useRealTimers();
|
|
705
|
-
}
|
|
706
|
-
});
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
describe('AccountDialogController — startInlineQr (the sign-in entry\'s embedded QR)', () => {
|
|
710
|
-
const pending = (code: string) => ({
|
|
711
|
-
sessionToken: `secret-${code}`,
|
|
712
|
-
authorizeCode: code,
|
|
713
|
-
qrPayload: `oxycommons://approve?v=1&code=${code}`,
|
|
714
|
-
expiresAt: Date.now() + 300_000,
|
|
715
|
-
status: 'pending',
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
it('starts a QR-only request without leaving the entry — no push, no Commons, no view change', async () => {
|
|
719
|
-
const { controller, oxy } = makeHarness();
|
|
720
|
-
oxy.startCommonsSignIn.mockResolvedValue(pending('AUTH-CODE'));
|
|
721
|
-
controller.setView('signin');
|
|
722
|
-
|
|
723
|
-
await controller.startInlineQr();
|
|
724
|
-
|
|
725
|
-
const snap = controller.getSnapshot();
|
|
726
|
-
expect(snap.view).toBe('signin');
|
|
727
|
-
expect(snap.signIn.inline).toBe(true);
|
|
728
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
729
|
-
expect(snap.signIn.route).toBe('qr');
|
|
730
|
-
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
731
|
-
// A bearer is planted in this harness, so an asked-for request WOULD push.
|
|
732
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
733
|
-
controller.cancelSignIn();
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it('"Try again" repeats the embedded QR', async () => {
|
|
737
|
-
const { controller, oxy } = makeHarness();
|
|
738
|
-
oxy.startCommonsSignIn.mockResolvedValue(pending('AUTH-CODE'));
|
|
739
|
-
await controller.startInlineQr();
|
|
740
|
-
|
|
741
|
-
await controller.retrySignIn();
|
|
742
|
-
|
|
743
|
-
expect(oxy.startCommonsSignIn).toHaveBeenCalledTimes(2);
|
|
744
|
-
expect(controller.getSnapshot().signIn.inline).toBe(true);
|
|
745
|
-
controller.cancelSignIn();
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
it('gives way to a sign-in the person chooses, withdrawing its own request', async () => {
|
|
749
|
-
const { controller, oxy } = makeHarness();
|
|
750
|
-
oxy.startCommonsSignIn.mockResolvedValueOnce(pending('INLINE')).mockResolvedValueOnce(pending('ASKED'));
|
|
751
|
-
await controller.startInlineQr();
|
|
752
|
-
|
|
753
|
-
await controller.showQr();
|
|
754
|
-
|
|
755
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('INLINE');
|
|
756
|
-
const snap = controller.getSnapshot();
|
|
757
|
-
expect(snap.signIn.inline).toBe(false);
|
|
758
|
-
expect(snap.signIn.authorizeCode).toBe('ASKED');
|
|
759
|
-
controller.cancelSignIn();
|
|
760
|
-
});
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
describe('AccountDialogController — Commons availability (canOpenApp)', () => {
|
|
764
|
-
const START_HANDLE = {
|
|
765
|
-
sessionToken: 'secret-tok',
|
|
766
|
-
authorizeCode: 'AUTH-CODE',
|
|
767
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
768
|
-
expiresAt: Date.now() + 600_000,
|
|
769
|
-
status: 'pending' as const,
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
function makeController(opts: {
|
|
773
|
-
openUrl?: jest.Mock;
|
|
774
|
-
canOpenApp?: jest.Mock;
|
|
775
|
-
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
776
|
-
const oxy = makeOxy();
|
|
777
|
-
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
778
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
779
|
-
const controller = new AccountDialogController({
|
|
780
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
781
|
-
sessionClient: new TestSessionClient(host().host),
|
|
782
|
-
clientId: 'oxy_dk_test',
|
|
783
|
-
pollIntervalMs: 1000,
|
|
784
|
-
openUrl: opts.openUrl,
|
|
785
|
-
canOpenApp: opts.canOpenApp,
|
|
786
|
-
// The deep-link route is mobile-only (`selectCommonsDelivery`), and a
|
|
787
|
-
// `canOpenApp` probe is only ever injected by a native (mobile) host.
|
|
788
|
-
platform: 'mobile',
|
|
789
|
-
});
|
|
790
|
-
return { controller, oxy };
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
|
|
794
|
-
setPlatformOS('ios');
|
|
795
|
-
const openUrl = jest.fn();
|
|
796
|
-
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
797
|
-
const { controller } = makeController({ openUrl, canOpenApp });
|
|
798
|
-
|
|
799
|
-
await controller.showQr();
|
|
800
|
-
await flush(); // let the (non-awaited) canOpenApp probe resolve
|
|
801
|
-
|
|
802
|
-
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
803
|
-
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
804
|
-
// The QR + polling remain the fallback path — the flow is still waiting.
|
|
805
|
-
const snap = controller.getSnapshot();
|
|
806
|
-
expect(snap.view).toBe('qr');
|
|
807
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
808
|
-
expect(snap.signIn.route).toBe('open-commons');
|
|
809
|
-
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
810
|
-
expect(snap.commonsAvailability).toBe('available');
|
|
811
|
-
controller.cancelSignIn();
|
|
812
|
-
setPlatformOS('web');
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
|
|
816
|
-
const openUrl = jest.fn();
|
|
817
|
-
const canOpenApp = jest.fn().mockResolvedValue(false);
|
|
818
|
-
const { controller } = makeController({ openUrl, canOpenApp });
|
|
819
|
-
|
|
820
|
-
await controller.showQr();
|
|
821
|
-
await flush();
|
|
822
|
-
|
|
823
|
-
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
824
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
825
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
826
|
-
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
827
|
-
controller.cancelSignIn();
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it('never probes or opens when canOpenApp is absent (web — unchanged behavior)', async () => {
|
|
831
|
-
const openUrl = jest.fn();
|
|
832
|
-
const { controller } = makeController({ openUrl });
|
|
833
|
-
|
|
834
|
-
await controller.showQr();
|
|
835
|
-
await flush();
|
|
836
|
-
|
|
837
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
838
|
-
expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
839
|
-
expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
|
|
840
|
-
controller.cancelSignIn();
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
|
|
844
|
-
const openUrl = jest.fn();
|
|
845
|
-
const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
|
|
846
|
-
const { controller } = makeController({ openUrl, canOpenApp });
|
|
847
|
-
|
|
848
|
-
await controller.showQr();
|
|
849
|
-
await flush();
|
|
850
|
-
|
|
851
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
852
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
853
|
-
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
854
|
-
controller.cancelSignIn();
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
|
|
858
|
-
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
859
|
-
const { controller } = makeController({ canOpenApp });
|
|
860
|
-
|
|
861
|
-
controller.start();
|
|
862
|
-
await flush();
|
|
863
|
-
|
|
864
|
-
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
865
|
-
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
866
|
-
controller.destroy();
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
|
|
871
|
-
type Handler = (...args: unknown[]) => void;
|
|
872
|
-
class FakeAuthSocket implements MinimalSocket {
|
|
873
|
-
connected = false;
|
|
874
|
-
disconnected = false;
|
|
875
|
-
handlers = new Map<string, Handler[]>();
|
|
876
|
-
emitted: Array<{ event: string; args: unknown[] }> = [];
|
|
877
|
-
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
878
|
-
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
879
|
-
emit(event: string, ...args: unknown[]) { this.emitted.push({ event, args }); }
|
|
880
|
-
connect() { this.connected = true; }
|
|
881
|
-
disconnect() { this.connected = false; this.disconnected = true; }
|
|
882
|
-
/** Simulate a server→client push on this socket. */
|
|
883
|
-
server(event: string, payload?: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
const START_HANDLE = {
|
|
887
|
-
sessionToken: 'secret-tok',
|
|
888
|
-
authorizeCode: 'AUTH-CODE',
|
|
889
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
890
|
-
expiresAt: Date.now() + 600_000,
|
|
891
|
-
status: 'pending' as const,
|
|
892
|
-
};
|
|
893
|
-
|
|
894
|
-
function makeSocketHarness(): { controller: AccountDialogController; oxy: OxyMock; created: () => FakeAuthSocket | null; factory: jest.Mock; commitSession: jest.Mock } {
|
|
895
|
-
const oxy = makeOxy();
|
|
896
|
-
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
897
|
-
let socket: FakeAuthSocket | null = null;
|
|
898
|
-
const factory = jest.fn((_uri: string, _opts?: Record<string, unknown>): MinimalSocket => {
|
|
899
|
-
socket = new FakeAuthSocket();
|
|
900
|
-
socket.connected = true; // real io autoConnect resolves before we inspect
|
|
901
|
-
return socket;
|
|
902
|
-
});
|
|
903
|
-
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
904
|
-
const controller = new AccountDialogController({
|
|
905
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
906
|
-
sessionClient: new TestSessionClient(host().host),
|
|
907
|
-
clientId: 'oxy_dk_test',
|
|
908
|
-
commitSession,
|
|
909
|
-
socketFactory: factory as unknown as SocketIOFactory,
|
|
910
|
-
});
|
|
911
|
-
return { controller, oxy, created: () => socket, factory, commitSession };
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
it('connects to /auth-session, joins the flow room, and wakes the claim on auth_update — no timer advance', async () => {
|
|
915
|
-
const { controller, oxy, created, factory, commitSession } = makeSocketHarness();
|
|
916
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
917
|
-
oxy.claimSessionByToken.mockResolvedValue({
|
|
918
|
-
accessToken: 'access-1', sessionId: 'sess-1', deviceId: 'device-1', expiresAt: '2030-01-01T00:00:00Z', user: user('a1'),
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
await controller.showQr();
|
|
922
|
-
|
|
923
|
-
expect(factory).toHaveBeenCalledWith('http://test.invalid/auth-session', expect.any(Object));
|
|
924
|
-
const sock = created();
|
|
925
|
-
if (!sock) throw new Error('socket not created');
|
|
926
|
-
expect(sock.emitted).toContainEqual({ event: 'join', args: ['secret-tok'] });
|
|
927
|
-
|
|
928
|
-
// The server pushes auth_update → immediate status check + claim, without any poll timer firing.
|
|
929
|
-
sock.server('auth_update', { status: 'authorized', sessionId: 'sess-1' });
|
|
930
|
-
await flush();
|
|
931
|
-
|
|
932
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
|
|
933
|
-
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
|
|
934
|
-
expect(commitSession).toHaveBeenCalled();
|
|
935
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
936
|
-
expect(sock.disconnected).toBe(true); // torn down on completion
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
it('re-joins the room on reconnect (connect event) and tears the socket down on cancelSignIn', async () => {
|
|
940
|
-
const { controller, oxy, created } = makeSocketHarness();
|
|
941
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
942
|
-
|
|
943
|
-
await controller.showQr();
|
|
944
|
-
const sock = created();
|
|
945
|
-
if (!sock) throw new Error('socket not created');
|
|
946
|
-
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(1);
|
|
947
|
-
|
|
948
|
-
// A reconnect fires `connect` again → the join is re-issued so it survives drops.
|
|
949
|
-
sock.server('connect');
|
|
950
|
-
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(2);
|
|
951
|
-
|
|
952
|
-
controller.cancelSignIn();
|
|
953
|
-
expect(sock.disconnected).toBe(true);
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
it('a stale auth_update after the flow was cancelled does not re-poll', async () => {
|
|
957
|
-
const { controller, oxy, created } = makeSocketHarness();
|
|
958
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
959
|
-
|
|
960
|
-
await controller.showQr();
|
|
961
|
-
const sock = created();
|
|
962
|
-
if (!sock) throw new Error('socket not created');
|
|
963
|
-
oxy.pollCommonsSignIn.mockClear();
|
|
964
|
-
|
|
965
|
-
controller.cancelSignIn();
|
|
966
|
-
// Even if a late auth_update slips through on the (now-detached) socket, the
|
|
967
|
-
// superseded-token guard drops it.
|
|
968
|
-
sock.server('auth_update', { status: 'authorized' });
|
|
969
|
-
await flush();
|
|
970
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
it('stops polling when an OAuth-bound session is authorized without a sessionId', async () => {
|
|
974
|
-
const { controller, oxy, created } = makeSocketHarness();
|
|
975
|
-
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
976
|
-
authorized: true,
|
|
977
|
-
purpose: 'oauth_authorization',
|
|
978
|
-
status: 'authorized',
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
await controller.showQr();
|
|
982
|
-
const sock = created();
|
|
983
|
-
if (!sock) throw new Error('socket not created');
|
|
984
|
-
sock.server('auth_update', { status: 'authorized' });
|
|
985
|
-
await flush();
|
|
986
|
-
|
|
987
|
-
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
988
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
989
|
-
expect(controller.getSnapshot().signIn.error).toContain('OAuth sign-in');
|
|
990
|
-
});
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
// ===========================================================================
|
|
994
|
-
// Issue #691, Phase 5 — automatic delivery selection + honest progress
|
|
995
|
-
// ===========================================================================
|
|
996
|
-
|
|
997
|
-
type DeliveryPlatform = import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
998
|
-
|
|
999
|
-
const DELIVERY_HANDLE = {
|
|
1000
|
-
sessionToken: 'secret-tok',
|
|
1001
|
-
authorizeCode: 'AUTH-CODE',
|
|
1002
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
1003
|
-
status: 'pending' as const,
|
|
1004
|
-
};
|
|
1005
|
-
|
|
1006
|
-
function makeDeliveryHarness(opts: {
|
|
1007
|
-
platform?: DeliveryPlatform;
|
|
1008
|
-
canOpenApp?: jest.Mock;
|
|
1009
|
-
openUrl?: jest.Mock;
|
|
1010
|
-
/** Default `true` — a planted bearer, the only state that may push. */
|
|
1011
|
-
authenticated?: boolean;
|
|
1012
|
-
pollIntervalMs?: number;
|
|
1013
|
-
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
1014
|
-
const oxy = makeOxy();
|
|
1015
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1016
|
-
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1017
|
-
authorized: false,
|
|
1018
|
-
status: 'pending',
|
|
1019
|
-
pushSentAt: null,
|
|
1020
|
-
openedAt: null,
|
|
1021
|
-
});
|
|
1022
|
-
if (opts.authenticated === false) oxy.emitTokenChange(null);
|
|
1023
|
-
const controller = new AccountDialogController({
|
|
1024
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
1025
|
-
sessionClient: new TestSessionClient(host().host),
|
|
1026
|
-
clientId: 'oxy_dk_test',
|
|
1027
|
-
// Every real consumer wires the provider's commit funnel; without it the
|
|
1028
|
-
// controller falls back to `SessionClient.registerAndActivate`, which opens
|
|
1029
|
-
// a BroadcastChannel this harness has no reason to exercise.
|
|
1030
|
-
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
1031
|
-
pollIntervalMs: opts.pollIntervalMs ?? 1000,
|
|
1032
|
-
platform: opts.platform,
|
|
1033
|
-
canOpenApp: opts.canOpenApp,
|
|
1034
|
-
openUrl: opts.openUrl,
|
|
1035
|
-
});
|
|
1036
|
-
return { controller, oxy };
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
describe('AccountDialogController — automatic delivery selection (#691 phase 5)', () => {
|
|
1040
|
-
it('chooses open-commons on mobile with a verified Commons link, and never pushes as well', async () => {
|
|
1041
|
-
const openUrl = jest.fn();
|
|
1042
|
-
const { controller, oxy } = makeDeliveryHarness({
|
|
1043
|
-
platform: 'mobile',
|
|
1044
|
-
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1045
|
-
openUrl,
|
|
1046
|
-
});
|
|
1047
|
-
|
|
1048
|
-
await controller.showQr();
|
|
1049
|
-
await flush();
|
|
1050
|
-
|
|
1051
|
-
const snap = controller.getSnapshot();
|
|
1052
|
-
expect(snap.signIn.route).toBe('open-commons');
|
|
1053
|
-
expect(snap.signIn.routeFailed).toBe(false);
|
|
1054
|
-
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1055
|
-
// The identity is reachable on THIS device — pushing would light up a second
|
|
1056
|
-
// surface for a request the user is about to confirm here.
|
|
1057
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1058
|
-
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1059
|
-
controller.cancelSignIn();
|
|
1060
|
-
});
|
|
1061
|
-
|
|
1062
|
-
it('chooses await-push when the server delivered to at least one known Commons installation', async () => {
|
|
1063
|
-
const openUrl = jest.fn();
|
|
1064
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', openUrl });
|
|
1065
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 2 });
|
|
1066
|
-
|
|
1067
|
-
await controller.showQr();
|
|
1068
|
-
await flush();
|
|
1069
|
-
|
|
1070
|
-
const snap = controller.getSnapshot();
|
|
1071
|
-
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1072
|
-
expect(snap.signIn.route).toBe('await-push');
|
|
1073
|
-
expect(snap.signIn.routeFailed).toBe(false);
|
|
1074
|
-
// "Check Commons on your phone" — the server CONFIRMED the dispatch.
|
|
1075
|
-
expect(snap.signIn.progress).toBe('delivered-to-commons');
|
|
1076
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
1077
|
-
controller.cancelSignIn();
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
it('falls through to QR when the delivery reached zero installations — no error surfaced', async () => {
|
|
1081
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1082
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1083
|
-
|
|
1084
|
-
await controller.showQr();
|
|
1085
|
-
await flush();
|
|
1086
|
-
|
|
1087
|
-
const snap = controller.getSnapshot();
|
|
1088
|
-
expect(snap.signIn.route).toBe('qr');
|
|
1089
|
-
expect(snap.signIn.routeFailed).toBe(false);
|
|
1090
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
1091
|
-
expect(snap.signIn.error).toBeNull();
|
|
1092
|
-
expect(snap.error).toBeNull();
|
|
1093
|
-
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1094
|
-
controller.cancelSignIn();
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
it('falls through to QR when the delivery call FAILS — silently, as a normal outcome', async () => {
|
|
1098
|
-
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1099
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1100
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1101
|
-
oxy.deliverCommonsSignIn.mockRejectedValue(new Error('delivery boom'));
|
|
1102
|
-
|
|
1103
|
-
await controller.showQr();
|
|
1104
|
-
await flush();
|
|
1105
|
-
|
|
1106
|
-
const snap = controller.getSnapshot();
|
|
1107
|
-
expect(snap.signIn.route).toBe('qr');
|
|
1108
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
1109
|
-
expect(snap.signIn.error).toBeNull();
|
|
1110
|
-
expect(snap.error).toBeNull();
|
|
1111
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
1112
|
-
expect(debugSpy).toHaveBeenCalledWith(
|
|
1113
|
-
'[AccountDialogController] Commons delivery unavailable (QR route)',
|
|
1114
|
-
{ component: 'AccountDialogController' },
|
|
1115
|
-
expect.any(Error),
|
|
1116
|
-
);
|
|
1117
|
-
controller.cancelSignIn();
|
|
1118
|
-
debugSpy.mockRestore();
|
|
1119
|
-
warnSpy.mockRestore();
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
it('treats a delivered:false response with a non-zero target count as zero targets (fail-safe to QR)', async () => {
|
|
1123
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1124
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 3 });
|
|
1125
|
-
|
|
1126
|
-
await controller.showQr();
|
|
1127
|
-
await flush();
|
|
1128
|
-
|
|
1129
|
-
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1130
|
-
controller.cancelSignIn();
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
it('NEVER attempts delivery without a bearer — the unauthenticated surface cannot ring a phone', async () => {
|
|
1134
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', authenticated: false });
|
|
1135
|
-
|
|
1136
|
-
await controller.showQr();
|
|
1137
|
-
await flush();
|
|
1138
|
-
|
|
1139
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1140
|
-
const snap = controller.getSnapshot();
|
|
1141
|
-
expect(snap.signIn.route).toBe('qr');
|
|
1142
|
-
expect(snap.signIn.error).toBeNull();
|
|
1143
|
-
controller.cancelSignIn();
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
it('pushes on mobile when no verified Commons link is available on this device', async () => {
|
|
1147
|
-
const openUrl = jest.fn();
|
|
1148
|
-
const { controller, oxy } = makeDeliveryHarness({
|
|
1149
|
-
platform: 'mobile',
|
|
1150
|
-
canOpenApp: jest.fn().mockResolvedValue(false),
|
|
1151
|
-
openUrl,
|
|
1152
|
-
});
|
|
1153
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1154
|
-
|
|
1155
|
-
await controller.showQr();
|
|
1156
|
-
await flush();
|
|
1157
|
-
|
|
1158
|
-
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1159
|
-
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1160
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
1161
|
-
controller.cancelSignIn();
|
|
1162
|
-
});
|
|
1163
|
-
|
|
1164
|
-
it('never deep-links from an unclassified surface, even with Commons installed', async () => {
|
|
1165
|
-
const openUrl = jest.fn();
|
|
1166
|
-
const { controller } = makeDeliveryHarness({
|
|
1167
|
-
// No `platform` supplied → 'unknown': the controller says so rather than
|
|
1168
|
-
// guessing, and an unresolved custom-scheme navigation is a dead end.
|
|
1169
|
-
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1170
|
-
openUrl,
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
await controller.showQr();
|
|
1174
|
-
await flush();
|
|
1175
|
-
|
|
1176
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
1177
|
-
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1178
|
-
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
1179
|
-
controller.cancelSignIn();
|
|
1180
|
-
});
|
|
1181
|
-
|
|
1182
|
-
it('marks the primary route FAILED (never auto-cascades) when the Commons link cannot be opened', async () => {
|
|
1183
|
-
// Route chosen: open-commons. No URL opener was injected, so the one action
|
|
1184
|
-
// the route implies cannot happen — the UI needs that fact to reveal
|
|
1185
|
-
// "Having trouble?"; the controller must not silently switch to another route.
|
|
1186
|
-
const { controller } = makeDeliveryHarness({
|
|
1187
|
-
platform: 'mobile',
|
|
1188
|
-
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
await controller.showQr();
|
|
1192
|
-
await flush();
|
|
1193
|
-
|
|
1194
|
-
const snap = controller.getSnapshot();
|
|
1195
|
-
expect(snap.signIn.route).toBe('open-commons');
|
|
1196
|
-
expect(snap.signIn.routeFailed).toBe(true);
|
|
1197
|
-
// Still a live request — the QR beneath it stays valid.
|
|
1198
|
-
expect(snap.signIn.phase).toBe('waiting');
|
|
1199
|
-
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1200
|
-
controller.cancelSignIn();
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
it('marks the primary route FAILED when opening the Commons link throws', async () => {
|
|
1204
|
-
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1205
|
-
const openUrl = jest.fn(() => {
|
|
1206
|
-
throw new Error('no handler for scheme');
|
|
1207
|
-
});
|
|
1208
|
-
const { controller } = makeDeliveryHarness({
|
|
1209
|
-
platform: 'mobile',
|
|
1210
|
-
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1211
|
-
openUrl,
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
await controller.showQr();
|
|
1215
|
-
await flush();
|
|
1216
|
-
|
|
1217
|
-
expect(controller.getSnapshot().signIn.route).toBe('open-commons');
|
|
1218
|
-
expect(controller.getSnapshot().signIn.routeFailed).toBe(true);
|
|
1219
|
-
expect(controller.getSnapshot().signIn.error).toBeNull();
|
|
1220
|
-
controller.cancelSignIn();
|
|
1221
|
-
debugSpy.mockRestore();
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
it('reports "preparing" until a route is actually resolved — it is never guessed', async () => {
|
|
1225
|
-
let releaseDelivery: () => void = () => undefined;
|
|
1226
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1227
|
-
oxy.deliverCommonsSignIn.mockImplementation(
|
|
1228
|
-
() =>
|
|
1229
|
-
new Promise((resolve) => {
|
|
1230
|
-
releaseDelivery = () => resolve({ delivered: true, targets: 1 });
|
|
1231
|
-
}),
|
|
1232
|
-
);
|
|
1233
|
-
|
|
1234
|
-
await controller.showQr();
|
|
1235
|
-
// The request exists and the QR is renderable, but no route has been chosen.
|
|
1236
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1237
|
-
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1238
|
-
expect(controller.getSnapshot().signIn.progress).toBe('preparing');
|
|
1239
|
-
|
|
1240
|
-
releaseDelivery();
|
|
1241
|
-
await flush();
|
|
1242
|
-
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1243
|
-
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1244
|
-
controller.cancelSignIn();
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
it('abandons route resolution when the flow was cancelled while it was in flight', async () => {
|
|
1248
|
-
const openUrl = jest.fn();
|
|
1249
|
-
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1250
|
-
const { controller, oxy } = makeDeliveryHarness({
|
|
1251
|
-
platform: 'mobile',
|
|
1252
|
-
canOpenApp: jest.fn(
|
|
1253
|
-
() =>
|
|
1254
|
-
new Promise<boolean>((resolve) => {
|
|
1255
|
-
releaseProbe = resolve;
|
|
1256
|
-
}),
|
|
1257
|
-
),
|
|
1258
|
-
openUrl,
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
await controller.showQr();
|
|
1262
|
-
controller.cancelSignIn();
|
|
1263
|
-
releaseProbe(true);
|
|
1264
|
-
await flush();
|
|
1265
|
-
|
|
1266
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
1267
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1268
|
-
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
|
-
it('abandons route resolution when the request was approved while it was in flight', async () => {
|
|
1272
|
-
jest.useFakeTimers();
|
|
1273
|
-
try {
|
|
1274
|
-
const openUrl = jest.fn();
|
|
1275
|
-
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1276
|
-
const { controller, oxy } = makeDeliveryHarness({
|
|
1277
|
-
platform: 'mobile',
|
|
1278
|
-
canOpenApp: jest.fn(
|
|
1279
|
-
() =>
|
|
1280
|
-
new Promise<boolean>((resolve) => {
|
|
1281
|
-
releaseProbe = resolve;
|
|
1282
|
-
}),
|
|
1283
|
-
),
|
|
1284
|
-
openUrl,
|
|
1285
|
-
});
|
|
1286
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
1287
|
-
oxy.claimSessionByToken.mockResolvedValue({
|
|
1288
|
-
accessToken: 'access-1',
|
|
1289
|
-
sessionId: 'sess-1',
|
|
1290
|
-
deviceId: 'device-1',
|
|
1291
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
1292
|
-
user: user('a1'),
|
|
1293
|
-
});
|
|
1294
|
-
|
|
1295
|
-
await controller.showQr();
|
|
1296
|
-
await jest.advanceTimersByTimeAsync(1000); // approval lands first
|
|
1297
|
-
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1298
|
-
|
|
1299
|
-
releaseProbe(true);
|
|
1300
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1301
|
-
|
|
1302
|
-
// Opening Commons after the session is already signed in would be a
|
|
1303
|
-
// pointless app switch — the resolution must abandon itself.
|
|
1304
|
-
expect(openUrl).not.toHaveBeenCalled();
|
|
1305
|
-
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1306
|
-
} finally {
|
|
1307
|
-
jest.useRealTimers();
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
it('never leaks the secret device-flow token into the snapshot', async () => {
|
|
1312
|
-
const { controller } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1313
|
-
|
|
1314
|
-
await controller.showQr();
|
|
1315
|
-
await flush();
|
|
1316
|
-
|
|
1317
|
-
const snap = controller.getSnapshot();
|
|
1318
|
-
expect(JSON.stringify(snap)).not.toContain('secret-tok');
|
|
1319
|
-
expect(Object.keys(snap.signIn)).toEqual([
|
|
1320
|
-
'phase',
|
|
1321
|
-
'authorizeCode',
|
|
1322
|
-
'qrPayload',
|
|
1323
|
-
'expiresAt',
|
|
1324
|
-
'error',
|
|
1325
|
-
'failure',
|
|
1326
|
-
'route',
|
|
1327
|
-
'routeFailed',
|
|
1328
|
-
'pushSentAt',
|
|
1329
|
-
'openedAt',
|
|
1330
|
-
'progress',
|
|
1331
|
-
'attempt',
|
|
1332
|
-
'inline',
|
|
1333
|
-
]);
|
|
1334
|
-
// Only the PUBLIC handles are exposed.
|
|
1335
|
-
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
1336
|
-
controller.cancelSignIn();
|
|
1337
|
-
});
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
describe('AccountDialogController — sign-in progress (#691 phase 5)', () => {
|
|
1341
|
-
it('advances only on real signals, in order, to "Identity confirmed"', async () => {
|
|
1342
|
-
jest.useFakeTimers();
|
|
1343
|
-
try {
|
|
1344
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1345
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1346
|
-
oxy.pollCommonsSignIn
|
|
1347
|
-
// 1st tick: still nothing but the confirmed push.
|
|
1348
|
-
.mockResolvedValueOnce({
|
|
1349
|
-
authorized: false,
|
|
1350
|
-
status: 'pending',
|
|
1351
|
-
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1352
|
-
openedAt: null,
|
|
1353
|
-
})
|
|
1354
|
-
// 2nd tick: the approver opened the request.
|
|
1355
|
-
.mockResolvedValueOnce({
|
|
1356
|
-
authorized: false,
|
|
1357
|
-
status: 'pending',
|
|
1358
|
-
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1359
|
-
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1360
|
-
})
|
|
1361
|
-
// 3rd tick: approved.
|
|
1362
|
-
.mockResolvedValue({
|
|
1363
|
-
authorized: true,
|
|
1364
|
-
sessionId: 'sess-1',
|
|
1365
|
-
status: 'authorized',
|
|
1366
|
-
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1367
|
-
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1368
|
-
});
|
|
1369
|
-
let releaseClaim: () => void = () => undefined;
|
|
1370
|
-
oxy.claimSessionByToken.mockImplementation(
|
|
1371
|
-
() =>
|
|
1372
|
-
new Promise((resolve) => {
|
|
1373
|
-
releaseClaim = () =>
|
|
1374
|
-
resolve({
|
|
1375
|
-
accessToken: 'access-1',
|
|
1376
|
-
sessionId: 'sess-1',
|
|
1377
|
-
deviceId: 'device-1',
|
|
1378
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
1379
|
-
user: user('a1'),
|
|
1380
|
-
});
|
|
1381
|
-
}),
|
|
1382
|
-
);
|
|
1383
|
-
|
|
1384
|
-
await controller.showQr();
|
|
1385
|
-
await jest.advanceTimersByTimeAsync(0); // let the route resolve
|
|
1386
|
-
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1387
|
-
|
|
1388
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1389
|
-
expect(controller.getSnapshot().signIn.pushSentAt).toBe('2026-07-27T10:00:00.000Z');
|
|
1390
|
-
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1391
|
-
|
|
1392
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1393
|
-
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1394
|
-
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1395
|
-
|
|
1396
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1397
|
-
// Approved: the claim/commit is running — "Confirming identity".
|
|
1398
|
-
expect(controller.getSnapshot().signIn.phase).toBe('authorized');
|
|
1399
|
-
expect(controller.getSnapshot().signIn.progress).toBe('confirming-identity');
|
|
1400
|
-
|
|
1401
|
-
releaseClaim();
|
|
1402
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1403
|
-
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1404
|
-
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
1405
|
-
expect(controller.getSnapshot().view).toBe('accounts');
|
|
1406
|
-
} finally {
|
|
1407
|
-
jest.useRealTimers();
|
|
1408
|
-
}
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
it('does not advance without a signal — repeated empty polls keep it waiting', async () => {
|
|
1412
|
-
jest.useFakeTimers();
|
|
1413
|
-
try {
|
|
1414
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1415
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1416
|
-
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1417
|
-
authorized: false,
|
|
1418
|
-
status: 'pending',
|
|
1419
|
-
pushSentAt: null,
|
|
1420
|
-
openedAt: null,
|
|
1421
|
-
});
|
|
1422
|
-
|
|
1423
|
-
await controller.showQr();
|
|
1424
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1425
|
-
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1426
|
-
|
|
1427
|
-
await jest.advanceTimersByTimeAsync(5000); // five fallback polls
|
|
1428
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(5);
|
|
1429
|
-
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1430
|
-
expect(controller.getSnapshot().signIn.pushSentAt).toBeNull();
|
|
1431
|
-
expect(controller.getSnapshot().signIn.openedAt).toBeNull();
|
|
1432
|
-
|
|
1433
|
-
controller.cancelSignIn();
|
|
1434
|
-
} finally {
|
|
1435
|
-
jest.useRealTimers();
|
|
1436
|
-
}
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
it('is monotone — a later, emptier status response never walks progress backwards', async () => {
|
|
1440
|
-
jest.useFakeTimers();
|
|
1441
|
-
try {
|
|
1442
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1443
|
-
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1444
|
-
oxy.pollCommonsSignIn
|
|
1445
|
-
.mockResolvedValueOnce({
|
|
1446
|
-
authorized: false,
|
|
1447
|
-
status: 'pending',
|
|
1448
|
-
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1449
|
-
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1450
|
-
})
|
|
1451
|
-
// An older API build (or a partial payload) omits the progress fields.
|
|
1452
|
-
.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1453
|
-
|
|
1454
|
-
await controller.showQr();
|
|
1455
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1456
|
-
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1457
|
-
|
|
1458
|
-
await jest.advanceTimersByTimeAsync(2000);
|
|
1459
|
-
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1460
|
-
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1461
|
-
|
|
1462
|
-
controller.cancelSignIn();
|
|
1463
|
-
} finally {
|
|
1464
|
-
jest.useRealTimers();
|
|
1465
|
-
}
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
it('treats "opened in Commons" as progress only — it never claims a session', async () => {
|
|
1469
|
-
jest.useFakeTimers();
|
|
1470
|
-
try {
|
|
1471
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1472
|
-
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1473
|
-
authorized: false,
|
|
1474
|
-
status: 'pending',
|
|
1475
|
-
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1476
|
-
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
await controller.showQr();
|
|
1480
|
-
await jest.advanceTimersByTimeAsync(3000);
|
|
1481
|
-
|
|
1482
|
-
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1483
|
-
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1484
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1485
|
-
|
|
1486
|
-
controller.cancelSignIn();
|
|
1487
|
-
} finally {
|
|
1488
|
-
jest.useRealTimers();
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
describe('AccountDialogController — cancellation converges (#691 phase 5)', () => {
|
|
1494
|
-
it('a denial in Commons converges on the waiting surface and stops the flow', async () => {
|
|
1495
|
-
jest.useFakeTimers();
|
|
1496
|
-
try {
|
|
1497
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1498
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1499
|
-
|
|
1500
|
-
await controller.showQr();
|
|
1501
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1502
|
-
|
|
1503
|
-
const snap = controller.getSnapshot();
|
|
1504
|
-
expect(snap.signIn.phase).toBe('error');
|
|
1505
|
-
expect(snap.signIn.error).toMatch(/denied/i);
|
|
1506
|
-
expect(snap.signIn.progress).toBe('idle');
|
|
1507
|
-
|
|
1508
|
-
await jest.advanceTimersByTimeAsync(10_000);
|
|
1509
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1510
|
-
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1511
|
-
} finally {
|
|
1512
|
-
jest.useRealTimers();
|
|
1513
|
-
}
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
it('a server-reported expiry converges and stops the flow', async () => {
|
|
1517
|
-
jest.useFakeTimers();
|
|
1518
|
-
try {
|
|
1519
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1520
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'expired' });
|
|
1521
|
-
|
|
1522
|
-
await controller.showQr();
|
|
1523
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1524
|
-
|
|
1525
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1526
|
-
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1527
|
-
|
|
1528
|
-
await jest.advanceTimersByTimeAsync(10_000);
|
|
1529
|
-
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1530
|
-
} finally {
|
|
1531
|
-
jest.useRealTimers();
|
|
1532
|
-
}
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
it('a locally-observed expiry converges without even asking the server', async () => {
|
|
1536
|
-
jest.useFakeTimers();
|
|
1537
|
-
try {
|
|
1538
|
-
const oxy = makeOxy();
|
|
1539
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
1540
|
-
...DELIVERY_HANDLE,
|
|
1541
|
-
expiresAt: Date.now() + 500, // expires before the first fallback poll
|
|
1542
|
-
});
|
|
1543
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1544
|
-
const controller = new AccountDialogController({
|
|
1545
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
1546
|
-
sessionClient: new TestSessionClient(host().host),
|
|
1547
|
-
clientId: 'oxy_dk_test',
|
|
1548
|
-
pollIntervalMs: 1000,
|
|
1549
|
-
platform: 'desktop',
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
await controller.showQr();
|
|
1553
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1554
|
-
|
|
1555
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1556
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1557
|
-
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1558
|
-
} finally {
|
|
1559
|
-
jest.useRealTimers();
|
|
1560
|
-
}
|
|
1561
|
-
});
|
|
1562
|
-
|
|
1563
|
-
it('closing the surface withdraws the request server-side and stops every timer', async () => {
|
|
1564
|
-
jest.useFakeTimers();
|
|
1565
|
-
try {
|
|
1566
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1567
|
-
|
|
1568
|
-
await controller.showQr();
|
|
1569
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1570
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1571
|
-
|
|
1572
|
-
controller.cancelSignIn();
|
|
1573
|
-
|
|
1574
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1575
|
-
expect(controller.getSnapshot().signIn).toEqual({
|
|
1576
|
-
phase: 'idle',
|
|
1577
|
-
authorizeCode: null,
|
|
1578
|
-
qrPayload: null,
|
|
1579
|
-
expiresAt: null,
|
|
1580
|
-
error: null,
|
|
1581
|
-
failure: null,
|
|
1582
|
-
route: null,
|
|
1583
|
-
routeFailed: false,
|
|
1584
|
-
pushSentAt: null,
|
|
1585
|
-
openedAt: null,
|
|
1586
|
-
progress: 'idle',
|
|
1587
|
-
attempt: expect.any(Number),
|
|
1588
|
-
inline: false,
|
|
1589
|
-
});
|
|
1590
|
-
await jest.advanceTimersByTimeAsync(10_000);
|
|
1591
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1592
|
-
} finally {
|
|
1593
|
-
jest.useRealTimers();
|
|
1594
|
-
}
|
|
1595
|
-
});
|
|
1596
|
-
|
|
1597
|
-
it('does not withdraw a request that already reached a terminal state', async () => {
|
|
1598
|
-
jest.useFakeTimers();
|
|
1599
|
-
try {
|
|
1600
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1601
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1602
|
-
|
|
1603
|
-
await controller.showQr();
|
|
1604
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
1605
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1606
|
-
|
|
1607
|
-
controller.cancelSignIn();
|
|
1608
|
-
// Commons already cancelled it — nothing left to withdraw.
|
|
1609
|
-
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
|
|
1610
|
-
} finally {
|
|
1611
|
-
jest.useRealTimers();
|
|
1612
|
-
}
|
|
1613
|
-
});
|
|
1614
|
-
|
|
1615
|
-
it('swallows a failed withdrawal (an approval may have raced it) without surfacing an error', async () => {
|
|
1616
|
-
jest.useFakeTimers();
|
|
1617
|
-
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1618
|
-
try {
|
|
1619
|
-
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1620
|
-
oxy.denyCommonsSignIn.mockRejectedValue(new Error('already authorized'));
|
|
1621
|
-
|
|
1622
|
-
await controller.showQr();
|
|
1623
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1624
|
-
controller.cancelSignIn();
|
|
1625
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1626
|
-
|
|
1627
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
1628
|
-
expect(controller.getSnapshot().error).toBeNull();
|
|
1629
|
-
expect(debugSpy).toHaveBeenCalledWith(
|
|
1630
|
-
'[AccountDialogController] request withdrawal failed',
|
|
1631
|
-
{ component: 'AccountDialogController' },
|
|
1632
|
-
expect.any(Error),
|
|
1633
|
-
);
|
|
1634
|
-
} finally {
|
|
1635
|
-
jest.useRealTimers();
|
|
1636
|
-
debugSpy.mockRestore();
|
|
1637
|
-
}
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
it('destroy tears down the poll timer and the auth-session socket', async () => {
|
|
1641
|
-
jest.useFakeTimers();
|
|
1642
|
-
try {
|
|
1643
|
-
const oxy = makeOxy();
|
|
1644
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1645
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1646
|
-
let socket: { disconnected: boolean } | null = null;
|
|
1647
|
-
const factory = jest.fn((): MinimalSocket => {
|
|
1648
|
-
const s: MinimalSocket & { disconnected: boolean } = {
|
|
1649
|
-
connected: true,
|
|
1650
|
-
disconnected: false,
|
|
1651
|
-
on: () => undefined,
|
|
1652
|
-
off: () => undefined,
|
|
1653
|
-
emit: () => undefined,
|
|
1654
|
-
connect: () => undefined,
|
|
1655
|
-
disconnect() {
|
|
1656
|
-
this.disconnected = true;
|
|
1657
|
-
},
|
|
1658
|
-
};
|
|
1659
|
-
socket = s;
|
|
1660
|
-
return s;
|
|
1661
|
-
});
|
|
1662
|
-
const controller = new AccountDialogController({
|
|
1663
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
1664
|
-
sessionClient: new TestSessionClient(host().host),
|
|
1665
|
-
clientId: 'oxy_dk_test',
|
|
1666
|
-
pollIntervalMs: 1000,
|
|
1667
|
-
platform: 'desktop',
|
|
1668
|
-
socketFactory: factory as unknown as SocketIOFactory,
|
|
1669
|
-
});
|
|
1670
|
-
|
|
1671
|
-
await controller.showQr();
|
|
1672
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
1673
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1674
|
-
|
|
1675
|
-
controller.destroy();
|
|
1676
|
-
|
|
1677
|
-
expect(socket).not.toBeNull();
|
|
1678
|
-
expect(socket?.disconnected).toBe(true);
|
|
1679
|
-
// No poll timer survives the teardown.
|
|
1680
|
-
await jest.advanceTimersByTimeAsync(30_000);
|
|
1681
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1682
|
-
} finally {
|
|
1683
|
-
jest.useRealTimers();
|
|
1684
|
-
}
|
|
1685
|
-
});
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
|
-
describe('AccountDialogController — lifecycle', () => {
|
|
1689
|
-
it('destroy unsubscribes so later device-state changes do not notify', async () => {
|
|
1690
|
-
const { controller, oxy, sc } = makeHarness();
|
|
1691
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
1692
|
-
controller.start();
|
|
1693
|
-
await Promise.resolve();
|
|
1694
|
-
const seen: string[] = [];
|
|
1695
|
-
controller.subscribe((s) => seen.push(s.view));
|
|
1696
|
-
controller.destroy();
|
|
1697
|
-
// destroy clears all listeners; a subsequent state push notifies nobody.
|
|
1698
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
1699
|
-
expect(seen).toEqual([]);
|
|
1700
|
-
});
|
|
1701
|
-
});
|
|
1702
|
-
|
|
1703
|
-
describe('AccountDialogController — the directory (ADR 0002)', () => {
|
|
1704
|
-
/** A two-principal device where `org` is reachable through BOTH people. */
|
|
1705
|
-
const sharedDirectory = (revision: number, activeContextId: string | null) => ({
|
|
1706
|
-
deviceId: 'device-1',
|
|
1707
|
-
revision,
|
|
1708
|
-
activeContextId,
|
|
1709
|
-
updatedAt: 1_720_000_000_000,
|
|
1710
|
-
principals: [
|
|
1711
|
-
{
|
|
1712
|
-
id: 'p-nate',
|
|
1713
|
-
userId: 'nate',
|
|
1714
|
-
authuser: 0,
|
|
1715
|
-
user: { id: 'nate', username: 'nate' },
|
|
1716
|
-
contexts: [
|
|
1717
|
-
{
|
|
1718
|
-
id: 'ctx-nate-org',
|
|
1719
|
-
accountId: 'org',
|
|
1720
|
-
kind: 'organization' as const,
|
|
1721
|
-
relationship: 'owner' as const,
|
|
1722
|
-
account: { id: 'org', username: 'oxy' },
|
|
1723
|
-
onDevice: true,
|
|
1724
|
-
available: true,
|
|
1725
|
-
active: activeContextId === 'ctx-nate-org',
|
|
1726
|
-
lastUsedAt: null,
|
|
1727
|
-
},
|
|
1728
|
-
],
|
|
1729
|
-
},
|
|
1730
|
-
{
|
|
1731
|
-
id: 'p-alice',
|
|
1732
|
-
userId: 'alice',
|
|
1733
|
-
authuser: 1,
|
|
1734
|
-
user: { id: 'alice', username: 'alice' },
|
|
1735
|
-
contexts: [
|
|
1736
|
-
{
|
|
1737
|
-
id: 'ctx-alice-org',
|
|
1738
|
-
accountId: 'org',
|
|
1739
|
-
kind: 'organization' as const,
|
|
1740
|
-
relationship: 'member' as const,
|
|
1741
|
-
account: { id: 'org', username: 'oxy' },
|
|
1742
|
-
onDevice: true,
|
|
1743
|
-
available: true,
|
|
1744
|
-
active: activeContextId === 'ctx-alice-org',
|
|
1745
|
-
lastUsedAt: null,
|
|
1746
|
-
},
|
|
1747
|
-
],
|
|
1748
|
-
},
|
|
1749
|
-
],
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
function directoryHarness(activeContextId: string | null) {
|
|
1753
|
-
const oxy = makeOxy();
|
|
1754
|
-
const urls: string[] = [];
|
|
1755
|
-
const bodies: unknown[] = [];
|
|
1756
|
-
const sc = new TestSessionClient({
|
|
1757
|
-
makeRequest: jest.fn(async (_method: string, url: string, data?: unknown) => {
|
|
1758
|
-
urls.push(url);
|
|
1759
|
-
bodies.push(data);
|
|
1760
|
-
if (url === '/session/device/directory') return sharedDirectory(5, activeContextId);
|
|
1761
|
-
if (url === '/session/device/activate') {
|
|
1762
|
-
return { directory: sharedDirectory(6, 'ctx-alice-org'), activeToken: null };
|
|
1763
|
-
}
|
|
1764
|
-
if (url === '/session/device/signout') {
|
|
1765
|
-
// The context-aware removals answer with their OWN contract — the
|
|
1766
|
-
// directory AND the flat state, because a removal elects a
|
|
1767
|
-
// replacement active context and both halves move together.
|
|
1768
|
-
return {
|
|
1769
|
-
directory: sharedDirectory(7, null),
|
|
1770
|
-
state: state([{ accountId: 'org', sessionId: 's-org' }], 'org', 7),
|
|
1771
|
-
activeToken: null,
|
|
1772
|
-
};
|
|
1773
|
-
}
|
|
1774
|
-
return undefined;
|
|
1775
|
-
}),
|
|
1776
|
-
getBaseURL: () => 'http://test.invalid',
|
|
1777
|
-
getAccessToken: () => 'token',
|
|
1778
|
-
getDeviceCredential: () => null,
|
|
1779
|
-
onTokensChanged: () => () => undefined,
|
|
1780
|
-
setTokens: jest.fn(),
|
|
1781
|
-
getCurrentAccountId: () => null,
|
|
1782
|
-
});
|
|
1783
|
-
const controller = createAccountDialogController({
|
|
1784
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
1785
|
-
sessionClient: sc,
|
|
1786
|
-
clientId: 'oxy_dk_test',
|
|
1787
|
-
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
1788
|
-
onSignedIn: jest.fn(),
|
|
1789
|
-
});
|
|
1790
|
-
return { controller, oxy, sc, urls, bodies };
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
it('publishes the directory and the active context on the snapshot', async () => {
|
|
1794
|
-
const { controller, oxy } = directoryHarness('ctx-alice-org');
|
|
1795
|
-
oxy.listAccounts.mockResolvedValue([]);
|
|
1796
|
-
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1797
|
-
|
|
1798
|
-
await controller.refresh();
|
|
1799
|
-
|
|
1800
|
-
const snap = controller.getSnapshot();
|
|
1801
|
-
expect(snap.directory?.revision).toBe(5);
|
|
1802
|
-
// The flat `accounts` list cannot express this: it is keyed by account id,
|
|
1803
|
-
// so ONE of these two routes to `org` would be all a consumer ever saw.
|
|
1804
|
-
expect(snap.directory?.principals.map((p) => p.userId)).toEqual(['nate', 'alice']);
|
|
1805
|
-
expect(snap.activeContext?.contextId).toBe('ctx-alice-org');
|
|
1806
|
-
expect(snap.activeContext?.actor.userId).toBe('alice');
|
|
1807
|
-
expect(snap.activeContext?.subject.accountId).toBe('org');
|
|
1808
|
-
expect(snap.activeContext?.isDelegated).toBe(true);
|
|
1809
|
-
});
|
|
1810
|
-
|
|
1811
|
-
it('activateContext POSTs the contextId and reports the switch in flight', async () => {
|
|
1812
|
-
const { controller, oxy, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1813
|
-
oxy.listAccounts.mockResolvedValue([]);
|
|
1814
|
-
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1815
|
-
await controller.refresh();
|
|
1816
|
-
|
|
1817
|
-
const inFlight: Array<string | null> = [];
|
|
1818
|
-
controller.subscribe((s) => inFlight.push(s.activatingContextId));
|
|
1819
|
-
|
|
1820
|
-
expect(await controller.activateContext('ctx-alice-org')).toBe(true);
|
|
1821
|
-
|
|
1822
|
-
expect(urls).toContain('/session/device/activate');
|
|
1823
|
-
expect(bodies[urls.indexOf('/session/device/activate')]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1824
|
-
// Reported while running, cleared after — a row can show a spinner.
|
|
1825
|
-
expect(inFlight).toContain('ctx-alice-org');
|
|
1826
|
-
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1827
|
-
expect(controller.getSnapshot().activeContext?.actor.userId).toBe('alice');
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
it('refuses a second activation while one is in flight', async () => {
|
|
1831
|
-
const { controller, oxy } = directoryHarness('ctx-nate-org');
|
|
1832
|
-
oxy.listAccounts.mockResolvedValue([]);
|
|
1833
|
-
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1834
|
-
await controller.refresh();
|
|
1835
|
-
|
|
1836
|
-
const first = controller.activateContext('ctx-alice-org');
|
|
1837
|
-
expect(await controller.activateContext('ctx-nate-org')).toBe(false);
|
|
1838
|
-
expect(await first).toBe(true);
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
it('surfaces an activation failure as a dialog error without wedging the flag', async () => {
|
|
1842
|
-
const { controller, oxy, sc } = directoryHarness('ctx-nate-org');
|
|
1843
|
-
oxy.listAccounts.mockResolvedValue([]);
|
|
1844
|
-
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1845
|
-
await controller.refresh();
|
|
1846
|
-
// A stale context id is an ordinary outcome, not a bug: the server 404s and
|
|
1847
|
-
// heals the row away.
|
|
1848
|
-
jest.spyOn(sc, 'activateContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1849
|
-
|
|
1850
|
-
expect(await controller.activateContext('ctx-gone')).toBe(false);
|
|
1851
|
-
|
|
1852
|
-
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1853
|
-
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1854
|
-
});
|
|
1855
|
-
|
|
1856
|
-
it('keeps the directory it already holds when a later read fails, and says so', async () => {
|
|
1857
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1858
|
-
const { controller, failDirectory } = makeHarness();
|
|
1859
|
-
|
|
1860
|
-
await controller.refresh();
|
|
1861
|
-
expect(controller.getSnapshot().directory?.revision).toBe(9_000);
|
|
1862
|
-
|
|
1863
|
-
failDirectory(new Error('boom'));
|
|
1864
|
-
await controller.refresh();
|
|
1865
|
-
|
|
1866
|
-
// Degraded, not blank. A transient outage must not empty a switcher the
|
|
1867
|
-
// user is looking at — but it must not pretend either, so the error is on
|
|
1868
|
-
// the snapshot for the surface to render.
|
|
1869
|
-
const snap = controller.getSnapshot();
|
|
1870
|
-
expect(snap.directory?.revision).toBe(9_000);
|
|
1871
|
-
expect(snap.error).toBe('boom');
|
|
1872
|
-
expect(snap.loading).toBe(false);
|
|
1873
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
1874
|
-
'[AccountDialogController] directory refresh failed',
|
|
1875
|
-
{ component: 'AccountDialogController' },
|
|
1876
|
-
expect.any(Error),
|
|
1877
|
-
);
|
|
1878
|
-
warnSpy.mockRestore();
|
|
1879
|
-
});
|
|
1880
|
-
|
|
1881
|
-
it('spins only while there is nothing to show', async () => {
|
|
1882
|
-
const { controller } = makeHarness();
|
|
1883
|
-
const loading: boolean[] = [];
|
|
1884
|
-
controller.subscribe((s) => loading.push(s.loading));
|
|
1885
|
-
|
|
1886
|
-
// First read: nothing held, so the switcher has nothing to render.
|
|
1887
|
-
await controller.refresh();
|
|
1888
|
-
expect(loading).toContain(true);
|
|
1889
|
-
expect(controller.getSnapshot().loading).toBe(false);
|
|
1890
|
-
|
|
1891
|
-
// Second read, behind a directory already on screen: refreshed in place,
|
|
1892
|
-
// never blanked back to a spinner.
|
|
1893
|
-
loading.length = 0;
|
|
1894
|
-
await controller.refresh();
|
|
1895
|
-
expect(loading).not.toContain(true);
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
it('removes ONE route to a shared account, naming the pair and not the account', async () => {
|
|
1899
|
-
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1900
|
-
await controller.refresh();
|
|
1901
|
-
|
|
1902
|
-
const inFlight: Array<string | null> = [];
|
|
1903
|
-
controller.subscribe((s) => inFlight.push(s.removingContextId));
|
|
1904
|
-
|
|
1905
|
-
expect(await controller.signOutContext('ctx-alice-org')).toBe(true);
|
|
1906
|
-
|
|
1907
|
-
const index = urls.indexOf('/session/device/signout');
|
|
1908
|
-
expect(index).toBeGreaterThanOrEqual(0);
|
|
1909
|
-
// `{ contextId }`, never `{ accountId }`. Both people reach `org` here, and
|
|
1910
|
-
// an account id would revoke Nate's route as a side effect of Alice tidying
|
|
1911
|
-
// her own list.
|
|
1912
|
-
expect(bodies[index]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1913
|
-
expect(inFlight).toContain('ctx-alice-org');
|
|
1914
|
-
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1915
|
-
});
|
|
1916
|
-
|
|
1917
|
-
it('removes ONE PERSON through the principal id, not through their contexts', async () => {
|
|
1918
|
-
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1919
|
-
await controller.refresh();
|
|
1920
|
-
|
|
1921
|
-
const inFlight: Array<string | null> = [];
|
|
1922
|
-
controller.subscribe((s) => inFlight.push(s.removingPrincipalId));
|
|
1923
|
-
|
|
1924
|
-
expect(await controller.signOutPrincipal('p-alice')).toBe(true);
|
|
1925
|
-
|
|
1926
|
-
const index = urls.indexOf('/session/device/signout');
|
|
1927
|
-
// `{ principalId }` — one call, not a loop over that person's contexts, and
|
|
1928
|
-
// emphatically not the context endpoint: pointing this at `{ contextId }`
|
|
1929
|
-
// would drop one pair and leave the person on the device.
|
|
1930
|
-
expect(bodies[index]).toEqual({ principalId: 'p-alice' });
|
|
1931
|
-
expect(urls.filter((u) => u === '/session/device/signout')).toHaveLength(1);
|
|
1932
|
-
expect(inFlight).toContain('p-alice');
|
|
1933
|
-
expect(controller.getSnapshot().removingPrincipalId).toBeNull();
|
|
1934
|
-
});
|
|
1935
|
-
|
|
1936
|
-
it('refuses a second removal while one is in flight, of either kind', async () => {
|
|
1937
|
-
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1938
|
-
await controller.refresh();
|
|
1939
|
-
let release: () => void = () => undefined;
|
|
1940
|
-
jest.spyOn(sc, 'signOutContext').mockImplementation(
|
|
1941
|
-
() => new Promise<void>((resolve) => { release = resolve; }),
|
|
1942
|
-
);
|
|
1943
|
-
const principalSpy = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
|
|
1944
|
-
|
|
1945
|
-
const first = controller.signOutContext('ctx-alice-org');
|
|
1946
|
-
// The two removals share one gate: they mutate the same device and a
|
|
1947
|
-
// concurrent pair would race for which replacement context ends up active.
|
|
1948
|
-
expect(await controller.signOutPrincipal('p-alice')).toBe(false);
|
|
1949
|
-
expect(principalSpy).not.toHaveBeenCalled();
|
|
1950
|
-
|
|
1951
|
-
release();
|
|
1952
|
-
expect(await first).toBe(true);
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
it('surfaces a failed removal without wedging the flag', async () => {
|
|
1956
|
-
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1957
|
-
await controller.refresh();
|
|
1958
|
-
// A context id is not stable across a removal, so a stale one is ordinary:
|
|
1959
|
-
// the server refuses and the next read simply does not offer it.
|
|
1960
|
-
jest.spyOn(sc, 'signOutContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1961
|
-
|
|
1962
|
-
expect(await controller.signOutContext('ctx-gone')).toBe(false);
|
|
1963
|
-
|
|
1964
|
-
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1965
|
-
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1966
|
-
});
|
|
1967
|
-
|
|
1968
|
-
it('makes no directory call while signed out', async () => {
|
|
1969
|
-
const { controller, oxy, urls } = directoryHarness(null);
|
|
1970
|
-
oxy.getAccessToken.mockReturnValue(null);
|
|
1971
|
-
oxy.listAccounts.mockResolvedValue([]);
|
|
1972
|
-
|
|
1973
|
-
await controller.refresh();
|
|
1974
|
-
|
|
1975
|
-
expect(urls).not.toContain('/session/device/directory');
|
|
1976
|
-
expect(controller.getSnapshot().directory).toBeNull();
|
|
1977
|
-
});
|
|
1978
|
-
});
|
|
1979
|
-
|
|
1980
|
-
/** A promise whose settlement the test controls — a slow network, on demand. */
|
|
1981
|
-
function deferred<T>(): {
|
|
1982
|
-
promise: Promise<T>;
|
|
1983
|
-
resolve: (value: T) => void;
|
|
1984
|
-
reject: (error: unknown) => void;
|
|
1985
|
-
} {
|
|
1986
|
-
let resolve: (value: T) => void = () => undefined;
|
|
1987
|
-
let reject: (error: unknown) => void = () => undefined;
|
|
1988
|
-
const promise = new Promise<T>((res, rej) => {
|
|
1989
|
-
resolve = res;
|
|
1990
|
-
reject = rej;
|
|
1991
|
-
});
|
|
1992
|
-
return { promise, resolve, reject };
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
const CLAIMED_SESSION = {
|
|
1996
|
-
accessToken: 'access-claimed',
|
|
1997
|
-
sessionId: 'sess-claimed',
|
|
1998
|
-
deviceId: 'device-1',
|
|
1999
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
2000
|
-
user: user('b1'),
|
|
2001
|
-
};
|
|
2002
|
-
|
|
2003
|
-
describe('AccountDialogController — an abandoned attempt stays abandoned', () => {
|
|
2004
|
-
it('withdraws a request that is created only after the user closed the dialog, and wires nothing up', async () => {
|
|
2005
|
-
jest.useFakeTimers();
|
|
2006
|
-
try {
|
|
2007
|
-
const { controller, oxy } = makeHarness();
|
|
2008
|
-
const created = deferred<typeof DELIVERY_HANDLE & { expiresAt: number }>();
|
|
2009
|
-
oxy.startCommonsSignIn.mockReturnValue(created.promise);
|
|
2010
|
-
|
|
2011
|
-
const running = controller.showQr();
|
|
2012
|
-
expect(controller.getSnapshot().signIn.phase).toBe('starting');
|
|
2013
|
-
// Closed on a slow connection: there is no authorize code to withdraw yet.
|
|
2014
|
-
controller.cancelSignIn();
|
|
2015
|
-
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
|
|
2016
|
-
|
|
2017
|
-
created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2018
|
-
await running;
|
|
2019
|
-
|
|
2020
|
-
// The late request is withdrawn the moment it exists, and nothing revives:
|
|
2021
|
-
// no waiting surface, no delivery, no poll.
|
|
2022
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
2023
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2024
|
-
expect(controller.getSnapshot().signIn.authorizeCode).toBeNull();
|
|
2025
|
-
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
2026
|
-
await jest.advanceTimersByTimeAsync(10_000);
|
|
2027
|
-
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
2028
|
-
} finally {
|
|
2029
|
-
jest.useRealTimers();
|
|
2030
|
-
}
|
|
2031
|
-
});
|
|
2032
|
-
|
|
2033
|
-
it('lets a newer attempt win over a slower earlier one', async () => {
|
|
2034
|
-
const { controller, oxy } = makeHarness();
|
|
2035
|
-
const first = deferred<unknown>();
|
|
2036
|
-
oxy.startCommonsSignIn
|
|
2037
|
-
.mockReturnValueOnce(first.promise)
|
|
2038
|
-
.mockResolvedValueOnce({
|
|
2039
|
-
sessionToken: 'tok-2',
|
|
2040
|
-
authorizeCode: 'CODE-2',
|
|
2041
|
-
qrPayload: 'oxycommons://approve?code=CODE-2',
|
|
2042
|
-
expiresAt: Date.now() + 600_000,
|
|
2043
|
-
status: 'pending',
|
|
2044
|
-
});
|
|
2045
|
-
|
|
2046
|
-
const slow = controller.showQr();
|
|
2047
|
-
await controller.showQr();
|
|
2048
|
-
first.resolve({
|
|
2049
|
-
sessionToken: 'tok-1',
|
|
2050
|
-
authorizeCode: 'CODE-1',
|
|
2051
|
-
qrPayload: 'oxycommons://approve?code=CODE-1',
|
|
2052
|
-
expiresAt: Date.now() + 600_000,
|
|
2053
|
-
status: 'pending',
|
|
2054
|
-
});
|
|
2055
|
-
await slow;
|
|
2056
|
-
|
|
2057
|
-
expect(controller.getSnapshot().signIn.authorizeCode).toBe('CODE-2');
|
|
2058
|
-
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('CODE-1');
|
|
2059
|
-
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalledWith('CODE-2');
|
|
2060
|
-
controller.cancelSignIn();
|
|
2061
|
-
});
|
|
2062
|
-
|
|
2063
|
-
it('never installs a shared-identity session that was minted after the user cancelled', async () => {
|
|
2064
|
-
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2065
|
-
const minted = deferred<SessionLoginResponse>();
|
|
2066
|
-
oxy.signInWithSharedIdentity.mockReturnValue(minted.promise);
|
|
2067
|
-
|
|
2068
|
-
const running = controller.signInWithOxy();
|
|
2069
|
-
// Minted without planting: the controller decides whether to install it.
|
|
2070
|
-
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
2071
|
-
controller.cancelSignIn();
|
|
2072
|
-
minted.resolve({
|
|
2073
|
-
sessionId: 'sess-shared',
|
|
2074
|
-
deviceId: 'device-1',
|
|
2075
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
2076
|
-
accessToken: 'access-shared',
|
|
2077
|
-
user: { id: 'a1', username: 'user_a1' },
|
|
2078
|
-
});
|
|
2079
|
-
await running;
|
|
2080
|
-
|
|
2081
|
-
expect(oxy.setTokens).not.toHaveBeenCalled();
|
|
2082
|
-
expect(commitSession).not.toHaveBeenCalled();
|
|
2083
|
-
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2084
|
-
// …and the abandoned attempt does not fall through to a QR request either.
|
|
2085
|
-
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
2086
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2087
|
-
});
|
|
2088
|
-
|
|
2089
|
-
it('never installs a session whose claim returned after the user cancelled', async () => {
|
|
2090
|
-
jest.useFakeTimers();
|
|
2091
|
-
try {
|
|
2092
|
-
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2093
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2094
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2095
|
-
const claim = deferred<typeof CLAIMED_SESSION>();
|
|
2096
|
-
oxy.claimSessionByToken.mockReturnValue(claim.promise);
|
|
2097
|
-
|
|
2098
|
-
await controller.showQr();
|
|
2099
|
-
await jest.advanceTimersByTimeAsync(1000); // poll → authorized → claim in flight
|
|
2100
|
-
expect(controller.getSnapshot().signIn.phase).toBe('authorized');
|
|
2101
|
-
|
|
2102
|
-
controller.cancelSignIn();
|
|
2103
|
-
claim.resolve(CLAIMED_SESSION);
|
|
2104
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
2105
|
-
|
|
2106
|
-
expect(oxy.setTokens).not.toHaveBeenCalled();
|
|
2107
|
-
expect(commitSession).not.toHaveBeenCalled();
|
|
2108
|
-
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2109
|
-
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
2110
|
-
} finally {
|
|
2111
|
-
jest.useRealTimers();
|
|
2112
|
-
}
|
|
2113
|
-
});
|
|
2114
|
-
|
|
2115
|
-
it('plants the claimed bearer at the install point, immediately before the commit', async () => {
|
|
2116
|
-
jest.useFakeTimers();
|
|
2117
|
-
try {
|
|
2118
|
-
const { controller, oxy, commitSession } = makeHarness();
|
|
2119
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2120
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2121
|
-
oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
|
|
2122
|
-
|
|
2123
|
-
await controller.showQr();
|
|
2124
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
2125
|
-
|
|
2126
|
-
expect(oxy.setTokens).toHaveBeenCalledWith('access-claimed');
|
|
2127
|
-
expect(oxy.setTokens.mock.invocationCallOrder[0]).toBeLessThan(
|
|
2128
|
-
commitSession.mock.invocationCallOrder[0],
|
|
2129
|
-
);
|
|
2130
|
-
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
2131
|
-
} finally {
|
|
2132
|
-
jest.useRealTimers();
|
|
2133
|
-
}
|
|
2134
|
-
});
|
|
2135
|
-
|
|
2136
|
-
it('restores the previous bearer when the commit fails, and fails the attempt', async () => {
|
|
2137
|
-
jest.useFakeTimers();
|
|
2138
|
-
try {
|
|
2139
|
-
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
2140
|
-
commitSession.mockRejectedValue(new Error('persist failed'));
|
|
2141
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2142
|
-
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
|
|
2143
|
-
oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
|
|
2144
|
-
|
|
2145
|
-
await controller.showQr();
|
|
2146
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
2147
|
-
|
|
2148
|
-
// Requests must not keep going out as an account the device never took.
|
|
2149
|
-
expect(oxy.getAccessToken()).toBe('access-token');
|
|
2150
|
-
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2151
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2152
|
-
expect(controller.getSnapshot().signIn.failure).toBe('unknown');
|
|
2153
|
-
} finally {
|
|
2154
|
-
jest.useRealTimers();
|
|
2155
|
-
}
|
|
2156
|
-
});
|
|
2157
|
-
|
|
2158
|
-
it('falls through to the QR handoff when a shared-identity session cannot be committed', async () => {
|
|
2159
|
-
const { controller, oxy, commitSession } = makeHarness();
|
|
2160
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2161
|
-
commitSession.mockRejectedValue(new Error('persist failed'));
|
|
2162
|
-
oxy.signInWithSharedIdentity.mockResolvedValue({
|
|
2163
|
-
sessionId: 'sess-shared',
|
|
2164
|
-
deviceId: 'device-1',
|
|
2165
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
2166
|
-
accessToken: 'access-shared',
|
|
2167
|
-
user: { id: 'a1', username: 'user_a1' },
|
|
2168
|
-
});
|
|
2169
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2170
|
-
|
|
2171
|
-
await controller.signInWithOxy();
|
|
2172
|
-
|
|
2173
|
-
expect(oxy.getAccessToken()).toBe('access-token');
|
|
2174
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2175
|
-
controller.cancelSignIn();
|
|
2176
|
-
warnSpy.mockRestore();
|
|
2177
|
-
});
|
|
2178
|
-
|
|
2179
|
-
it('classifies an unreachable server as a network failure', async () => {
|
|
2180
|
-
const { controller, oxy } = makeHarness();
|
|
2181
|
-
oxy.startCommonsSignIn.mockRejectedValue(
|
|
2182
|
-
Object.assign(new Error('Network error - failed to connect to server'), { code: 'NETWORK_ERROR', status: 0 }),
|
|
2183
|
-
);
|
|
2184
|
-
|
|
2185
|
-
await controller.showQr();
|
|
2186
|
-
|
|
2187
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2188
|
-
expect(controller.getSnapshot().signIn.failure).toBe('network');
|
|
2189
|
-
});
|
|
2190
|
-
|
|
2191
|
-
it('moves the attempt identity on every new attempt, and keeps it on the failure it belongs to', async () => {
|
|
2192
|
-
const { controller, oxy } = makeHarness({ clientId: null });
|
|
2193
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2194
|
-
|
|
2195
|
-
await controller.showQr();
|
|
2196
|
-
const firstFailure = controller.getSnapshot().signIn.attempt;
|
|
2197
|
-
await controller.showQr();
|
|
2198
|
-
|
|
2199
|
-
// Same message, different attempt — a surface can tell a repeat of the
|
|
2200
|
-
// failure it already reported from a new one.
|
|
2201
|
-
expect(controller.getSnapshot().signIn.failure).toBe('not-configured');
|
|
2202
|
-
expect(controller.getSnapshot().signIn.attempt).not.toBe(firstFailure);
|
|
2203
|
-
});
|
|
2204
|
-
});
|
|
2205
|
-
|
|
2206
|
-
describe('AccountDialogController — "Try again" repeats the user\'s choice', () => {
|
|
2207
|
-
it('retries "Sign in with Oxy" after a failed Oxy attempt', async () => {
|
|
2208
|
-
const { controller, oxy } = makeHarness();
|
|
2209
|
-
oxy.startCommonsSignIn.mockRejectedValueOnce(new Error('boom'));
|
|
2210
|
-
|
|
2211
|
-
await controller.signInWithOxy();
|
|
2212
|
-
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
2213
|
-
|
|
2214
|
-
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
2215
|
-
await controller.retrySignIn();
|
|
2216
|
-
|
|
2217
|
-
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(2);
|
|
2218
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2219
|
-
controller.cancelSignIn();
|
|
2220
|
-
});
|
|
2221
|
-
|
|
2222
|
-
});
|
|
2223
|
-
|
|
2224
|
-
describe('AccountDialogController — device mutations report their own outcome', () => {
|
|
2225
|
-
function directoryClient() {
|
|
2226
|
-
const sc = new TestSessionClient(host().host);
|
|
2227
|
-
const controller = createAccountDialogController({
|
|
2228
|
-
oxyServices: makeOxy() as unknown as OxyServices,
|
|
2229
|
-
sessionClient: sc,
|
|
2230
|
-
clientId: 'oxy_dk_test',
|
|
2231
|
-
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
2232
|
-
});
|
|
2233
|
-
return { controller, sc };
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
it('reports a switch that happened as a switch, even when the re-read after it fails', async () => {
|
|
2237
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2238
|
-
const { controller, sc } = directoryClient();
|
|
2239
|
-
jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
|
|
2240
|
-
jest.spyOn(sc, 'refreshDirectory').mockRejectedValue(new Error('directory boom'));
|
|
2241
|
-
|
|
2242
|
-
// The subject DID change. Calling that a failed switch would invite the user
|
|
2243
|
-
// to repeat it — and skip the cache reset a switch requires.
|
|
2244
|
-
expect(await controller.activateContext('ctx-b')).toBe(true);
|
|
2245
|
-
// The re-read's failure is still reported, as the directory's.
|
|
2246
|
-
expect(controller.getSnapshot().error).toBe('directory boom');
|
|
2247
|
-
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
2248
|
-
warnSpy.mockRestore();
|
|
2249
|
-
});
|
|
2250
|
-
|
|
2251
|
-
it('never lets an older refresh failure overwrite a newer successful read', async () => {
|
|
2252
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
2253
|
-
const { controller, sc } = directoryClient();
|
|
2254
|
-
const older = deferred<void>();
|
|
2255
|
-
jest
|
|
2256
|
-
.spyOn(sc, 'refreshDirectory')
|
|
2257
|
-
.mockReturnValueOnce(older.promise)
|
|
2258
|
-
.mockResolvedValueOnce(undefined);
|
|
2259
|
-
|
|
2260
|
-
const first = controller.refresh();
|
|
2261
|
-
expect(await controller.refresh()).toBe(true);
|
|
2262
|
-
older.reject(new Error('stale boom'));
|
|
2263
|
-
expect(await first).toBe(false);
|
|
2264
|
-
|
|
2265
|
-
expect(controller.getSnapshot().error).toBeNull();
|
|
2266
|
-
expect(controller.getSnapshot().loading).toBe(false);
|
|
2267
|
-
warnSpy.mockRestore();
|
|
2268
|
-
});
|
|
2269
|
-
|
|
2270
|
-
it('refuses a removal while a switch is in flight, and a switch while a removal is', async () => {
|
|
2271
|
-
const { controller, sc } = directoryClient();
|
|
2272
|
-
const switching = deferred<void>();
|
|
2273
|
-
jest.spyOn(sc, 'activateContext').mockReturnValue(switching.promise);
|
|
2274
|
-
const removeContext = jest.spyOn(sc, 'signOutContext').mockResolvedValue(undefined);
|
|
2275
|
-
const removePrincipal = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
|
|
2276
|
-
jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
|
|
2277
|
-
|
|
2278
|
-
const activation = controller.activateContext('ctx-b');
|
|
2279
|
-
expect(controller.isDeviceMutationInFlight()).toBe(true);
|
|
2280
|
-
// Refused, not queued: nothing destructive may fire later, after the switch.
|
|
2281
|
-
expect(await controller.signOutContext('ctx-a')).toBe(false);
|
|
2282
|
-
expect(await controller.signOutPrincipal('p-a')).toBe(false);
|
|
2283
|
-
expect(removeContext).not.toHaveBeenCalled();
|
|
2284
|
-
expect(removePrincipal).not.toHaveBeenCalled();
|
|
2285
|
-
|
|
2286
|
-
switching.resolve();
|
|
2287
|
-
expect(await activation).toBe(true);
|
|
2288
|
-
expect(controller.isDeviceMutationInFlight()).toBe(false);
|
|
2289
|
-
|
|
2290
|
-
const removing = deferred<void>();
|
|
2291
|
-
removeContext.mockReturnValue(removing.promise);
|
|
2292
|
-
const removal = controller.signOutContext('ctx-a');
|
|
2293
|
-
expect(await controller.activateContext('ctx-c')).toBe(false);
|
|
2294
|
-
removing.resolve();
|
|
2295
|
-
expect(await removal).toBe(true);
|
|
2296
|
-
await flush();
|
|
2297
|
-
expect(removeContext).toHaveBeenCalledTimes(1);
|
|
2298
|
-
});
|
|
2299
|
-
|
|
2300
|
-
it('runs a host operation under the same gate, and releases it when the operation rejects', async () => {
|
|
2301
|
-
const { controller, sc } = directoryClient();
|
|
2302
|
-
const signingOut = deferred<string>();
|
|
2303
|
-
const activate = jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
|
|
2304
|
-
jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
|
|
2305
|
-
|
|
2306
|
-
const exclusive = controller.runDeviceMutation(() => signingOut.promise);
|
|
2307
|
-
expect(await controller.activateContext('ctx-b')).toBe(false);
|
|
2308
|
-
expect(await controller.runDeviceMutation(async () => 'second')).toEqual({ ran: false });
|
|
2309
|
-
expect(activate).not.toHaveBeenCalled();
|
|
2310
|
-
signingOut.resolve('done');
|
|
2311
|
-
expect(await exclusive).toEqual({ ran: true, value: 'done' });
|
|
2312
|
-
|
|
2313
|
-
await expect(
|
|
2314
|
-
controller.runDeviceMutation(async () => {
|
|
2315
|
-
throw new Error('boom');
|
|
2316
|
-
}),
|
|
2317
|
-
).rejects.toThrow('boom');
|
|
2318
|
-
expect(controller.isDeviceMutationInFlight()).toBe(false);
|
|
2319
|
-
});
|
|
2320
|
-
});
|
|
2321
|
-
|
|
2322
|
-
it('createAccountDialogController returns an AccountDialogController instance', () => {
|
|
2323
|
-
const { controller } = makeHarness();
|
|
2324
|
-
expect(controller).toBeInstanceOf(AccountDialogController);
|
|
2325
|
-
});
|
|
2326
|
-
|
|
2327
|
-
// Ensure the exported type surface is reachable at compile time for binders.
|
|
2328
|
-
const _typecheck: MinimalUserData | null = null;
|
|
2329
|
-
void _typecheck;
|
|
2330
|
-
|
|
2331
|
-
describe('AccountDialogController — choosing a device account row (OxyHQ/oxy#1375 items 20 and 21)', () => {
|
|
2332
|
-
/**
|
|
2333
|
-
* The device's directory as Commons leaves it: `qa` on the device and active,
|
|
2334
|
-
* plus (when `withOrg`) an organization `qa` also operates.
|
|
2335
|
-
*/
|
|
2336
|
-
const deviceDirectory = (activeContextId: string | null, withOrg = false) => ({
|
|
2337
|
-
deviceId: 'device-1',
|
|
2338
|
-
revision: 4,
|
|
2339
|
-
activeContextId,
|
|
2340
|
-
updatedAt: 1_720_000_000_000,
|
|
2341
|
-
principals: [
|
|
2342
|
-
{
|
|
2343
|
-
id: 'p-qa',
|
|
2344
|
-
userId: 'qa',
|
|
2345
|
-
authuser: 0,
|
|
2346
|
-
user: { id: 'qa', username: 'qatest0925' },
|
|
2347
|
-
contexts: [
|
|
2348
|
-
{
|
|
2349
|
-
id: 'ctx-qa',
|
|
2350
|
-
accountId: 'qa',
|
|
2351
|
-
kind: 'personal' as const,
|
|
2352
|
-
relationship: 'self' as const,
|
|
2353
|
-
account: { id: 'qa', username: 'qatest0925' },
|
|
2354
|
-
onDevice: true,
|
|
2355
|
-
available: true,
|
|
2356
|
-
active: activeContextId === 'ctx-qa',
|
|
2357
|
-
lastUsedAt: null,
|
|
2358
|
-
},
|
|
2359
|
-
...(withOrg
|
|
2360
|
-
? [
|
|
2361
|
-
{
|
|
2362
|
-
id: 'ctx-qa-org',
|
|
2363
|
-
accountId: 'org',
|
|
2364
|
-
kind: 'organization' as const,
|
|
2365
|
-
relationship: 'owner' as const,
|
|
2366
|
-
account: { id: 'org', username: 'oxy' },
|
|
2367
|
-
onDevice: true,
|
|
2368
|
-
available: true,
|
|
2369
|
-
active: activeContextId === 'ctx-qa-org',
|
|
2370
|
-
lastUsedAt: null,
|
|
2371
|
-
},
|
|
2372
|
-
]
|
|
2373
|
-
: []),
|
|
2374
|
-
],
|
|
2375
|
-
},
|
|
2376
|
-
],
|
|
2377
|
-
});
|
|
2378
|
-
|
|
2379
|
-
const sharedSession: SessionLoginResponse = {
|
|
2380
|
-
sessionId: 'sess-shared',
|
|
2381
|
-
deviceId: 'device-1',
|
|
2382
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
2383
|
-
accessToken: 'access-shared',
|
|
2384
|
-
user: { id: 'qa', username: 'qatest0925', name: { displayName: 'QA' } },
|
|
2385
|
-
};
|
|
2386
|
-
|
|
2387
|
-
/**
|
|
2388
|
-
* A device that still lists `qa` (Commons holds the shared identity) while
|
|
2389
|
-
* THIS app holds no bearer — Mention after "Sign out".
|
|
2390
|
-
*/
|
|
2391
|
-
async function signedOutDevice(options: { withOrg?: boolean } = {}) {
|
|
2392
|
-
const oxy = makeOxy();
|
|
2393
|
-
const urls: string[] = [];
|
|
2394
|
-
const sc = new TestSessionClient({
|
|
2395
|
-
makeRequest: jest.fn(async (_method: string, url: string) => {
|
|
2396
|
-
urls.push(url);
|
|
2397
|
-
if (url === '/session/device/directory') return deviceDirectory('ctx-qa', options.withOrg);
|
|
2398
|
-
if (url === '/session/device/activate') {
|
|
2399
|
-
return { directory: { ...deviceDirectory('ctx-qa-org', true), revision: 5 }, activeToken: null };
|
|
2400
|
-
}
|
|
2401
|
-
return undefined;
|
|
2402
|
-
}),
|
|
2403
|
-
getBaseURL: () => 'http://test.invalid',
|
|
2404
|
-
getAccessToken: () => oxy.getAccessToken(),
|
|
2405
|
-
getDeviceCredential: () => null,
|
|
2406
|
-
onTokensChanged: () => () => undefined,
|
|
2407
|
-
setTokens: jest.fn(),
|
|
2408
|
-
getCurrentAccountId: () => null,
|
|
2409
|
-
});
|
|
2410
|
-
// The directory this client read while it was still signed in.
|
|
2411
|
-
await sc.refreshDirectory();
|
|
2412
|
-
oxy.emitTokenChange(null);
|
|
2413
|
-
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
2414
|
-
const onSignedIn = jest.fn();
|
|
2415
|
-
const controller = createAccountDialogController({
|
|
2416
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
2417
|
-
sessionClient: sc,
|
|
2418
|
-
clientId: 'oxy_dk_test',
|
|
2419
|
-
commitSession,
|
|
2420
|
-
onSignedIn,
|
|
2421
|
-
});
|
|
2422
|
-
controller.start();
|
|
2423
|
-
await flush();
|
|
2424
|
-
urls.length = 0;
|
|
2425
|
-
return { controller, oxy, sc, urls, commitSession, onSignedIn };
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
it('publishes that this client is signed out while the device still lists an active account', async () => {
|
|
2429
|
-
const { controller } = await signedOutDevice();
|
|
2430
|
-
const snap = controller.getSnapshot();
|
|
2431
|
-
expect(snap.hasSession).toBe(false);
|
|
2432
|
-
// The precondition of the bug: the row the sheet renders reads as "active".
|
|
2433
|
-
expect(snap.activeContext?.contextId).toBe('ctx-qa');
|
|
2434
|
-
controller.destroy();
|
|
2435
|
-
});
|
|
2436
|
-
|
|
2437
|
-
it('signed out, choosing the listed account signs in through the shared identity, like "Continue with Oxy"', async () => {
|
|
2438
|
-
const { controller, oxy, urls, commitSession, onSignedIn } = await signedOutDevice();
|
|
2439
|
-
oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
|
|
2440
|
-
|
|
2441
|
-
// It used to short-circuit on "already the active row" and report success,
|
|
2442
|
-
// closing the sheet on an app that was still signed out.
|
|
2443
|
-
expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
|
|
2444
|
-
|
|
2445
|
-
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
2446
|
-
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
2447
|
-
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'qa' }));
|
|
2448
|
-
expect(controller.getSnapshot().hasSession).toBe(true);
|
|
2449
|
-
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
2450
|
-
// The silent mint already landed on the chosen pair: no switch on top.
|
|
2451
|
-
expect(urls).not.toContain('/session/device/activate');
|
|
2452
|
-
controller.destroy();
|
|
2453
|
-
});
|
|
2454
|
-
|
|
2455
|
-
it('signed out with no shared identity, choosing the account starts the same request "Continue with Oxy" does', async () => {
|
|
2456
|
-
const { controller, oxy, onSignedIn } = await signedOutDevice();
|
|
2457
|
-
oxy.signInWithSharedIdentity.mockResolvedValue(null);
|
|
2458
|
-
oxy.startCommonsSignIn.mockResolvedValue({
|
|
2459
|
-
sessionToken: 'secret-tok',
|
|
2460
|
-
authorizeCode: 'AUTH-CODE',
|
|
2461
|
-
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
2462
|
-
expiresAt: Date.now() + 300_000,
|
|
2463
|
-
status: 'pending',
|
|
2464
|
-
});
|
|
2465
|
-
|
|
2466
|
-
expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
|
|
2467
|
-
|
|
2468
|
-
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
2469
|
-
expect(controller.getSnapshot().view).toBe('qr');
|
|
2470
|
-
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
2471
|
-
expect(onSignedIn).not.toHaveBeenCalled();
|
|
2472
|
-
controller.destroy();
|
|
2473
|
-
});
|
|
2474
|
-
|
|
2475
|
-
it('signed out, choosing an organization row signs in and then activates that row', async () => {
|
|
2476
|
-
const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
|
|
2477
|
-
oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
|
|
2478
|
-
|
|
2479
|
-
expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
|
|
2480
|
-
|
|
2481
|
-
expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
2482
|
-
expect(urls).toContain('/session/device/activate');
|
|
2483
|
-
expect(controller.getSnapshot().activeContext?.contextId).toBe('ctx-qa-org');
|
|
2484
|
-
controller.destroy();
|
|
2485
|
-
});
|
|
2486
|
-
|
|
2487
|
-
it('signed in, the active row is "current" and any other row is a switch', async () => {
|
|
2488
|
-
const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
|
|
2489
|
-
oxy.emitTokenChange('access-token');
|
|
2490
|
-
await flush();
|
|
2491
|
-
urls.length = 0;
|
|
2492
|
-
expect(controller.getSnapshot().hasSession).toBe(true);
|
|
2493
|
-
|
|
2494
|
-
expect(await controller.chooseContext('ctx-qa')).toBe('current');
|
|
2495
|
-
expect(urls).not.toContain('/session/device/activate');
|
|
2496
|
-
|
|
2497
|
-
expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
|
|
2498
|
-
expect(urls).toContain('/session/device/activate');
|
|
2499
|
-
expect(oxy.signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
2500
|
-
controller.destroy();
|
|
2501
|
-
});
|
|
2502
|
-
});
|