@oxy.so/core 1.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +221 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/HttpService.js +1270 -0
- package/dist/cjs/OxyServices.base.js +390 -0
- package/dist/cjs/OxyServices.errors.js +95 -0
- package/dist/cjs/OxyServices.js +72 -0
- package/dist/cjs/boot/sessionColdBoot.js +395 -0
- package/dist/cjs/constants/version.js +16 -0
- package/dist/cjs/crypto/aead.js +79 -0
- package/dist/cjs/crypto/ecdh.js +35 -0
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/kdf.js +39 -0
- package/dist/cjs/crypto/keyManager.js +2234 -0
- package/dist/cjs/crypto/polyfill.js +131 -0
- package/dist/cjs/crypto/recoveryPhrase.js +242 -0
- package/dist/cjs/crypto/signatureService.js +273 -0
- package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
- package/dist/cjs/i18n/accountRoleLabels.js +27 -0
- package/dist/cjs/i18n/index.js +90 -0
- package/dist/cjs/i18n/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
- package/dist/cjs/i18n/trustTierLabels.js +19 -0
- package/dist/cjs/index.js +498 -0
- package/dist/cjs/inference/OxyInferenceClient.js +544 -0
- package/dist/cjs/logger/index.js +149 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +555 -0
- package/dist/cjs/mixins/OxyServices.analytics.js +49 -0
- package/dist/cjs/mixins/OxyServices.appData.js +108 -0
- package/dist/cjs/mixins/OxyServices.assets.js +764 -0
- package/dist/cjs/mixins/OxyServices.auth.js +1254 -0
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- package/dist/cjs/mixins/OxyServices.connectedApps.js +78 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +191 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +314 -0
- package/dist/cjs/mixins/OxyServices.devices.js +116 -0
- package/dist/cjs/mixins/OxyServices.features.js +335 -0
- package/dist/cjs/mixins/OxyServices.followGraph.js +221 -0
- package/dist/cjs/mixins/OxyServices.identity.js +461 -0
- package/dist/cjs/mixins/OxyServices.identityBackup.js +172 -0
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.language.js +125 -0
- package/dist/cjs/mixins/OxyServices.links.js +72 -0
- package/dist/cjs/mixins/OxyServices.location.js +43 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.payment.js +158 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +199 -0
- package/dist/cjs/mixins/OxyServices.reputation.js +289 -0
- package/dist/cjs/mixins/OxyServices.security.js +89 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.topics.js +137 -0
- package/dist/cjs/mixins/OxyServices.user.js +902 -0
- package/dist/cjs/mixins/OxyServices.utility.js +1098 -0
- package/dist/cjs/mixins/index.js +135 -0
- package/dist/cjs/mixins/mixinHelpers.js +53 -0
- package/dist/cjs/models/Topic.js +16 -0
- package/dist/cjs/models/interfaces.js +20 -0
- package/dist/cjs/models/session.js +2 -0
- package/dist/cjs/server/auth.js +167 -0
- package/dist/cjs/server/capabilityTicket.js +192 -0
- package/dist/cjs/server/cors.js +225 -0
- package/dist/cjs/server/index.js +97 -0
- package/dist/cjs/server/rateLimit.js +207 -0
- package/dist/cjs/server/safeFetch.js +458 -0
- package/dist/cjs/server/securityHeaders.js +377 -0
- package/dist/cjs/server/userInvalidation.js +156 -0
- package/dist/cjs/server/verifySecret.js +50 -0
- package/dist/cjs/session/SessionClient.js +865 -0
- package/dist/cjs/session/accountDelegationAccess.js +48 -0
- package/dist/cjs/session/accountDialogController.js +1131 -0
- package/dist/cjs/session/accountSwitchTargets.js +81 -0
- package/dist/cjs/session/authStateStore.js +404 -0
- package/dist/cjs/session/createSessionClient.js +32 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +121 -0
- package/dist/cjs/session/refresh.js +398 -0
- package/dist/cjs/session/sessionClientHost.js +34 -0
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/shared/utils/colorUtils.js +153 -0
- package/dist/cjs/shared/utils/errorUtils.js +183 -0
- package/dist/cjs/shared/utils/index.js +49 -0
- package/dist/cjs/shared/utils/networkUtils.js +183 -0
- package/dist/cjs/shared/utils/themeUtils.js +106 -0
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/cjs/utils/accountUtils.js +140 -0
- package/dist/cjs/utils/activityId.js +6 -0
- package/dist/cjs/utils/apiUtils.js +91 -0
- package/dist/cjs/utils/asyncUtils.js +222 -0
- package/dist/cjs/utils/authHelpers.js +120 -0
- package/dist/cjs/utils/authWebUrl.js +19 -0
- package/dist/cjs/utils/avatarUtils.js +29 -0
- package/dist/cjs/utils/cache.js +235 -0
- package/dist/cjs/utils/cacheKey.js +85 -0
- package/dist/cjs/utils/coldBoot.js +122 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/deviceManager.js +174 -0
- package/dist/cjs/utils/displayNamePolicyRanges.generated.js +60 -0
- package/dist/cjs/utils/edgeRegion.js +6 -0
- package/dist/cjs/utils/errorUtils.js +243 -0
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/cjs/utils/languageUtils.js +224 -0
- package/dist/cjs/utils/oauthPkce.js +311 -0
- package/dist/cjs/utils/officialOrigins.js +64 -0
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/cjs/utils/platform.js +119 -0
- package/dist/cjs/utils/profileLinks.js +63 -0
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/cjs/utils/registrableApex.js +45 -0
- package/dist/cjs/utils/requestUtils.js +209 -0
- package/dist/cjs/utils/sessionUtils.js +181 -0
- package/dist/cjs/utils/textNormalization.js +158 -0
- package/dist/cjs/utils/userHandle.js +37 -0
- package/dist/cjs/utils/userIdentity.js +50 -0
- package/dist/cjs/utils/validationUtils.js +370 -0
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/HttpService.js +1266 -0
- package/dist/esm/OxyServices.base.js +386 -0
- package/dist/esm/OxyServices.errors.js +88 -0
- package/dist/esm/OxyServices.js +66 -0
- package/dist/esm/boot/sessionColdBoot.js +392 -0
- package/dist/esm/constants/version.js +13 -0
- package/dist/esm/crypto/aead.js +74 -0
- package/dist/esm/crypto/ecdh.js +32 -0
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/kdf.js +36 -0
- package/dist/esm/crypto/keyManager.js +2227 -0
- package/dist/esm/crypto/polyfill.js +129 -0
- package/dist/esm/crypto/recoveryPhrase.js +238 -0
- package/dist/esm/crypto/signatureService.js +269 -0
- package/dist/esm/i18n/accountCategoryLabels.js +37 -0
- package/dist/esm/i18n/accountRoleLabels.js +20 -0
- package/dist/esm/i18n/index.js +83 -0
- package/dist/esm/i18n/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
- package/dist/esm/i18n/trustTierLabels.js +12 -0
- package/dist/esm/index.js +254 -0
- package/dist/esm/inference/OxyInferenceClient.js +538 -0
- package/dist/esm/logger/index.js +140 -0
- package/dist/esm/mixins/OxyServices.accounts.js +543 -0
- package/dist/esm/mixins/OxyServices.analytics.js +46 -0
- package/dist/esm/mixins/OxyServices.appData.js +103 -0
- package/dist/esm/mixins/OxyServices.assets.js +761 -0
- package/dist/esm/mixins/OxyServices.auth.js +1247 -0
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- package/dist/esm/mixins/OxyServices.connectedApps.js +75 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +186 -0
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +311 -0
- package/dist/esm/mixins/OxyServices.devices.js +113 -0
- package/dist/esm/mixins/OxyServices.features.js +332 -0
- package/dist/esm/mixins/OxyServices.followGraph.js +218 -0
- package/dist/esm/mixins/OxyServices.identity.js +457 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +169 -0
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.language.js +122 -0
- package/dist/esm/mixins/OxyServices.links.js +69 -0
- package/dist/esm/mixins/OxyServices.location.js +40 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.payment.js +155 -0
- package/dist/esm/mixins/OxyServices.privacy.js +196 -0
- package/dist/esm/mixins/OxyServices.reputation.js +286 -0
- package/dist/esm/mixins/OxyServices.security.js +86 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.topics.js +134 -0
- package/dist/esm/mixins/OxyServices.user.js +899 -0
- package/dist/esm/mixins/OxyServices.utility.js +1095 -0
- package/dist/esm/mixins/index.js +132 -0
- package/dist/esm/mixins/mixinHelpers.js +48 -0
- package/dist/esm/models/Topic.js +13 -0
- package/dist/esm/models/interfaces.js +17 -0
- package/dist/esm/models/session.js +1 -0
- package/dist/esm/server/auth.js +154 -0
- package/dist/esm/server/capabilityTicket.js +183 -0
- package/dist/esm/server/cors.js +220 -0
- package/dist/esm/server/index.js +47 -0
- package/dist/esm/server/rateLimit.js +201 -0
- package/dist/esm/server/safeFetch.js +447 -0
- package/dist/esm/server/securityHeaders.js +364 -0
- package/dist/esm/server/userInvalidation.js +152 -0
- package/dist/esm/server/verifySecret.js +47 -0
- package/dist/esm/session/SessionClient.js +861 -0
- package/dist/esm/session/accountDelegationAccess.js +45 -0
- package/dist/esm/session/accountDialogController.js +1126 -0
- package/dist/esm/session/accountSwitchTargets.js +77 -0
- package/dist/esm/session/authStateStore.js +398 -0
- package/dist/esm/session/createSessionClient.js +29 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +114 -0
- package/dist/esm/session/refresh.js +390 -0
- package/dist/esm/session/sessionClientHost.js +31 -0
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/shared/utils/colorUtils.js +143 -0
- package/dist/esm/shared/utils/errorUtils.js +170 -0
- package/dist/esm/shared/utils/index.js +15 -0
- package/dist/esm/shared/utils/networkUtils.js +173 -0
- package/dist/esm/shared/utils/themeUtils.js +98 -0
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/esm/utils/accountUtils.js +131 -0
- package/dist/esm/utils/activityId.js +2 -0
- package/dist/esm/utils/apiUtils.js +84 -0
- package/dist/esm/utils/asyncUtils.js +207 -0
- package/dist/esm/utils/authHelpers.js +111 -0
- package/dist/esm/utils/authWebUrl.js +16 -0
- package/dist/esm/utils/avatarUtils.js +26 -0
- package/dist/esm/utils/cache.js +227 -0
- package/dist/esm/utils/cacheKey.js +80 -0
- package/dist/esm/utils/coldBoot.js +119 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/deviceManager.js +170 -0
- package/dist/esm/utils/displayNamePolicyRanges.generated.js +57 -0
- package/dist/esm/utils/edgeRegion.js +2 -0
- package/dist/esm/utils/errorUtils.js +232 -0
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/esm/utils/languageUtils.js +212 -0
- package/dist/esm/utils/oauthPkce.js +297 -0
- package/dist/esm/utils/officialOrigins.js +59 -0
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/esm/utils/platform.js +110 -0
- package/dist/esm/utils/profileLinks.js +60 -0
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/esm/utils/registrableApex.js +42 -0
- package/dist/esm/utils/requestUtils.js +203 -0
- package/dist/esm/utils/sessionUtils.js +171 -0
- package/dist/esm/utils/textNormalization.js +154 -0
- package/dist/esm/utils/userHandle.js +33 -0
- package/dist/esm/utils/userIdentity.js +45 -0
- package/dist/esm/utils/validationUtils.js +350 -0
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/HttpService.d.ts +413 -0
- package/dist/types/OxyServices.base.d.ts +187 -0
- package/dist/types/OxyServices.d.ts +139 -0
- package/dist/types/OxyServices.errors.d.ts +84 -0
- package/dist/types/boot/sessionColdBoot.d.ts +140 -0
- package/dist/types/constants/version.d.ts +13 -0
- package/dist/types/crypto/aead.d.ts +56 -0
- package/dist/types/crypto/ecdh.d.ts +29 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/kdf.d.ts +25 -0
- package/dist/types/crypto/keyManager.d.ts +582 -0
- package/dist/types/crypto/polyfill.d.ts +25 -0
- package/dist/types/crypto/recoveryPhrase.d.ts +173 -0
- package/dist/types/crypto/signatureService.d.ts +136 -0
- package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
- package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
- package/dist/types/i18n/index.d.ts +3 -0
- package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
- package/dist/types/i18n/trustTierLabels.d.ts +9 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/inference/OxyInferenceClient.d.ts +342 -0
- package/dist/types/logger/index.d.ts +104 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +868 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +72 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +108 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +329 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +908 -0
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +510 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +169 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +100 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +185 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +103 -0
- package/dist/types/mixins/OxyServices.features.d.ts +250 -0
- package/dist/types/mixins/OxyServices.followGraph.d.ts +223 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +351 -0
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.language.d.ts +87 -0
- package/dist/types/mixins/OxyServices.links.d.ts +104 -0
- package/dist/types/mixins/OxyServices.location.d.ts +70 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +198 -0
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +117 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +146 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +202 -0
- package/dist/types/mixins/OxyServices.security.d.ts +84 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +110 -0
- package/dist/types/mixins/OxyServices.user.d.ts +558 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +393 -0
- package/dist/types/mixins/index.d.ts +87 -0
- package/dist/types/mixins/mixinHelpers.d.ts +31 -0
- package/dist/types/models/Topic.d.ts +42 -0
- package/dist/types/models/interfaces.d.ts +675 -0
- package/dist/types/models/session.d.ts +51 -0
- package/dist/types/server/auth.d.ts +130 -0
- package/dist/types/server/capabilityTicket.d.ts +41 -0
- package/dist/types/server/cors.d.ts +98 -0
- package/dist/types/server/index.d.ts +35 -0
- package/dist/types/server/rateLimit.d.ts +40 -0
- package/dist/types/server/safeFetch.d.ts +137 -0
- package/dist/types/server/securityHeaders.d.ts +221 -0
- package/dist/types/server/userInvalidation.d.ts +114 -0
- package/dist/types/server/verifySecret.d.ts +29 -0
- package/dist/types/session/SessionClient.d.ts +418 -0
- package/dist/types/session/accountDelegationAccess.d.ts +40 -0
- package/dist/types/session/accountDialogController.d.ts +648 -0
- package/dist/types/session/accountSwitchTargets.d.ts +70 -0
- package/dist/types/session/authStateStore.d.ts +153 -0
- package/dist/types/session/createSessionClient.d.ts +30 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +74 -0
- package/dist/types/session/refresh.d.ts +179 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/dist/types/session/socketLoader.d.ts +11 -0
- package/dist/types/shared/utils/colorUtils.d.ts +104 -0
- package/dist/types/shared/utils/errorUtils.d.ts +97 -0
- package/dist/types/shared/utils/index.d.ts +13 -0
- package/dist/types/shared/utils/networkUtils.d.ts +139 -0
- package/dist/types/shared/utils/themeUtils.d.ts +90 -0
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/dist/types/utils/accountUtils.d.ts +100 -0
- package/dist/types/utils/activityId.d.ts +2 -0
- package/dist/types/utils/apiUtils.d.ts +95 -0
- package/dist/types/utils/asyncUtils.d.ts +62 -0
- package/dist/types/utils/authHelpers.d.ts +58 -0
- package/dist/types/utils/authWebUrl.d.ts +16 -0
- package/dist/types/utils/avatarUtils.d.ts +16 -0
- package/dist/types/utils/cache.d.ts +130 -0
- package/dist/types/utils/cacheKey.d.ts +66 -0
- package/dist/types/utils/coldBoot.d.ts +131 -0
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/deviceManager.d.ts +65 -0
- package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +57 -0
- package/dist/types/utils/edgeRegion.d.ts +2 -0
- package/dist/types/utils/errorUtils.d.ts +125 -0
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/dist/types/utils/languageUtils.d.ts +100 -0
- package/dist/types/utils/oauthPkce.d.ts +157 -0
- package/dist/types/utils/officialOrigins.d.ts +13 -0
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/dist/types/utils/platform.d.ts +49 -0
- package/dist/types/utils/profileLinks.d.ts +44 -0
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/dist/types/utils/registrableApex.d.ts +27 -0
- package/dist/types/utils/requestUtils.d.ts +123 -0
- package/dist/types/utils/sessionUtils.d.ts +54 -0
- package/dist/types/utils/textNormalization.d.ts +103 -0
- package/dist/types/utils/userHandle.d.ts +26 -0
- package/dist/types/utils/userIdentity.d.ts +21 -0
- package/dist/types/utils/validationUtils.d.ts +268 -0
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +168 -0
- package/src/HttpService.ts +1553 -0
- package/src/OxyServices.base.ts +478 -0
- package/src/OxyServices.errors.ts +122 -0
- package/src/OxyServices.ts +172 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceActivityId.test.ts +49 -0
- package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
- package/src/__tests__/httpServiceCache.test.ts +318 -0
- package/src/__tests__/httpServiceCsrf.test.ts +235 -0
- package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
- package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/__tests__/inSessionRefresh.test.ts +260 -0
- package/src/__tests__/linkedClient.test.ts +370 -0
- package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
- package/src/__tests__/serverValueImportsDeclared.test.ts +120 -0
- package/src/__tests__/userIdentity.test.ts +143 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +570 -0
- package/src/boot/sessionColdBoot.ts +529 -0
- package/src/constants/version.ts +15 -0
- package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +226 -0
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +272 -0
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +329 -0
- package/src/crypto/__tests__/polyfill.test.ts +109 -0
- package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/__tests__/signChallengeShared.test.ts +63 -0
- package/src/crypto/__tests__/signedRecord.test.ts +230 -0
- package/src/crypto/aead.ts +97 -0
- package/src/crypto/ecdh.ts +37 -0
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/kdf.ts +43 -0
- package/src/crypto/keyManager.ts +2607 -0
- package/src/crypto/polyfill.ts +143 -0
- package/src/crypto/recoveryPhrase.ts +337 -0
- package/src/crypto/signatureService.ts +350 -0
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
- package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
- package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
- package/src/i18n/accountCategoryLabels.ts +44 -0
- package/src/i18n/accountRoleLabels.ts +26 -0
- package/src/i18n/index.ts +88 -0
- package/src/i18n/locales/ar-SA.json +282 -0
- package/src/i18n/locales/ca-ES.json +282 -0
- package/src/i18n/locales/de-DE.json +282 -0
- package/src/i18n/locales/en-US.json +2161 -0
- package/src/i18n/locales/es-ES.json +2161 -0
- package/src/i18n/locales/fr-FR.json +282 -0
- package/src/i18n/locales/it-IT.json +282 -0
- package/src/i18n/locales/ja-JP.json +282 -0
- package/src/i18n/locales/ko-KR.json +282 -0
- package/src/i18n/locales/pt-PT.json +282 -0
- package/src/i18n/locales/zh-CN.json +282 -0
- package/src/i18n/reputationCategoryLabels.ts +20 -0
- package/src/i18n/trustTierLabels.ts +18 -0
- package/src/index.ts +881 -0
- package/src/inference/OxyInferenceClient.ts +888 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +834 -0
- package/src/logger/__tests__/logger.test.ts +207 -0
- package/src/logger/index.ts +217 -0
- package/src/mixins/OxyServices.accounts.ts +1330 -0
- package/src/mixins/OxyServices.analytics.ts +53 -0
- package/src/mixins/OxyServices.appData.ts +158 -0
- package/src/mixins/OxyServices.assets.ts +861 -0
- package/src/mixins/OxyServices.auth.ts +1858 -0
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.civic.ts +945 -0
- package/src/mixins/OxyServices.connectedApps.ts +174 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.deviceBoot.ts +210 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +393 -0
- package/src/mixins/OxyServices.devices.ts +120 -0
- package/src/mixins/OxyServices.features.ts +454 -0
- package/src/mixins/OxyServices.followGraph.ts +293 -0
- package/src/mixins/OxyServices.identity.ts +705 -0
- package/src/mixins/OxyServices.identityBackup.ts +250 -0
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.language.ts +144 -0
- package/src/mixins/OxyServices.links.ts +107 -0
- package/src/mixins/OxyServices.location.ts +46 -0
- package/src/mixins/OxyServices.nodes.ts +337 -0
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.payment.ts +163 -0
- package/src/mixins/OxyServices.privacy.ts +219 -0
- package/src/mixins/OxyServices.reputation.ts +436 -0
- package/src/mixins/OxyServices.security.ts +110 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.topics.ts +169 -0
- package/src/mixins/OxyServices.user.ts +1137 -0
- package/src/mixins/OxyServices.utility.ts +1370 -0
- package/src/mixins/__tests__/OxyServices.civic.test.ts +1095 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +124 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +271 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +376 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +276 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +192 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/accounts.test.ts +727 -0
- package/src/mixins/__tests__/appData.test.ts +203 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +1144 -0
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +173 -0
- package/src/mixins/__tests__/followGraph.test.ts +147 -0
- package/src/mixins/__tests__/followGraphPagination.test.ts +271 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +346 -0
- package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/mixins/__tests__/identityBackup.test.ts +315 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/onTokensChanged.test.ts +129 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +284 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +155 -0
- package/src/mixins/__tests__/reputation.test.ts +522 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +832 -0
- package/src/mixins/__tests__/serviceTokenJwks.test.ts +208 -0
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/topics.test.ts +156 -0
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +129 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +130 -0
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +205 -0
- package/src/mixins/index.ts +201 -0
- package/src/mixins/mixinHelpers.ts +69 -0
- package/src/models/Topic.ts +46 -0
- package/src/models/interfaces.ts +786 -0
- package/src/models/session.ts +55 -0
- package/src/server/__tests__/auth.test.ts +78 -0
- package/src/server/__tests__/capabilityTicket.test.ts +111 -0
- package/src/server/__tests__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/cors.test.ts +158 -0
- package/src/server/__tests__/rateLimit.test.ts +257 -0
- package/src/server/__tests__/safeFetch.test.ts +232 -0
- package/src/server/__tests__/securityHeaders.test.ts +348 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/__tests__/userInvalidation.test.ts +203 -0
- package/src/server/__tests__/verifySecret.test.ts +40 -0
- package/src/server/auth.ts +271 -0
- package/src/server/capabilityTicket.ts +258 -0
- package/src/server/cors.ts +277 -0
- package/src/server/index.ts +136 -0
- package/src/server/rateLimit.ts +313 -0
- package/src/server/safeFetch.ts +586 -0
- package/src/server/securityHeaders.ts +467 -0
- package/src/server/userInvalidation.ts +199 -0
- package/src/server/verifySecret.ts +52 -0
- package/src/session/SessionClient.ts +1001 -0
- package/src/session/__tests__/SessionClient.additive.test.ts +153 -0
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +114 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +91 -0
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +119 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +72 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +202 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +81 -0
- package/src/session/__tests__/SessionClient.state.test.ts +78 -0
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/session/__tests__/accountDelegationAccess.test.ts +104 -0
- package/src/session/__tests__/accountDialogController.test.ts +1969 -0
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
- package/src/session/__tests__/accountSwitchTargets.test.ts +137 -0
- package/src/session/__tests__/authStateStore.test.ts +451 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +105 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +307 -0
- package/src/session/__tests__/refresh.test.ts +443 -0
- package/src/session/__tests__/sessionIntegration.test.ts +209 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDelegationAccess.ts +72 -0
- package/src/session/accountDialogController.ts +1494 -0
- package/src/session/accountSwitchTargets.ts +93 -0
- package/src/session/authStateStore.ts +502 -0
- package/src/session/createSessionClient.ts +39 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +137 -0
- package/src/session/refresh.ts +517 -0
- package/src/session/sessionClientHost.ts +39 -0
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/src/session/socketLoader.ts +30 -0
- package/src/shared/utils/colorUtils.ts +155 -0
- package/src/shared/utils/errorUtils.ts +181 -0
- package/src/shared/utils/index.ts +59 -0
- package/src/shared/utils/networkUtils.ts +248 -0
- package/src/shared/utils/themeUtils.ts +115 -0
- package/src/types/buffer.d.ts +97 -0
- package/src/types/expo-crypto.d.ts +16 -0
- package/src/types/expo-secure-store.d.ts +17 -0
- package/src/utils/__tests__/accountUtils.test.ts +151 -0
- package/src/utils/__tests__/activityId.test.ts +45 -0
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/__tests__/authWebUrl.test.ts +15 -0
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +496 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
- package/src/utils/__tests__/languageUtils.test.ts +219 -0
- package/src/utils/__tests__/oauthPkce.test.ts +272 -0
- package/src/utils/__tests__/officialOrigins.test.ts +31 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/__tests__/profileLinks.test.ts +180 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/textNormalization.test.ts +205 -0
- package/src/utils/__tests__/validationUtils.test.ts +703 -0
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/accountCacheSweep.ts +93 -0
- package/src/utils/accountUtils.ts +194 -0
- package/src/utils/activityId.ts +2 -0
- package/src/utils/apiUtils.ts +137 -0
- package/src/utils/asyncUtils.ts +280 -0
- package/src/utils/authHelpers.ts +148 -0
- package/src/utils/authWebUrl.ts +17 -0
- package/src/utils/avatarUtils.ts +39 -0
- package/src/utils/cache.ts +273 -0
- package/src/utils/cacheKey.ts +93 -0
- package/src/utils/coldBoot.ts +218 -0
- package/src/utils/commonsApproval.ts +39 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/deviceManager.ts +203 -0
- package/src/utils/displayNamePolicyRanges.generated.ts +69 -0
- package/src/utils/edgeRegion.ts +2 -0
- package/src/utils/errorUtils.ts +352 -0
- package/src/utils/identityCacheSweep.ts +104 -0
- package/src/utils/languageUtils.ts +256 -0
- package/src/utils/oauthPkce.ts +392 -0
- package/src/utils/officialOrigins.ts +62 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
- package/src/utils/platform.ts +139 -0
- package/src/utils/profileLinks.ts +91 -0
- package/src/utils/redactUrl.ts +28 -0
- package/src/utils/registrableApex.ts +41 -0
- package/src/utils/requestUtils.ts +237 -0
- package/src/utils/sessionUtils.ts +206 -0
- package/src/utils/textNormalization.ts +162 -0
- package/src/utils/userHandle.ts +49 -0
- package/src/utils/userIdentity.ts +60 -0
- package/src/utils/validationUtils.ts +390 -0
- package/src/utils/webauthnOrigin.ts +52 -0
|
@@ -0,0 +1,1969 @@
|
|
|
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
|
+
/**
|
|
141
|
+
* Test helper: set the current access token and fire every registered
|
|
142
|
+
* `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
|
|
143
|
+
* With no listener yet registered (before `start()`), it just sets the token.
|
|
144
|
+
*/
|
|
145
|
+
emitTokenChange: (token: string | null) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeOxy(): OxyMock {
|
|
149
|
+
const tokenListeners = new Set<(token: string | null) => void>();
|
|
150
|
+
// Authenticated by default (mirrors a warm start with a planted bearer).
|
|
151
|
+
let currentToken: string | null = 'access-token';
|
|
152
|
+
return {
|
|
153
|
+
getAccessToken: jest.fn(() => currentToken),
|
|
154
|
+
getBaseURL: jest.fn(() => 'http://test.invalid'),
|
|
155
|
+
onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
|
|
156
|
+
tokenListeners.add(listener);
|
|
157
|
+
return () => tokenListeners.delete(listener);
|
|
158
|
+
}),
|
|
159
|
+
listAccounts: jest.fn().mockResolvedValue([]),
|
|
160
|
+
getUsersByIds: jest.fn().mockResolvedValue([]),
|
|
161
|
+
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
162
|
+
switchToAccount: jest.fn(),
|
|
163
|
+
startCommonsSignIn: jest.fn(),
|
|
164
|
+
pollCommonsSignIn: jest.fn(),
|
|
165
|
+
// Default: no capable Commons installation is registered. A NORMAL outcome
|
|
166
|
+
// that resolves the primary route to QR — never an error.
|
|
167
|
+
deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
|
|
168
|
+
denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
|
|
169
|
+
claimSessionByToken: jest.fn(),
|
|
170
|
+
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
171
|
+
emitTokenChange: (token: string | null) => {
|
|
172
|
+
currentToken = token;
|
|
173
|
+
for (const listener of tokenListeners) {
|
|
174
|
+
listener(token);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Flush pending microtasks (a `start()`-triggered `refresh()` cannot be awaited directly). */
|
|
181
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
182
|
+
|
|
183
|
+
interface Harness {
|
|
184
|
+
controller: AccountDialogController;
|
|
185
|
+
oxy: OxyMock;
|
|
186
|
+
sc: TestSessionClient;
|
|
187
|
+
commitSession: jest.Mock;
|
|
188
|
+
onSignedIn: jest.Mock;
|
|
189
|
+
/** Every path the session client has requested, in order. */
|
|
190
|
+
urls: string[];
|
|
191
|
+
/** Make every directory read reject with `error`. */
|
|
192
|
+
failDirectory: (error: unknown) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function makeHarness(
|
|
196
|
+
over: Partial<{
|
|
197
|
+
clientId: string | null;
|
|
198
|
+
openPopup: () => import('../accountDialogController').PopupWindowHandle | null;
|
|
199
|
+
hubBaseUrl: string;
|
|
200
|
+
platform: import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
201
|
+
openUrl: (url: string) => void;
|
|
202
|
+
canOpenApp: (url: string) => Promise<boolean>;
|
|
203
|
+
}> = {},
|
|
204
|
+
): Harness {
|
|
205
|
+
const oxy = makeOxy();
|
|
206
|
+
const recording = host();
|
|
207
|
+
const sc = new TestSessionClient(recording.host);
|
|
208
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
209
|
+
const onSignedIn = jest.fn();
|
|
210
|
+
const controller = createAccountDialogController({
|
|
211
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
212
|
+
sessionClient: sc,
|
|
213
|
+
clientId: 'clientId' in over ? over.clientId : 'oxy_dk_test',
|
|
214
|
+
commitSession,
|
|
215
|
+
onSignedIn,
|
|
216
|
+
pollIntervalMs: 1000,
|
|
217
|
+
openPopup: over.openPopup,
|
|
218
|
+
hubBaseUrl: over.hubBaseUrl,
|
|
219
|
+
platform: over.platform,
|
|
220
|
+
openUrl: over.openUrl,
|
|
221
|
+
canOpenApp: over.canOpenApp,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
controller,
|
|
225
|
+
oxy,
|
|
226
|
+
sc,
|
|
227
|
+
commitSession,
|
|
228
|
+
onSignedIn,
|
|
229
|
+
urls: recording.urls,
|
|
230
|
+
failDirectory: recording.failDirectory,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** How many times the device directory has been read. */
|
|
235
|
+
const directoryReads = (urls: string[]): number =>
|
|
236
|
+
urls.filter((url) => url === '/session/device/directory').length;
|
|
237
|
+
|
|
238
|
+
describe('AccountDialogController — initial + views', () => {
|
|
239
|
+
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
240
|
+
const { controller } = makeHarness();
|
|
241
|
+
const snap = controller.getSnapshot();
|
|
242
|
+
expect(snap.view).toBe('accounts');
|
|
243
|
+
expect(snap.directory).toBeNull();
|
|
244
|
+
expect(snap.activeContext).toBeNull();
|
|
245
|
+
expect(snap.loading).toBe(false);
|
|
246
|
+
expect(snap.activatingContextId).toBeNull();
|
|
247
|
+
expect(snap.removingContextId).toBeNull();
|
|
248
|
+
expect(snap.removingPrincipalId).toBeNull();
|
|
249
|
+
expect(snap.signIn.phase).toBe('idle');
|
|
250
|
+
expect(snap.signIn.route).toBeNull();
|
|
251
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
252
|
+
expect(snap.signIn.pushSentAt).toBeNull();
|
|
253
|
+
expect(snap.signIn.openedAt).toBeNull();
|
|
254
|
+
expect(snap.signIn.progress).toBe('idle');
|
|
255
|
+
expect(snap.commonsAvailability).toBe('unknown');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('setView / add move between views and notify subscribers', () => {
|
|
259
|
+
const { controller } = makeHarness();
|
|
260
|
+
const seen: string[] = [];
|
|
261
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
262
|
+
|
|
263
|
+
controller.add();
|
|
264
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
265
|
+
controller.setView('signin');
|
|
266
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
267
|
+
controller.setView('accounts');
|
|
268
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
269
|
+
|
|
270
|
+
expect(seen).toEqual(['add', 'signin', 'accounts']);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('startSignup moves to the signup view and notifies subscribers', () => {
|
|
274
|
+
const { controller } = makeHarness();
|
|
275
|
+
const seen: string[] = [];
|
|
276
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
277
|
+
|
|
278
|
+
controller.startSignup();
|
|
279
|
+
expect(controller.getSnapshot().view).toBe('signup');
|
|
280
|
+
controller.setView('accounts');
|
|
281
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
282
|
+
|
|
283
|
+
expect(seen).toEqual(['signup', 'accounts']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('getSnapshot returns a stable reference until a change occurs', () => {
|
|
287
|
+
const { controller } = makeHarness();
|
|
288
|
+
const a = controller.getSnapshot();
|
|
289
|
+
expect(controller.getSnapshot()).toBe(a);
|
|
290
|
+
controller.setView('add');
|
|
291
|
+
expect(controller.getSnapshot()).not.toBe(a);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('AccountDialogController — auth-gated directory read', () => {
|
|
296
|
+
it('start() while signed out makes no directory call and does not error', async () => {
|
|
297
|
+
const { controller, oxy, sc, urls } = makeHarness();
|
|
298
|
+
// Cold boot: no bearer planted yet (no listeners registered pre-start).
|
|
299
|
+
oxy.emitTokenChange(null);
|
|
300
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
301
|
+
|
|
302
|
+
controller.start();
|
|
303
|
+
await flush();
|
|
304
|
+
|
|
305
|
+
// The directory read is a PRIVATE call. Issuing it before the cold-boot
|
|
306
|
+
// restore plants the bearer 401s, which clears the token and signs the user
|
|
307
|
+
// out — the failure this gate exists for.
|
|
308
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
309
|
+
const snap = controller.getSnapshot();
|
|
310
|
+
expect(snap.error).toBeNull();
|
|
311
|
+
expect(snap.loading).toBe(false);
|
|
312
|
+
controller.destroy();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('start() while authenticated reads the directory exactly once', async () => {
|
|
316
|
+
const { controller, sc, urls } = makeHarness();
|
|
317
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
318
|
+
|
|
319
|
+
controller.start();
|
|
320
|
+
await flush();
|
|
321
|
+
|
|
322
|
+
expect(directoryReads(urls)).toBe(1);
|
|
323
|
+
controller.destroy();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('reads the directory once when the bearer is planted after a signed-out start', async () => {
|
|
327
|
+
const { controller, oxy, urls } = makeHarness();
|
|
328
|
+
oxy.emitTokenChange(null);
|
|
329
|
+
controller.start();
|
|
330
|
+
await flush();
|
|
331
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
332
|
+
|
|
333
|
+
// Cold-boot restore plants the token → onTokensChanged → one read.
|
|
334
|
+
oxy.emitTokenChange('access-token');
|
|
335
|
+
await flush();
|
|
336
|
+
expect(directoryReads(urls)).toBe(1);
|
|
337
|
+
controller.destroy();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('never reconstructs the account graph — no listAccounts, no getUsersByIds', async () => {
|
|
341
|
+
const { controller, oxy, sc } = makeHarness();
|
|
342
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
343
|
+
|
|
344
|
+
controller.start();
|
|
345
|
+
await flush();
|
|
346
|
+
// A device change used to refetch profiles for any newly-seen account id.
|
|
347
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a2', 2));
|
|
348
|
+
await flush();
|
|
349
|
+
|
|
350
|
+
// ADR 0002's whole point: the client holds ONE caller's account graph and
|
|
351
|
+
// cannot enumerate another principal's, so a switcher assembled from it is
|
|
352
|
+
// one person's answer presented as the device's. The directory carries the
|
|
353
|
+
// rows AND the display metadata, so neither private call is made at all.
|
|
354
|
+
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
355
|
+
expect(oxy.getUsersByIds).not.toHaveBeenCalled();
|
|
356
|
+
controller.destroy();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('does not loop when the directory read rejects — one call per refresh, no re-trigger on device changes', async () => {
|
|
360
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
361
|
+
const { controller, sc, urls, failDirectory } = makeHarness();
|
|
362
|
+
failDirectory(new Error('directory boom'));
|
|
363
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
364
|
+
|
|
365
|
+
controller.start();
|
|
366
|
+
await flush();
|
|
367
|
+
expect(directoryReads(urls)).toBe(1);
|
|
368
|
+
expect(controller.getSnapshot().error).toBe('directory boom');
|
|
369
|
+
|
|
370
|
+
// A subsequent device push must not re-trigger it (the auth edge did not
|
|
371
|
+
// move → `reconcileAuth` is a no-op → no storm).
|
|
372
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
|
|
373
|
+
await flush();
|
|
374
|
+
expect(directoryReads(urls)).toBe(1);
|
|
375
|
+
controller.destroy();
|
|
376
|
+
warnSpy.mockRestore();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('treats a 401 from the directory read as the signed-out edge — debug, no surfaced error, no warn', async () => {
|
|
380
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
381
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
382
|
+
const { controller, failDirectory } = makeHarness();
|
|
383
|
+
failDirectory(
|
|
384
|
+
Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
await controller.refresh();
|
|
388
|
+
|
|
389
|
+
// A signed-out device is a normal state, not a warning.
|
|
390
|
+
expect(controller.getSnapshot().error).toBeNull();
|
|
391
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
392
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
393
|
+
'[AccountDialogController] directory unauthorized (signed out)',
|
|
394
|
+
{ component: 'AccountDialogController' },
|
|
395
|
+
expect.objectContaining({ status: 401 }),
|
|
396
|
+
);
|
|
397
|
+
warnSpy.mockRestore();
|
|
398
|
+
debugSpy.mockRestore();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('AccountDialogController — sign in with Oxy', () => {
|
|
403
|
+
it('completes silently when a shared identity mints a session', async () => {
|
|
404
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
405
|
+
const session: SessionLoginResponse = {
|
|
406
|
+
sessionId: 'sess-shared',
|
|
407
|
+
deviceId: 'device-1',
|
|
408
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
409
|
+
accessToken: 'access-shared',
|
|
410
|
+
user: { id: 'a1', username: 'user_a1', name: { displayName: 'User a1' } },
|
|
411
|
+
};
|
|
412
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(session);
|
|
413
|
+
|
|
414
|
+
await controller.signInWithOxy();
|
|
415
|
+
|
|
416
|
+
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
417
|
+
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
418
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
419
|
+
// Terminal SUCCESS — the surface gets one frame to show "Identity confirmed".
|
|
420
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
421
|
+
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
422
|
+
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
423
|
+
|
|
424
|
+
// …and a new intention clears it, so the next entry never opens on the
|
|
425
|
+
// previous sign-in's terminal state.
|
|
426
|
+
controller.add();
|
|
427
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
428
|
+
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('falls through to the QR handoff when no shared identity is present', async () => {
|
|
432
|
+
const { controller, oxy } = makeHarness();
|
|
433
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(null);
|
|
434
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
435
|
+
sessionToken: 'secret-tok',
|
|
436
|
+
authorizeCode: 'AUTH-CODE',
|
|
437
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
438
|
+
expiresAt: Date.now() + 300_000,
|
|
439
|
+
status: 'pending',
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await controller.signInWithOxy();
|
|
443
|
+
|
|
444
|
+
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
445
|
+
const snap = controller.getSnapshot();
|
|
446
|
+
expect(snap.view).toBe('qr');
|
|
447
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
448
|
+
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
449
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
450
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
451
|
+
expect(snap.signIn.route).toBe('qr');
|
|
452
|
+
controller.cancelSignIn();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('selects await-push when delivery succeeds for a signed-in user', async () => {
|
|
456
|
+
const { controller, oxy } = makeHarness();
|
|
457
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
458
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
459
|
+
sessionToken: 'secret-tok',
|
|
460
|
+
authorizeCode: 'AUTH-CODE',
|
|
461
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
462
|
+
expiresAt: Date.now() + 300_000,
|
|
463
|
+
status: 'pending',
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
await controller.showQr();
|
|
467
|
+
|
|
468
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
469
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
470
|
+
controller.cancelSignIn();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('skips deliver when no bearer is planted', async () => {
|
|
474
|
+
const { controller, oxy } = makeHarness();
|
|
475
|
+
oxy.emitTokenChange(null);
|
|
476
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
477
|
+
sessionToken: 'secret-tok',
|
|
478
|
+
authorizeCode: 'AUTH-CODE',
|
|
479
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
480
|
+
expiresAt: Date.now() + 300_000,
|
|
481
|
+
status: 'pending',
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await controller.showQr();
|
|
485
|
+
|
|
486
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
487
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
488
|
+
controller.cancelSignIn();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('errors when showQr is called without a clientId', async () => {
|
|
492
|
+
const { controller } = makeHarness({ clientId: null });
|
|
493
|
+
await controller.showQr();
|
|
494
|
+
const snap = controller.getSnapshot();
|
|
495
|
+
expect(snap.signIn.phase).toBe('error');
|
|
496
|
+
expect(snap.signIn.error).toMatch(/clientId/);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('polls, claims, and commits when the QR flow is authorized', async () => {
|
|
500
|
+
jest.useFakeTimers();
|
|
501
|
+
try {
|
|
502
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
503
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
504
|
+
sessionToken: 'secret-tok',
|
|
505
|
+
authorizeCode: 'AUTH-CODE',
|
|
506
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
507
|
+
expiresAt: Date.now() + 600_000,
|
|
508
|
+
status: 'pending',
|
|
509
|
+
});
|
|
510
|
+
oxy.pollCommonsSignIn
|
|
511
|
+
.mockResolvedValueOnce({ authorized: false, status: 'pending' })
|
|
512
|
+
.mockResolvedValueOnce({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
513
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
514
|
+
accessToken: 'access-1',
|
|
515
|
+
sessionId: 'sess-1',
|
|
516
|
+
deviceId: 'device-1',
|
|
517
|
+
deviceSecret: 'claimed-secret',
|
|
518
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
519
|
+
user: user('a1'),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await controller.showQr();
|
|
523
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
524
|
+
|
|
525
|
+
await jest.advanceTimersByTimeAsync(1000); // first poll → pending
|
|
526
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
527
|
+
|
|
528
|
+
await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
|
|
529
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
530
|
+
expect(commitSession).toHaveBeenCalledWith(
|
|
531
|
+
expect.objectContaining({
|
|
532
|
+
sessionId: 'sess-1',
|
|
533
|
+
accessToken: 'access-1',
|
|
534
|
+
deviceSecret: 'claimed-secret',
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
538
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
539
|
+
} finally {
|
|
540
|
+
jest.useRealTimers();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('surfaces a denied QR authorization as an error and stops polling', async () => {
|
|
545
|
+
jest.useFakeTimers();
|
|
546
|
+
try {
|
|
547
|
+
const { controller, oxy } = makeHarness();
|
|
548
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
549
|
+
sessionToken: 'secret-tok',
|
|
550
|
+
authorizeCode: 'AUTH-CODE',
|
|
551
|
+
qrPayload: 'oxycommons://approve',
|
|
552
|
+
expiresAt: Date.now() + 600_000,
|
|
553
|
+
status: 'pending',
|
|
554
|
+
});
|
|
555
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
556
|
+
|
|
557
|
+
await controller.showQr();
|
|
558
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
559
|
+
|
|
560
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
561
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
|
|
562
|
+
|
|
563
|
+
// No further polls after the terminal error.
|
|
564
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
565
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
566
|
+
} finally {
|
|
567
|
+
jest.useRealTimers();
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('cancelSignIn stops the poll and resets to idle', async () => {
|
|
572
|
+
jest.useFakeTimers();
|
|
573
|
+
try {
|
|
574
|
+
const { controller, oxy } = makeHarness();
|
|
575
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
576
|
+
sessionToken: 'secret-tok',
|
|
577
|
+
authorizeCode: 'AUTH-CODE',
|
|
578
|
+
qrPayload: 'oxycommons://approve',
|
|
579
|
+
expiresAt: Date.now() + 600_000,
|
|
580
|
+
status: 'pending',
|
|
581
|
+
});
|
|
582
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
583
|
+
|
|
584
|
+
await controller.showQr();
|
|
585
|
+
controller.cancelSignIn();
|
|
586
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
587
|
+
expect(controller.getSnapshot().signIn.progress).toBe('idle');
|
|
588
|
+
// Cancellation converges server-side too: the abandoned request is
|
|
589
|
+
// withdrawn so a stale QR can never be approved later.
|
|
590
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
591
|
+
|
|
592
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
593
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
594
|
+
} finally {
|
|
595
|
+
jest.useRealTimers();
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
/** A controllable fake `PopupWindowHandle` for `startPasskeyHubSignIn` tests. */
|
|
601
|
+
function fakePopup(): import('../accountDialogController').PopupWindowHandle & { setClosed: () => void } {
|
|
602
|
+
let closed = false;
|
|
603
|
+
return {
|
|
604
|
+
get closed() {
|
|
605
|
+
return closed;
|
|
606
|
+
},
|
|
607
|
+
close: jest.fn(() => {
|
|
608
|
+
closed = true;
|
|
609
|
+
}),
|
|
610
|
+
location: { href: '' },
|
|
611
|
+
setClosed: () => {
|
|
612
|
+
closed = true;
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popup)', () => {
|
|
618
|
+
it('opens the popup synchronously, then navigates it to the hub URL with the authorizeCode once the session exists', async () => {
|
|
619
|
+
const popup = fakePopup();
|
|
620
|
+
const openPopup = jest.fn(() => popup);
|
|
621
|
+
const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://auth.oxy.so' });
|
|
622
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
623
|
+
sessionToken: 'secret-tok',
|
|
624
|
+
authorizeCode: 'AUTH-CODE',
|
|
625
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
626
|
+
expiresAt: Date.now() + 300_000,
|
|
627
|
+
status: 'pending',
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await controller.startPasskeyHubSignIn();
|
|
631
|
+
|
|
632
|
+
expect(openPopup).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect(popup.location.href).toBe('https://auth.oxy.so/hub-passkey?code=AUTH-CODE');
|
|
634
|
+
// Same underlying device-flow session showQr would create — the QR view
|
|
635
|
+
// still renders as a fallback/alternative alongside the popup.
|
|
636
|
+
const snap = controller.getSnapshot();
|
|
637
|
+
expect(snap.view).toBe('qr');
|
|
638
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
639
|
+
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
640
|
+
controller.cancelSignIn();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('falls back to the plain QR flow (no navigation) when the popup is blocked', async () => {
|
|
644
|
+
const openPopup = jest.fn(() => null);
|
|
645
|
+
const { controller, oxy } = makeHarness({ openPopup });
|
|
646
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
647
|
+
sessionToken: 'secret-tok',
|
|
648
|
+
authorizeCode: 'AUTH-CODE',
|
|
649
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
650
|
+
expiresAt: Date.now() + 300_000,
|
|
651
|
+
status: 'pending',
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
await controller.startPasskeyHubSignIn();
|
|
655
|
+
|
|
656
|
+
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
657
|
+
const snap = controller.getSnapshot();
|
|
658
|
+
expect(snap.view).toBe('qr');
|
|
659
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
660
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
661
|
+
controller.cancelSignIn();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('closes the popup without navigating it when device-flow session creation fails', async () => {
|
|
665
|
+
const popup = fakePopup();
|
|
666
|
+
const openPopup = jest.fn(() => popup);
|
|
667
|
+
const { controller, oxy } = makeHarness({ openPopup });
|
|
668
|
+
oxy.startCommonsSignIn.mockRejectedValue(new Error('network down'));
|
|
669
|
+
|
|
670
|
+
await controller.startPasskeyHubSignIn();
|
|
671
|
+
|
|
672
|
+
expect(popup.close).toHaveBeenCalledTimes(1);
|
|
673
|
+
expect(popup.location.href).toBe('');
|
|
674
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('surfaces "cancelled" and closes the popup watcher when the user closes the popup before authorizing', async () => {
|
|
678
|
+
jest.useFakeTimers();
|
|
679
|
+
try {
|
|
680
|
+
const popup = fakePopup();
|
|
681
|
+
const openPopup = jest.fn(() => popup);
|
|
682
|
+
const { controller, oxy } = makeHarness({ openPopup });
|
|
683
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
684
|
+
sessionToken: 'secret-tok',
|
|
685
|
+
authorizeCode: 'AUTH-CODE',
|
|
686
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
687
|
+
expiresAt: Date.now() + 300_000,
|
|
688
|
+
status: 'pending',
|
|
689
|
+
});
|
|
690
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
691
|
+
|
|
692
|
+
await controller.startPasskeyHubSignIn();
|
|
693
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
694
|
+
|
|
695
|
+
popup.setClosed();
|
|
696
|
+
await jest.advanceTimersByTimeAsync(1000); // the 1s popup-close watchdog tick
|
|
697
|
+
|
|
698
|
+
const snap = controller.getSnapshot();
|
|
699
|
+
expect(snap.signIn.phase).toBe('error');
|
|
700
|
+
expect(snap.signIn.error).toMatch(/cancelled/i);
|
|
701
|
+
} finally {
|
|
702
|
+
jest.useRealTimers();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('closes the popup again (never navigates it) when clientId is missing', async () => {
|
|
707
|
+
const popup = fakePopup();
|
|
708
|
+
const openPopup = jest.fn(() => popup);
|
|
709
|
+
const { controller, oxy } = makeHarness({ clientId: null, openPopup });
|
|
710
|
+
|
|
711
|
+
await controller.startPasskeyHubSignIn();
|
|
712
|
+
|
|
713
|
+
// openPopup is invoked unconditionally (before the clientId check, since it
|
|
714
|
+
// must run synchronously) — but the popup must be closed again rather than
|
|
715
|
+
// navigated, since the flow can't proceed without a clientId.
|
|
716
|
+
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
717
|
+
expect(popup.close).toHaveBeenCalledTimes(1);
|
|
718
|
+
expect(popup.location.href).toBe('');
|
|
719
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
720
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/clientId/);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
describe('AccountDialogController — Commons availability (canOpenApp)', () => {
|
|
725
|
+
const START_HANDLE = {
|
|
726
|
+
sessionToken: 'secret-tok',
|
|
727
|
+
authorizeCode: 'AUTH-CODE',
|
|
728
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
729
|
+
expiresAt: Date.now() + 600_000,
|
|
730
|
+
status: 'pending' as const,
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
function makeController(opts: {
|
|
734
|
+
openUrl?: jest.Mock;
|
|
735
|
+
canOpenApp?: jest.Mock;
|
|
736
|
+
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
737
|
+
const oxy = makeOxy();
|
|
738
|
+
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
739
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
740
|
+
const controller = new AccountDialogController({
|
|
741
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
742
|
+
sessionClient: new TestSessionClient(host().host),
|
|
743
|
+
clientId: 'oxy_dk_test',
|
|
744
|
+
pollIntervalMs: 1000,
|
|
745
|
+
openUrl: opts.openUrl,
|
|
746
|
+
canOpenApp: opts.canOpenApp,
|
|
747
|
+
// The deep-link route is mobile-only (`selectCommonsDelivery`), and a
|
|
748
|
+
// `canOpenApp` probe is only ever injected by a native (mobile) host.
|
|
749
|
+
platform: 'mobile',
|
|
750
|
+
});
|
|
751
|
+
return { controller, oxy };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
|
|
755
|
+
setPlatformOS('ios');
|
|
756
|
+
const openUrl = jest.fn();
|
|
757
|
+
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
758
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
759
|
+
|
|
760
|
+
await controller.showQr();
|
|
761
|
+
await flush(); // let the (non-awaited) canOpenApp probe resolve
|
|
762
|
+
|
|
763
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
764
|
+
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
765
|
+
// The QR + polling remain the fallback path — the flow is still waiting.
|
|
766
|
+
const snap = controller.getSnapshot();
|
|
767
|
+
expect(snap.view).toBe('qr');
|
|
768
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
769
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
770
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
771
|
+
expect(snap.commonsAvailability).toBe('available');
|
|
772
|
+
controller.cancelSignIn();
|
|
773
|
+
setPlatformOS('web');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
|
|
777
|
+
const openUrl = jest.fn();
|
|
778
|
+
const canOpenApp = jest.fn().mockResolvedValue(false);
|
|
779
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
780
|
+
|
|
781
|
+
await controller.showQr();
|
|
782
|
+
await flush();
|
|
783
|
+
|
|
784
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
785
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
786
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
787
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
788
|
+
controller.cancelSignIn();
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('never probes or opens when canOpenApp is absent (web — unchanged behavior)', async () => {
|
|
792
|
+
const openUrl = jest.fn();
|
|
793
|
+
const { controller } = makeController({ openUrl });
|
|
794
|
+
|
|
795
|
+
await controller.showQr();
|
|
796
|
+
await flush();
|
|
797
|
+
|
|
798
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
799
|
+
expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
800
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
|
|
801
|
+
controller.cancelSignIn();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
|
|
805
|
+
const openUrl = jest.fn();
|
|
806
|
+
const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
|
|
807
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
808
|
+
|
|
809
|
+
await controller.showQr();
|
|
810
|
+
await flush();
|
|
811
|
+
|
|
812
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
813
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
814
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
815
|
+
controller.cancelSignIn();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
|
|
819
|
+
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
820
|
+
const { controller } = makeController({ canOpenApp });
|
|
821
|
+
|
|
822
|
+
controller.start();
|
|
823
|
+
await flush();
|
|
824
|
+
|
|
825
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
826
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
827
|
+
controller.destroy();
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
|
|
832
|
+
type Handler = (...args: unknown[]) => void;
|
|
833
|
+
class FakeAuthSocket implements MinimalSocket {
|
|
834
|
+
connected = false;
|
|
835
|
+
disconnected = false;
|
|
836
|
+
handlers = new Map<string, Handler[]>();
|
|
837
|
+
emitted: Array<{ event: string; args: unknown[] }> = [];
|
|
838
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
839
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
840
|
+
emit(event: string, ...args: unknown[]) { this.emitted.push({ event, args }); }
|
|
841
|
+
connect() { this.connected = true; }
|
|
842
|
+
disconnect() { this.connected = false; this.disconnected = true; }
|
|
843
|
+
/** Simulate a server→client push on this socket. */
|
|
844
|
+
server(event: string, payload?: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const START_HANDLE = {
|
|
848
|
+
sessionToken: 'secret-tok',
|
|
849
|
+
authorizeCode: 'AUTH-CODE',
|
|
850
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
851
|
+
expiresAt: Date.now() + 600_000,
|
|
852
|
+
status: 'pending' as const,
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
function makeSocketHarness(): { controller: AccountDialogController; oxy: OxyMock; created: () => FakeAuthSocket | null; factory: jest.Mock; commitSession: jest.Mock } {
|
|
856
|
+
const oxy = makeOxy();
|
|
857
|
+
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
858
|
+
let socket: FakeAuthSocket | null = null;
|
|
859
|
+
const factory = jest.fn((_uri: string, _opts?: Record<string, unknown>): MinimalSocket => {
|
|
860
|
+
socket = new FakeAuthSocket();
|
|
861
|
+
socket.connected = true; // real io autoConnect resolves before we inspect
|
|
862
|
+
return socket;
|
|
863
|
+
});
|
|
864
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
865
|
+
const controller = new AccountDialogController({
|
|
866
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
867
|
+
sessionClient: new TestSessionClient(host().host),
|
|
868
|
+
clientId: 'oxy_dk_test',
|
|
869
|
+
commitSession,
|
|
870
|
+
socketFactory: factory as unknown as SocketIOFactory,
|
|
871
|
+
});
|
|
872
|
+
return { controller, oxy, created: () => socket, factory, commitSession };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
it('connects to /auth-session, joins the flow room, and wakes the claim on auth_update — no timer advance', async () => {
|
|
876
|
+
const { controller, oxy, created, factory, commitSession } = makeSocketHarness();
|
|
877
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
878
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
879
|
+
accessToken: 'access-1', sessionId: 'sess-1', deviceId: 'device-1', expiresAt: '2030-01-01T00:00:00Z', user: user('a1'),
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
await controller.showQr();
|
|
883
|
+
|
|
884
|
+
expect(factory).toHaveBeenCalledWith('http://test.invalid/auth-session', expect.any(Object));
|
|
885
|
+
const sock = created();
|
|
886
|
+
if (!sock) throw new Error('socket not created');
|
|
887
|
+
expect(sock.emitted).toContainEqual({ event: 'join', args: ['secret-tok'] });
|
|
888
|
+
|
|
889
|
+
// The server pushes auth_update → immediate status check + claim, without any poll timer firing.
|
|
890
|
+
sock.server('auth_update', { status: 'authorized', sessionId: 'sess-1' });
|
|
891
|
+
await flush();
|
|
892
|
+
|
|
893
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
|
|
894
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
895
|
+
expect(commitSession).toHaveBeenCalled();
|
|
896
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
897
|
+
expect(sock.disconnected).toBe(true); // torn down on completion
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('re-joins the room on reconnect (connect event) and tears the socket down on cancelSignIn', async () => {
|
|
901
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
902
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
903
|
+
|
|
904
|
+
await controller.showQr();
|
|
905
|
+
const sock = created();
|
|
906
|
+
if (!sock) throw new Error('socket not created');
|
|
907
|
+
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(1);
|
|
908
|
+
|
|
909
|
+
// A reconnect fires `connect` again → the join is re-issued so it survives drops.
|
|
910
|
+
sock.server('connect');
|
|
911
|
+
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(2);
|
|
912
|
+
|
|
913
|
+
controller.cancelSignIn();
|
|
914
|
+
expect(sock.disconnected).toBe(true);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('a stale auth_update after the flow was cancelled does not re-poll', async () => {
|
|
918
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
919
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
920
|
+
|
|
921
|
+
await controller.showQr();
|
|
922
|
+
const sock = created();
|
|
923
|
+
if (!sock) throw new Error('socket not created');
|
|
924
|
+
oxy.pollCommonsSignIn.mockClear();
|
|
925
|
+
|
|
926
|
+
controller.cancelSignIn();
|
|
927
|
+
// Even if a late auth_update slips through on the (now-detached) socket, the
|
|
928
|
+
// superseded-token guard drops it.
|
|
929
|
+
sock.server('auth_update', { status: 'authorized' });
|
|
930
|
+
await flush();
|
|
931
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('stops polling when an OAuth-bound session is authorized without a sessionId', async () => {
|
|
935
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
936
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
937
|
+
authorized: true,
|
|
938
|
+
purpose: 'oauth_authorization',
|
|
939
|
+
status: 'authorized',
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
await controller.showQr();
|
|
943
|
+
const sock = created();
|
|
944
|
+
if (!sock) throw new Error('socket not created');
|
|
945
|
+
sock.server('auth_update', { status: 'authorized' });
|
|
946
|
+
await flush();
|
|
947
|
+
|
|
948
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
949
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
950
|
+
expect(controller.getSnapshot().signIn.error).toContain('OAuth sign-in');
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// ===========================================================================
|
|
955
|
+
// Issue #691, Phase 5 — automatic delivery selection + honest progress
|
|
956
|
+
// ===========================================================================
|
|
957
|
+
|
|
958
|
+
type DeliveryPlatform = import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
|
|
959
|
+
|
|
960
|
+
const DELIVERY_HANDLE = {
|
|
961
|
+
sessionToken: 'secret-tok',
|
|
962
|
+
authorizeCode: 'AUTH-CODE',
|
|
963
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
964
|
+
status: 'pending' as const,
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
function makeDeliveryHarness(opts: {
|
|
968
|
+
platform?: DeliveryPlatform;
|
|
969
|
+
canOpenApp?: jest.Mock;
|
|
970
|
+
openUrl?: jest.Mock;
|
|
971
|
+
/** Default `true` — a planted bearer, the only state that may push. */
|
|
972
|
+
authenticated?: boolean;
|
|
973
|
+
pollIntervalMs?: number;
|
|
974
|
+
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
975
|
+
const oxy = makeOxy();
|
|
976
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
977
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
978
|
+
authorized: false,
|
|
979
|
+
status: 'pending',
|
|
980
|
+
pushSentAt: null,
|
|
981
|
+
openedAt: null,
|
|
982
|
+
});
|
|
983
|
+
if (opts.authenticated === false) oxy.emitTokenChange(null);
|
|
984
|
+
const controller = new AccountDialogController({
|
|
985
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
986
|
+
sessionClient: new TestSessionClient(host().host),
|
|
987
|
+
clientId: 'oxy_dk_test',
|
|
988
|
+
// Every real consumer wires the provider's commit funnel; without it the
|
|
989
|
+
// controller falls back to `SessionClient.registerAndActivate`, which opens
|
|
990
|
+
// a BroadcastChannel this harness has no reason to exercise.
|
|
991
|
+
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
992
|
+
pollIntervalMs: opts.pollIntervalMs ?? 1000,
|
|
993
|
+
platform: opts.platform,
|
|
994
|
+
canOpenApp: opts.canOpenApp,
|
|
995
|
+
openUrl: opts.openUrl,
|
|
996
|
+
});
|
|
997
|
+
return { controller, oxy };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
describe('AccountDialogController — automatic delivery selection (#691 phase 5)', () => {
|
|
1001
|
+
it('chooses open-commons on mobile with a verified Commons link, and never pushes as well', async () => {
|
|
1002
|
+
const openUrl = jest.fn();
|
|
1003
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1004
|
+
platform: 'mobile',
|
|
1005
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1006
|
+
openUrl,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
await controller.showQr();
|
|
1010
|
+
await flush();
|
|
1011
|
+
|
|
1012
|
+
const snap = controller.getSnapshot();
|
|
1013
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
1014
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1015
|
+
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1016
|
+
// The identity is reachable on THIS device — pushing would light up a second
|
|
1017
|
+
// surface for a request the user is about to confirm here.
|
|
1018
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1019
|
+
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1020
|
+
controller.cancelSignIn();
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it('chooses await-push when the server delivered to at least one known Commons installation', async () => {
|
|
1024
|
+
const openUrl = jest.fn();
|
|
1025
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', openUrl });
|
|
1026
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 2 });
|
|
1027
|
+
|
|
1028
|
+
await controller.showQr();
|
|
1029
|
+
await flush();
|
|
1030
|
+
|
|
1031
|
+
const snap = controller.getSnapshot();
|
|
1032
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1033
|
+
expect(snap.signIn.route).toBe('await-push');
|
|
1034
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1035
|
+
// "Check Commons on your phone" — the server CONFIRMED the dispatch.
|
|
1036
|
+
expect(snap.signIn.progress).toBe('delivered-to-commons');
|
|
1037
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1038
|
+
controller.cancelSignIn();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('falls through to QR when the delivery reached zero installations — no error surfaced', async () => {
|
|
1042
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1043
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1044
|
+
|
|
1045
|
+
await controller.showQr();
|
|
1046
|
+
await flush();
|
|
1047
|
+
|
|
1048
|
+
const snap = controller.getSnapshot();
|
|
1049
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1050
|
+
expect(snap.signIn.routeFailed).toBe(false);
|
|
1051
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1052
|
+
expect(snap.signIn.error).toBeNull();
|
|
1053
|
+
expect(snap.error).toBeNull();
|
|
1054
|
+
expect(snap.signIn.progress).toBe('awaiting-approval');
|
|
1055
|
+
controller.cancelSignIn();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it('falls through to QR when the delivery call FAILS — silently, as a normal outcome', async () => {
|
|
1059
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1060
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1061
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1062
|
+
oxy.deliverCommonsSignIn.mockRejectedValue(new Error('delivery boom'));
|
|
1063
|
+
|
|
1064
|
+
await controller.showQr();
|
|
1065
|
+
await flush();
|
|
1066
|
+
|
|
1067
|
+
const snap = controller.getSnapshot();
|
|
1068
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1069
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1070
|
+
expect(snap.signIn.error).toBeNull();
|
|
1071
|
+
expect(snap.error).toBeNull();
|
|
1072
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
1073
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1074
|
+
'[AccountDialogController] Commons delivery unavailable (QR route)',
|
|
1075
|
+
{ component: 'AccountDialogController' },
|
|
1076
|
+
expect.any(Error),
|
|
1077
|
+
);
|
|
1078
|
+
controller.cancelSignIn();
|
|
1079
|
+
debugSpy.mockRestore();
|
|
1080
|
+
warnSpy.mockRestore();
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('treats a delivered:false response with a non-zero target count as zero targets (fail-safe to QR)', async () => {
|
|
1084
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1085
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 3 });
|
|
1086
|
+
|
|
1087
|
+
await controller.showQr();
|
|
1088
|
+
await flush();
|
|
1089
|
+
|
|
1090
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1091
|
+
controller.cancelSignIn();
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('NEVER attempts delivery without a bearer — the unauthenticated surface cannot ring a phone', async () => {
|
|
1095
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', authenticated: false });
|
|
1096
|
+
|
|
1097
|
+
await controller.showQr();
|
|
1098
|
+
await flush();
|
|
1099
|
+
|
|
1100
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1101
|
+
const snap = controller.getSnapshot();
|
|
1102
|
+
expect(snap.signIn.route).toBe('qr');
|
|
1103
|
+
expect(snap.signIn.error).toBeNull();
|
|
1104
|
+
controller.cancelSignIn();
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('pushes on mobile when no verified Commons link is available on this device', async () => {
|
|
1108
|
+
const openUrl = jest.fn();
|
|
1109
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1110
|
+
platform: 'mobile',
|
|
1111
|
+
canOpenApp: jest.fn().mockResolvedValue(false),
|
|
1112
|
+
openUrl,
|
|
1113
|
+
});
|
|
1114
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1115
|
+
|
|
1116
|
+
await controller.showQr();
|
|
1117
|
+
await flush();
|
|
1118
|
+
|
|
1119
|
+
expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1120
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1121
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1122
|
+
controller.cancelSignIn();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('never deep-links from an unclassified surface, even with Commons installed', async () => {
|
|
1126
|
+
const openUrl = jest.fn();
|
|
1127
|
+
const { controller } = makeDeliveryHarness({
|
|
1128
|
+
// No `platform` supplied → 'unknown': the controller says so rather than
|
|
1129
|
+
// guessing, and an unresolved custom-scheme navigation is a dead end.
|
|
1130
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1131
|
+
openUrl,
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
await controller.showQr();
|
|
1135
|
+
await flush();
|
|
1136
|
+
|
|
1137
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1138
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1139
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
1140
|
+
controller.cancelSignIn();
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('marks the primary route FAILED (never auto-cascades) when the Commons link cannot be opened', async () => {
|
|
1144
|
+
// Route chosen: open-commons. No URL opener was injected, so the one action
|
|
1145
|
+
// the route implies cannot happen — the UI needs that fact to reveal
|
|
1146
|
+
// "Having trouble?"; the controller must not silently switch to another route.
|
|
1147
|
+
const { controller } = makeDeliveryHarness({
|
|
1148
|
+
platform: 'mobile',
|
|
1149
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
await controller.showQr();
|
|
1153
|
+
await flush();
|
|
1154
|
+
|
|
1155
|
+
const snap = controller.getSnapshot();
|
|
1156
|
+
expect(snap.signIn.route).toBe('open-commons');
|
|
1157
|
+
expect(snap.signIn.routeFailed).toBe(true);
|
|
1158
|
+
// Still a live request — the QR beneath it stays valid.
|
|
1159
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
1160
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
1161
|
+
controller.cancelSignIn();
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it('marks the primary route FAILED when opening the Commons link throws', async () => {
|
|
1165
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1166
|
+
const openUrl = jest.fn(() => {
|
|
1167
|
+
throw new Error('no handler for scheme');
|
|
1168
|
+
});
|
|
1169
|
+
const { controller } = makeDeliveryHarness({
|
|
1170
|
+
platform: 'mobile',
|
|
1171
|
+
canOpenApp: jest.fn().mockResolvedValue(true),
|
|
1172
|
+
openUrl,
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
await controller.showQr();
|
|
1176
|
+
await flush();
|
|
1177
|
+
|
|
1178
|
+
expect(controller.getSnapshot().signIn.route).toBe('open-commons');
|
|
1179
|
+
expect(controller.getSnapshot().signIn.routeFailed).toBe(true);
|
|
1180
|
+
expect(controller.getSnapshot().signIn.error).toBeNull();
|
|
1181
|
+
controller.cancelSignIn();
|
|
1182
|
+
debugSpy.mockRestore();
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
it('reports "preparing" until a route is actually resolved — it is never guessed', async () => {
|
|
1186
|
+
let releaseDelivery: () => void = () => undefined;
|
|
1187
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1188
|
+
oxy.deliverCommonsSignIn.mockImplementation(
|
|
1189
|
+
() =>
|
|
1190
|
+
new Promise((resolve) => {
|
|
1191
|
+
releaseDelivery = () => resolve({ delivered: true, targets: 1 });
|
|
1192
|
+
}),
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
await controller.showQr();
|
|
1196
|
+
// The request exists and the QR is renderable, but no route has been chosen.
|
|
1197
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1198
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1199
|
+
expect(controller.getSnapshot().signIn.progress).toBe('preparing');
|
|
1200
|
+
|
|
1201
|
+
releaseDelivery();
|
|
1202
|
+
await flush();
|
|
1203
|
+
expect(controller.getSnapshot().signIn.route).toBe('await-push');
|
|
1204
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1205
|
+
controller.cancelSignIn();
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('abandons route resolution when the flow was cancelled while it was in flight', async () => {
|
|
1209
|
+
const openUrl = jest.fn();
|
|
1210
|
+
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1211
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1212
|
+
platform: 'mobile',
|
|
1213
|
+
canOpenApp: jest.fn(
|
|
1214
|
+
() =>
|
|
1215
|
+
new Promise<boolean>((resolve) => {
|
|
1216
|
+
releaseProbe = resolve;
|
|
1217
|
+
}),
|
|
1218
|
+
),
|
|
1219
|
+
openUrl,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
await controller.showQr();
|
|
1223
|
+
controller.cancelSignIn();
|
|
1224
|
+
releaseProbe(true);
|
|
1225
|
+
await flush();
|
|
1226
|
+
|
|
1227
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1228
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1229
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it('abandons route resolution when the request was approved while it was in flight', async () => {
|
|
1233
|
+
jest.useFakeTimers();
|
|
1234
|
+
try {
|
|
1235
|
+
const openUrl = jest.fn();
|
|
1236
|
+
let releaseProbe: (installed: boolean) => void = () => undefined;
|
|
1237
|
+
const { controller, oxy } = makeDeliveryHarness({
|
|
1238
|
+
platform: 'mobile',
|
|
1239
|
+
canOpenApp: jest.fn(
|
|
1240
|
+
() =>
|
|
1241
|
+
new Promise<boolean>((resolve) => {
|
|
1242
|
+
releaseProbe = resolve;
|
|
1243
|
+
}),
|
|
1244
|
+
),
|
|
1245
|
+
openUrl,
|
|
1246
|
+
});
|
|
1247
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
1248
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
1249
|
+
accessToken: 'access-1',
|
|
1250
|
+
sessionId: 'sess-1',
|
|
1251
|
+
deviceId: 'device-1',
|
|
1252
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
1253
|
+
user: user('a1'),
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
await controller.showQr();
|
|
1257
|
+
await jest.advanceTimersByTimeAsync(1000); // approval lands first
|
|
1258
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1259
|
+
|
|
1260
|
+
releaseProbe(true);
|
|
1261
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1262
|
+
|
|
1263
|
+
// Opening Commons after the session is already signed in would be a
|
|
1264
|
+
// pointless app switch — the resolution must abandon itself.
|
|
1265
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
1266
|
+
expect(controller.getSnapshot().signIn.route).toBeNull();
|
|
1267
|
+
} finally {
|
|
1268
|
+
jest.useRealTimers();
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it('does not run automatic delivery for the passkey hub flow (the popup is the primary surface)', async () => {
|
|
1273
|
+
const popup = fakePopup();
|
|
1274
|
+
const oxy = makeOxy();
|
|
1275
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1276
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1277
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1278
|
+
const controller = new AccountDialogController({
|
|
1279
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1280
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1281
|
+
clientId: 'oxy_dk_test',
|
|
1282
|
+
pollIntervalMs: 1000,
|
|
1283
|
+
platform: 'desktop',
|
|
1284
|
+
openPopup: () => popup,
|
|
1285
|
+
hubBaseUrl: 'https://auth.oxy.so',
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
await controller.startPasskeyHubSignIn();
|
|
1289
|
+
await flush();
|
|
1290
|
+
|
|
1291
|
+
expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
|
|
1292
|
+
expect(controller.getSnapshot().signIn.route).toBe('qr');
|
|
1293
|
+
controller.cancelSignIn();
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
it('never leaks the secret device-flow token into the snapshot', async () => {
|
|
1297
|
+
const { controller } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1298
|
+
|
|
1299
|
+
await controller.showQr();
|
|
1300
|
+
await flush();
|
|
1301
|
+
|
|
1302
|
+
const snap = controller.getSnapshot();
|
|
1303
|
+
expect(JSON.stringify(snap)).not.toContain('secret-tok');
|
|
1304
|
+
expect(Object.keys(snap.signIn)).toEqual([
|
|
1305
|
+
'phase',
|
|
1306
|
+
'authorizeCode',
|
|
1307
|
+
'qrPayload',
|
|
1308
|
+
'expiresAt',
|
|
1309
|
+
'error',
|
|
1310
|
+
'route',
|
|
1311
|
+
'routeFailed',
|
|
1312
|
+
'pushSentAt',
|
|
1313
|
+
'openedAt',
|
|
1314
|
+
'progress',
|
|
1315
|
+
]);
|
|
1316
|
+
// Only the PUBLIC handles are exposed.
|
|
1317
|
+
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
1318
|
+
controller.cancelSignIn();
|
|
1319
|
+
});
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
describe('AccountDialogController — sign-in progress (#691 phase 5)', () => {
|
|
1323
|
+
it('advances only on real signals, in order, to "Identity confirmed"', async () => {
|
|
1324
|
+
jest.useFakeTimers();
|
|
1325
|
+
try {
|
|
1326
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1327
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1328
|
+
oxy.pollCommonsSignIn
|
|
1329
|
+
// 1st tick: still nothing but the confirmed push.
|
|
1330
|
+
.mockResolvedValueOnce({
|
|
1331
|
+
authorized: false,
|
|
1332
|
+
status: 'pending',
|
|
1333
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1334
|
+
openedAt: null,
|
|
1335
|
+
})
|
|
1336
|
+
// 2nd tick: the approver opened the request.
|
|
1337
|
+
.mockResolvedValueOnce({
|
|
1338
|
+
authorized: false,
|
|
1339
|
+
status: 'pending',
|
|
1340
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1341
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1342
|
+
})
|
|
1343
|
+
// 3rd tick: approved.
|
|
1344
|
+
.mockResolvedValue({
|
|
1345
|
+
authorized: true,
|
|
1346
|
+
sessionId: 'sess-1',
|
|
1347
|
+
status: 'authorized',
|
|
1348
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1349
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1350
|
+
});
|
|
1351
|
+
let releaseClaim: () => void = () => undefined;
|
|
1352
|
+
oxy.claimSessionByToken.mockImplementation(
|
|
1353
|
+
() =>
|
|
1354
|
+
new Promise((resolve) => {
|
|
1355
|
+
releaseClaim = () =>
|
|
1356
|
+
resolve({
|
|
1357
|
+
accessToken: 'access-1',
|
|
1358
|
+
sessionId: 'sess-1',
|
|
1359
|
+
deviceId: 'device-1',
|
|
1360
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
1361
|
+
user: user('a1'),
|
|
1362
|
+
});
|
|
1363
|
+
}),
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
await controller.showQr();
|
|
1367
|
+
await jest.advanceTimersByTimeAsync(0); // let the route resolve
|
|
1368
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1369
|
+
|
|
1370
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1371
|
+
expect(controller.getSnapshot().signIn.pushSentAt).toBe('2026-07-27T10:00:00.000Z');
|
|
1372
|
+
expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
|
|
1373
|
+
|
|
1374
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1375
|
+
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1376
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1377
|
+
|
|
1378
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1379
|
+
// Approved: the claim/commit is running — "Confirming identity".
|
|
1380
|
+
expect(controller.getSnapshot().signIn.phase).toBe('authorized');
|
|
1381
|
+
expect(controller.getSnapshot().signIn.progress).toBe('confirming-identity');
|
|
1382
|
+
|
|
1383
|
+
releaseClaim();
|
|
1384
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1385
|
+
expect(controller.getSnapshot().signIn.phase).toBe('completed');
|
|
1386
|
+
expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
|
|
1387
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
1388
|
+
} finally {
|
|
1389
|
+
jest.useRealTimers();
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('does not advance without a signal — repeated empty polls keep it waiting', async () => {
|
|
1394
|
+
jest.useFakeTimers();
|
|
1395
|
+
try {
|
|
1396
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1397
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1398
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1399
|
+
authorized: false,
|
|
1400
|
+
status: 'pending',
|
|
1401
|
+
pushSentAt: null,
|
|
1402
|
+
openedAt: null,
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
await controller.showQr();
|
|
1406
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1407
|
+
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1408
|
+
|
|
1409
|
+
await jest.advanceTimersByTimeAsync(5000); // five fallback polls
|
|
1410
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(5);
|
|
1411
|
+
expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
|
|
1412
|
+
expect(controller.getSnapshot().signIn.pushSentAt).toBeNull();
|
|
1413
|
+
expect(controller.getSnapshot().signIn.openedAt).toBeNull();
|
|
1414
|
+
|
|
1415
|
+
controller.cancelSignIn();
|
|
1416
|
+
} finally {
|
|
1417
|
+
jest.useRealTimers();
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('is monotone — a later, emptier status response never walks progress backwards', async () => {
|
|
1422
|
+
jest.useFakeTimers();
|
|
1423
|
+
try {
|
|
1424
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1425
|
+
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
|
|
1426
|
+
oxy.pollCommonsSignIn
|
|
1427
|
+
.mockResolvedValueOnce({
|
|
1428
|
+
authorized: false,
|
|
1429
|
+
status: 'pending',
|
|
1430
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1431
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1432
|
+
})
|
|
1433
|
+
// An older API build (or a partial payload) omits the progress fields.
|
|
1434
|
+
.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1435
|
+
|
|
1436
|
+
await controller.showQr();
|
|
1437
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1438
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1439
|
+
|
|
1440
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
1441
|
+
expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
|
|
1442
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1443
|
+
|
|
1444
|
+
controller.cancelSignIn();
|
|
1445
|
+
} finally {
|
|
1446
|
+
jest.useRealTimers();
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('treats "opened in Commons" as progress only — it never claims a session', async () => {
|
|
1451
|
+
jest.useFakeTimers();
|
|
1452
|
+
try {
|
|
1453
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1454
|
+
oxy.pollCommonsSignIn.mockResolvedValue({
|
|
1455
|
+
authorized: false,
|
|
1456
|
+
status: 'pending',
|
|
1457
|
+
pushSentAt: '2026-07-27T10:00:00.000Z',
|
|
1458
|
+
openedAt: '2026-07-27T10:00:12.000Z',
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
await controller.showQr();
|
|
1462
|
+
await jest.advanceTimersByTimeAsync(3000);
|
|
1463
|
+
|
|
1464
|
+
expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
|
|
1465
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1466
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1467
|
+
|
|
1468
|
+
controller.cancelSignIn();
|
|
1469
|
+
} finally {
|
|
1470
|
+
jest.useRealTimers();
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
describe('AccountDialogController — cancellation converges (#691 phase 5)', () => {
|
|
1476
|
+
it('a denial in Commons converges on the waiting surface and stops the flow', async () => {
|
|
1477
|
+
jest.useFakeTimers();
|
|
1478
|
+
try {
|
|
1479
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1480
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1481
|
+
|
|
1482
|
+
await controller.showQr();
|
|
1483
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1484
|
+
|
|
1485
|
+
const snap = controller.getSnapshot();
|
|
1486
|
+
expect(snap.signIn.phase).toBe('error');
|
|
1487
|
+
expect(snap.signIn.error).toMatch(/denied/i);
|
|
1488
|
+
expect(snap.signIn.progress).toBe('idle');
|
|
1489
|
+
|
|
1490
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1491
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1492
|
+
expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
|
|
1493
|
+
} finally {
|
|
1494
|
+
jest.useRealTimers();
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it('a server-reported expiry converges and stops the flow', async () => {
|
|
1499
|
+
jest.useFakeTimers();
|
|
1500
|
+
try {
|
|
1501
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1502
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'expired' });
|
|
1503
|
+
|
|
1504
|
+
await controller.showQr();
|
|
1505
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1506
|
+
|
|
1507
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1508
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1509
|
+
|
|
1510
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1511
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
1512
|
+
} finally {
|
|
1513
|
+
jest.useRealTimers();
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
it('a locally-observed expiry converges without even asking the server', async () => {
|
|
1518
|
+
jest.useFakeTimers();
|
|
1519
|
+
try {
|
|
1520
|
+
const oxy = makeOxy();
|
|
1521
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
1522
|
+
...DELIVERY_HANDLE,
|
|
1523
|
+
expiresAt: Date.now() + 500, // expires before the first fallback poll
|
|
1524
|
+
});
|
|
1525
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1526
|
+
const controller = new AccountDialogController({
|
|
1527
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1528
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1529
|
+
clientId: 'oxy_dk_test',
|
|
1530
|
+
pollIntervalMs: 1000,
|
|
1531
|
+
platform: 'desktop',
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
await controller.showQr();
|
|
1535
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1536
|
+
|
|
1537
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1538
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1539
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
|
|
1540
|
+
} finally {
|
|
1541
|
+
jest.useRealTimers();
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('closing the surface withdraws the request server-side and stops every timer', async () => {
|
|
1546
|
+
jest.useFakeTimers();
|
|
1547
|
+
try {
|
|
1548
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1549
|
+
|
|
1550
|
+
await controller.showQr();
|
|
1551
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1552
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1553
|
+
|
|
1554
|
+
controller.cancelSignIn();
|
|
1555
|
+
|
|
1556
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
1557
|
+
expect(controller.getSnapshot().signIn).toEqual({
|
|
1558
|
+
phase: 'idle',
|
|
1559
|
+
authorizeCode: null,
|
|
1560
|
+
qrPayload: null,
|
|
1561
|
+
expiresAt: null,
|
|
1562
|
+
error: null,
|
|
1563
|
+
route: null,
|
|
1564
|
+
routeFailed: false,
|
|
1565
|
+
pushSentAt: null,
|
|
1566
|
+
openedAt: null,
|
|
1567
|
+
progress: 'idle',
|
|
1568
|
+
});
|
|
1569
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
1570
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1571
|
+
} finally {
|
|
1572
|
+
jest.useRealTimers();
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('does not withdraw a request that already reached a terminal state', async () => {
|
|
1577
|
+
jest.useFakeTimers();
|
|
1578
|
+
try {
|
|
1579
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1580
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
1581
|
+
|
|
1582
|
+
await controller.showQr();
|
|
1583
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
1584
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
1585
|
+
|
|
1586
|
+
controller.cancelSignIn();
|
|
1587
|
+
// Commons already cancelled it — nothing left to withdraw.
|
|
1588
|
+
expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
|
|
1589
|
+
} finally {
|
|
1590
|
+
jest.useRealTimers();
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it('swallows a failed withdrawal (an approval may have raced it) without surfacing an error', async () => {
|
|
1595
|
+
jest.useFakeTimers();
|
|
1596
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
1597
|
+
try {
|
|
1598
|
+
const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
|
|
1599
|
+
oxy.denyCommonsSignIn.mockRejectedValue(new Error('already authorized'));
|
|
1600
|
+
|
|
1601
|
+
await controller.showQr();
|
|
1602
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1603
|
+
controller.cancelSignIn();
|
|
1604
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1605
|
+
|
|
1606
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
1607
|
+
expect(controller.getSnapshot().error).toBeNull();
|
|
1608
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
1609
|
+
'[AccountDialogController] request withdrawal failed',
|
|
1610
|
+
{ component: 'AccountDialogController' },
|
|
1611
|
+
expect.any(Error),
|
|
1612
|
+
);
|
|
1613
|
+
} finally {
|
|
1614
|
+
jest.useRealTimers();
|
|
1615
|
+
debugSpy.mockRestore();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it('destroy tears down the poll timer, the auth-session socket, and the popup watcher', async () => {
|
|
1620
|
+
jest.useFakeTimers();
|
|
1621
|
+
try {
|
|
1622
|
+
const popup = fakePopup();
|
|
1623
|
+
const oxy = makeOxy();
|
|
1624
|
+
oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
|
|
1625
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1626
|
+
let socket: { disconnected: boolean } | null = null;
|
|
1627
|
+
const factory = jest.fn((): MinimalSocket => {
|
|
1628
|
+
const s: MinimalSocket & { disconnected: boolean } = {
|
|
1629
|
+
connected: true,
|
|
1630
|
+
disconnected: false,
|
|
1631
|
+
on: () => undefined,
|
|
1632
|
+
off: () => undefined,
|
|
1633
|
+
emit: () => undefined,
|
|
1634
|
+
connect: () => undefined,
|
|
1635
|
+
disconnect() {
|
|
1636
|
+
this.disconnected = true;
|
|
1637
|
+
},
|
|
1638
|
+
};
|
|
1639
|
+
socket = s;
|
|
1640
|
+
return s;
|
|
1641
|
+
});
|
|
1642
|
+
const controller = new AccountDialogController({
|
|
1643
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1644
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1645
|
+
clientId: 'oxy_dk_test',
|
|
1646
|
+
pollIntervalMs: 1000,
|
|
1647
|
+
platform: 'desktop',
|
|
1648
|
+
openPopup: () => popup,
|
|
1649
|
+
socketFactory: factory as unknown as SocketIOFactory,
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
await controller.startPasskeyHubSignIn();
|
|
1653
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
1654
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
1655
|
+
|
|
1656
|
+
controller.destroy();
|
|
1657
|
+
|
|
1658
|
+
expect(socket).not.toBeNull();
|
|
1659
|
+
expect(socket?.disconnected).toBe(true);
|
|
1660
|
+
expect(popup.close).toHaveBeenCalled();
|
|
1661
|
+
// No poll timer and no popup watchdog survive the teardown.
|
|
1662
|
+
await jest.advanceTimersByTimeAsync(30_000);
|
|
1663
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
1664
|
+
} finally {
|
|
1665
|
+
jest.useRealTimers();
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
describe('AccountDialogController — lifecycle', () => {
|
|
1671
|
+
it('destroy unsubscribes so later device-state changes do not notify', async () => {
|
|
1672
|
+
const { controller, oxy, sc } = makeHarness();
|
|
1673
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
1674
|
+
controller.start();
|
|
1675
|
+
await Promise.resolve();
|
|
1676
|
+
const seen: string[] = [];
|
|
1677
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
1678
|
+
controller.destroy();
|
|
1679
|
+
// destroy clears all listeners; a subsequent state push notifies nobody.
|
|
1680
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
1681
|
+
expect(seen).toEqual([]);
|
|
1682
|
+
});
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
describe('AccountDialogController — the directory (ADR 0002)', () => {
|
|
1686
|
+
/** A two-principal device where `org` is reachable through BOTH people. */
|
|
1687
|
+
const sharedDirectory = (revision: number, activeContextId: string | null) => ({
|
|
1688
|
+
deviceId: 'device-1',
|
|
1689
|
+
revision,
|
|
1690
|
+
activeContextId,
|
|
1691
|
+
updatedAt: 1_720_000_000_000,
|
|
1692
|
+
principals: [
|
|
1693
|
+
{
|
|
1694
|
+
id: 'p-nate',
|
|
1695
|
+
userId: 'nate',
|
|
1696
|
+
authuser: 0,
|
|
1697
|
+
user: { id: 'nate', username: 'nate' },
|
|
1698
|
+
contexts: [
|
|
1699
|
+
{
|
|
1700
|
+
id: 'ctx-nate-org',
|
|
1701
|
+
accountId: 'org',
|
|
1702
|
+
kind: 'organization' as const,
|
|
1703
|
+
relationship: 'owner' as const,
|
|
1704
|
+
account: { id: 'org', username: 'oxy' },
|
|
1705
|
+
onDevice: true,
|
|
1706
|
+
available: true,
|
|
1707
|
+
active: activeContextId === 'ctx-nate-org',
|
|
1708
|
+
lastUsedAt: null,
|
|
1709
|
+
},
|
|
1710
|
+
],
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
id: 'p-alice',
|
|
1714
|
+
userId: 'alice',
|
|
1715
|
+
authuser: 1,
|
|
1716
|
+
user: { id: 'alice', username: 'alice' },
|
|
1717
|
+
contexts: [
|
|
1718
|
+
{
|
|
1719
|
+
id: 'ctx-alice-org',
|
|
1720
|
+
accountId: 'org',
|
|
1721
|
+
kind: 'organization' as const,
|
|
1722
|
+
relationship: 'member' as const,
|
|
1723
|
+
account: { id: 'org', username: 'oxy' },
|
|
1724
|
+
onDevice: true,
|
|
1725
|
+
available: true,
|
|
1726
|
+
active: activeContextId === 'ctx-alice-org',
|
|
1727
|
+
lastUsedAt: null,
|
|
1728
|
+
},
|
|
1729
|
+
],
|
|
1730
|
+
},
|
|
1731
|
+
],
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
function directoryHarness(activeContextId: string | null) {
|
|
1735
|
+
const oxy = makeOxy();
|
|
1736
|
+
const urls: string[] = [];
|
|
1737
|
+
const bodies: unknown[] = [];
|
|
1738
|
+
const sc = new TestSessionClient({
|
|
1739
|
+
makeRequest: jest.fn(async (_method: string, url: string, data?: unknown) => {
|
|
1740
|
+
urls.push(url);
|
|
1741
|
+
bodies.push(data);
|
|
1742
|
+
if (url === '/session/device/directory') return sharedDirectory(5, activeContextId);
|
|
1743
|
+
if (url === '/session/device/activate') {
|
|
1744
|
+
return { directory: sharedDirectory(6, 'ctx-alice-org'), activeToken: null };
|
|
1745
|
+
}
|
|
1746
|
+
if (url === '/session/device/signout') {
|
|
1747
|
+
// The context-aware removals answer with their OWN contract — the
|
|
1748
|
+
// directory AND the flat state, because a removal elects a
|
|
1749
|
+
// replacement active context and both halves move together.
|
|
1750
|
+
return {
|
|
1751
|
+
directory: sharedDirectory(7, null),
|
|
1752
|
+
state: state([{ accountId: 'org', sessionId: 's-org' }], 'org', 7),
|
|
1753
|
+
activeToken: null,
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
return undefined;
|
|
1757
|
+
}),
|
|
1758
|
+
getBaseURL: () => 'http://test.invalid',
|
|
1759
|
+
getAccessToken: () => 'token',
|
|
1760
|
+
getDeviceCredential: () => null,
|
|
1761
|
+
onTokensChanged: () => () => undefined,
|
|
1762
|
+
setTokens: jest.fn(),
|
|
1763
|
+
getCurrentAccountId: () => null,
|
|
1764
|
+
});
|
|
1765
|
+
const controller = createAccountDialogController({
|
|
1766
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1767
|
+
sessionClient: sc,
|
|
1768
|
+
clientId: 'oxy_dk_test',
|
|
1769
|
+
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
1770
|
+
onSignedIn: jest.fn(),
|
|
1771
|
+
});
|
|
1772
|
+
return { controller, oxy, sc, urls, bodies };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
it('publishes the directory and the active context on the snapshot', async () => {
|
|
1776
|
+
const { controller, oxy } = directoryHarness('ctx-alice-org');
|
|
1777
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1778
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1779
|
+
|
|
1780
|
+
await controller.refresh();
|
|
1781
|
+
|
|
1782
|
+
const snap = controller.getSnapshot();
|
|
1783
|
+
expect(snap.directory?.revision).toBe(5);
|
|
1784
|
+
// The flat `accounts` list cannot express this: it is keyed by account id,
|
|
1785
|
+
// so ONE of these two routes to `org` would be all a consumer ever saw.
|
|
1786
|
+
expect(snap.directory?.principals.map((p) => p.userId)).toEqual(['nate', 'alice']);
|
|
1787
|
+
expect(snap.activeContext?.contextId).toBe('ctx-alice-org');
|
|
1788
|
+
expect(snap.activeContext?.actor.userId).toBe('alice');
|
|
1789
|
+
expect(snap.activeContext?.subject.accountId).toBe('org');
|
|
1790
|
+
expect(snap.activeContext?.isDelegated).toBe(true);
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it('activateContext POSTs the contextId and reports the switch in flight', async () => {
|
|
1794
|
+
const { controller, oxy, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1795
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1796
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1797
|
+
await controller.refresh();
|
|
1798
|
+
|
|
1799
|
+
const inFlight: Array<string | null> = [];
|
|
1800
|
+
controller.subscribe((s) => inFlight.push(s.activatingContextId));
|
|
1801
|
+
|
|
1802
|
+
expect(await controller.activateContext('ctx-alice-org')).toBe(true);
|
|
1803
|
+
|
|
1804
|
+
expect(urls).toContain('/session/device/activate');
|
|
1805
|
+
expect(bodies[urls.indexOf('/session/device/activate')]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1806
|
+
// Reported while running, cleared after — a row can show a spinner.
|
|
1807
|
+
expect(inFlight).toContain('ctx-alice-org');
|
|
1808
|
+
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1809
|
+
expect(controller.getSnapshot().activeContext?.actor.userId).toBe('alice');
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('refuses a second activation while one is in flight', async () => {
|
|
1813
|
+
const { controller, oxy } = directoryHarness('ctx-nate-org');
|
|
1814
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1815
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1816
|
+
await controller.refresh();
|
|
1817
|
+
|
|
1818
|
+
const first = controller.activateContext('ctx-alice-org');
|
|
1819
|
+
expect(await controller.activateContext('ctx-nate-org')).toBe(false);
|
|
1820
|
+
expect(await first).toBe(true);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it('surfaces an activation failure as a dialog error without wedging the flag', async () => {
|
|
1824
|
+
const { controller, oxy, sc } = directoryHarness('ctx-nate-org');
|
|
1825
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1826
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1827
|
+
await controller.refresh();
|
|
1828
|
+
// A stale context id is an ordinary outcome, not a bug: the server 404s and
|
|
1829
|
+
// heals the row away.
|
|
1830
|
+
jest.spyOn(sc, 'activateContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1831
|
+
|
|
1832
|
+
expect(await controller.activateContext('ctx-gone')).toBe(false);
|
|
1833
|
+
|
|
1834
|
+
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1835
|
+
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
it('keeps the directory it already holds when a later read fails, and says so', async () => {
|
|
1839
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1840
|
+
const { controller, failDirectory } = makeHarness();
|
|
1841
|
+
|
|
1842
|
+
await controller.refresh();
|
|
1843
|
+
expect(controller.getSnapshot().directory?.revision).toBe(9_000);
|
|
1844
|
+
|
|
1845
|
+
failDirectory(new Error('boom'));
|
|
1846
|
+
await controller.refresh();
|
|
1847
|
+
|
|
1848
|
+
// Degraded, not blank. A transient outage must not empty a switcher the
|
|
1849
|
+
// user is looking at — but it must not pretend either, so the error is on
|
|
1850
|
+
// the snapshot for the surface to render.
|
|
1851
|
+
const snap = controller.getSnapshot();
|
|
1852
|
+
expect(snap.directory?.revision).toBe(9_000);
|
|
1853
|
+
expect(snap.error).toBe('boom');
|
|
1854
|
+
expect(snap.loading).toBe(false);
|
|
1855
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1856
|
+
'[AccountDialogController] directory refresh failed',
|
|
1857
|
+
{ component: 'AccountDialogController' },
|
|
1858
|
+
expect.any(Error),
|
|
1859
|
+
);
|
|
1860
|
+
warnSpy.mockRestore();
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it('spins only while there is nothing to show', async () => {
|
|
1864
|
+
const { controller } = makeHarness();
|
|
1865
|
+
const loading: boolean[] = [];
|
|
1866
|
+
controller.subscribe((s) => loading.push(s.loading));
|
|
1867
|
+
|
|
1868
|
+
// First read: nothing held, so the switcher has nothing to render.
|
|
1869
|
+
await controller.refresh();
|
|
1870
|
+
expect(loading).toContain(true);
|
|
1871
|
+
expect(controller.getSnapshot().loading).toBe(false);
|
|
1872
|
+
|
|
1873
|
+
// Second read, behind a directory already on screen: refreshed in place,
|
|
1874
|
+
// never blanked back to a spinner.
|
|
1875
|
+
loading.length = 0;
|
|
1876
|
+
await controller.refresh();
|
|
1877
|
+
expect(loading).not.toContain(true);
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
it('removes ONE route to a shared account, naming the pair and not the account', async () => {
|
|
1881
|
+
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1882
|
+
await controller.refresh();
|
|
1883
|
+
|
|
1884
|
+
const inFlight: Array<string | null> = [];
|
|
1885
|
+
controller.subscribe((s) => inFlight.push(s.removingContextId));
|
|
1886
|
+
|
|
1887
|
+
expect(await controller.signOutContext('ctx-alice-org')).toBe(true);
|
|
1888
|
+
|
|
1889
|
+
const index = urls.indexOf('/session/device/signout');
|
|
1890
|
+
expect(index).toBeGreaterThanOrEqual(0);
|
|
1891
|
+
// `{ contextId }`, never `{ accountId }`. Both people reach `org` here, and
|
|
1892
|
+
// an account id would revoke Nate's route as a side effect of Alice tidying
|
|
1893
|
+
// her own list.
|
|
1894
|
+
expect(bodies[index]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1895
|
+
expect(inFlight).toContain('ctx-alice-org');
|
|
1896
|
+
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it('removes ONE PERSON through the principal id, not through their contexts', async () => {
|
|
1900
|
+
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1901
|
+
await controller.refresh();
|
|
1902
|
+
|
|
1903
|
+
const inFlight: Array<string | null> = [];
|
|
1904
|
+
controller.subscribe((s) => inFlight.push(s.removingPrincipalId));
|
|
1905
|
+
|
|
1906
|
+
expect(await controller.signOutPrincipal('p-alice')).toBe(true);
|
|
1907
|
+
|
|
1908
|
+
const index = urls.indexOf('/session/device/signout');
|
|
1909
|
+
// `{ principalId }` — one call, not a loop over that person's contexts, and
|
|
1910
|
+
// emphatically not the context endpoint: pointing this at `{ contextId }`
|
|
1911
|
+
// would drop one pair and leave the person on the device.
|
|
1912
|
+
expect(bodies[index]).toEqual({ principalId: 'p-alice' });
|
|
1913
|
+
expect(urls.filter((u) => u === '/session/device/signout')).toHaveLength(1);
|
|
1914
|
+
expect(inFlight).toContain('p-alice');
|
|
1915
|
+
expect(controller.getSnapshot().removingPrincipalId).toBeNull();
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it('refuses a second removal while one is in flight, of either kind', async () => {
|
|
1919
|
+
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1920
|
+
await controller.refresh();
|
|
1921
|
+
let release: () => void = () => undefined;
|
|
1922
|
+
jest.spyOn(sc, 'signOutContext').mockImplementation(
|
|
1923
|
+
() => new Promise<void>((resolve) => { release = resolve; }),
|
|
1924
|
+
);
|
|
1925
|
+
const principalSpy = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
|
|
1926
|
+
|
|
1927
|
+
const first = controller.signOutContext('ctx-alice-org');
|
|
1928
|
+
// The two removals share one gate: they mutate the same device and a
|
|
1929
|
+
// concurrent pair would race for which replacement context ends up active.
|
|
1930
|
+
expect(await controller.signOutPrincipal('p-alice')).toBe(false);
|
|
1931
|
+
expect(principalSpy).not.toHaveBeenCalled();
|
|
1932
|
+
|
|
1933
|
+
release();
|
|
1934
|
+
expect(await first).toBe(true);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it('surfaces a failed removal without wedging the flag', async () => {
|
|
1938
|
+
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1939
|
+
await controller.refresh();
|
|
1940
|
+
// A context id is not stable across a removal, so a stale one is ordinary:
|
|
1941
|
+
// the server refuses and the next read simply does not offer it.
|
|
1942
|
+
jest.spyOn(sc, 'signOutContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1943
|
+
|
|
1944
|
+
expect(await controller.signOutContext('ctx-gone')).toBe(false);
|
|
1945
|
+
|
|
1946
|
+
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1947
|
+
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
it('makes no directory call while signed out', async () => {
|
|
1951
|
+
const { controller, oxy, urls } = directoryHarness(null);
|
|
1952
|
+
oxy.getAccessToken.mockReturnValue(null);
|
|
1953
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1954
|
+
|
|
1955
|
+
await controller.refresh();
|
|
1956
|
+
|
|
1957
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
1958
|
+
expect(controller.getSnapshot().directory).toBeNull();
|
|
1959
|
+
});
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
it('createAccountDialogController returns an AccountDialogController instance', () => {
|
|
1963
|
+
const { controller } = makeHarness();
|
|
1964
|
+
expect(controller).toBeInstanceOf(AccountDialogController);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
// Ensure the exported type surface is reachable at compile time for binders.
|
|
1968
|
+
const _typecheck: MinimalUserData | null = null;
|
|
1969
|
+
void _typecheck;
|