@xenon-device-management/xenon 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -29
- package/lib/package.json +6 -5
- package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-GmckX_7r.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-DqODzkyo.js +2 -0
- package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-7hETKEWb.js} +1 -1
- package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-GOgssFEP.js} +1 -1
- package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-Yvf6RGMd.js} +1 -1
- package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-KrPnfdsf.js} +1 -1
- package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-C6WjZFeI.js} +1 -1
- package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CSRIhZEh.js} +1 -1
- package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-CB8ST3nE.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C9o90KId.js} +1 -1
- package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-CPm3w9l2.js} +1 -1
- package/lib/public/assets/{Table-BcTm5xV2.js → Table-DwIgERRt.js} +1 -1
- package/lib/public/assets/{activity-DOVdsfMQ.js → activity-DrLbHifn.js} +1 -1
- package/lib/public/assets/ai-settings-DU_Ls06P.js +16 -0
- package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-CWYdEzJ_.js} +1 -1
- package/lib/public/assets/{apps-D0SC6uw6.js → apps-BqNVdln7.js} +1 -1
- package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BtB0_Jg_.js} +1 -1
- package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-C8NhdtyN.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-gMg0ffvv.js} +1 -1
- package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CiYZ3N4M.js} +2 -2
- package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-EaaqDmLN.js} +1 -1
- package/lib/public/assets/{button-Dda4irn_.js → button-DDau4Axv.js} +1 -1
- package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-DQ83B1ed.js} +1 -1
- package/lib/public/assets/{check-CZ9iw9-e.js → check-K1ovTQam.js} +1 -1
- package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-Biq00pMt.js} +1 -1
- package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CBv88cH7.js} +1 -1
- package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-Bmltailn.js} +1 -1
- package/lib/public/assets/{clock-CpuDKysU.js → clock-BPrld-3h.js} +1 -1
- package/lib/public/assets/{copy-CAQydNFe.js → copy-t0QB14JI.js} +1 -1
- package/lib/public/assets/derive-UnLRp4r3.js +1 -0
- package/lib/public/assets/device-explorer-BgGZ8AvV.js +282 -0
- package/lib/public/assets/{download-C9XG6lLv.js → download-BroHxNOh.js} +1 -1
- package/lib/public/assets/forgot-password-Bjmf49i6.js +1 -0
- package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-DeD__6wa.js} +6 -6
- package/lib/public/assets/{index-DJGc1M2f.js → index-72oF89LJ.js} +1 -1
- package/lib/public/assets/index-B0JeuZbg.js +15 -0
- package/lib/public/assets/index-B0gQkEg0.js +219 -0
- package/lib/public/assets/{input-Cm0tmSoc.js → input-DIc4MSC7.js} +1 -1
- package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-BcWm2vW8.js} +1 -1
- package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-B06IJJad.js} +1 -1
- package/lib/public/assets/{lock-CCJv9m_I.js → lock-pVLvnN30.js} +1 -1
- package/lib/public/assets/{login-f8vOVW-U.js → login-CN77vy--.js} +1 -1
- package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-CSdzQSkY.js} +1 -1
- package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-BtrN5IkI.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
- package/lib/public/assets/{network-ebT86qoi.js → network-BSfA8ZxG.js} +1 -1
- package/lib/public/assets/{overview-C0aykVrd.js → overview-BfTebCK5.js} +1 -1
- package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-D8KUX6Qg.js} +1 -1
- package/lib/public/assets/{play-DLG9t8FU.js → play-Cvc8xzic.js} +1 -1
- package/lib/public/assets/{plus-nBFSMu_X.js → plus-C9Zj84UF.js} +1 -1
- package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-Bi27STgs.js} +1 -1
- package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-CPsOuwSu.js} +1 -1
- package/lib/public/assets/runbook-page-DEQ-DBOX.js +304 -0
- package/lib/public/assets/{select-88jHcl-Z.js → select-BWpnhWXj.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BefSCSpe.js} +1 -1
- package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-MSk96Zz7.js} +1 -1
- package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
- package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
- package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-V6x-cXwy.js} +1 -1
- package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-Duex-GZD.js} +1 -1
- package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BBcoqBmL.js} +1 -1
- package/lib/public/assets/{teams-B4-jrr-1.js → teams-ts8p5rEh.js} +1 -1
- package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-CZ2ew6AZ.js} +1 -1
- package/lib/public/assets/{upload-BG7_rMXK.js → upload-Dv99y_le.js} +1 -1
- package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-DrYC8JCF.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
- package/lib/public/assets/{users-CFba5TSy.js → users-D7tKMESH.js} +1 -1
- package/lib/public/assets/{users-Cp0beJm3.js → users-gJ5Y01yo.js} +1 -1
- package/lib/public/assets/{video-off-DDsEISjV.js → video-off-CqZVuZGs.js} +1 -1
- package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-DI5DSW99.js} +1 -1
- package/lib/public/assets/{zap-l4VaXzq8.js → zap-ClcHFxfo.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +16 -12
- package/lib/src/InternalHttpClient.js +27 -5
- package/lib/src/XenonCapabilityManager.js +10 -3
- package/lib/src/app/dashboardPluginLink.js +117 -0
- package/lib/src/app/index.js +13 -34
- package/lib/src/app/openapi/control.yaml +6 -4
- package/lib/src/app/openapi/grid.yaml +8 -2
- package/lib/src/app/openapi/identity.yaml +103 -31
- package/lib/src/app/openapi/platform.yaml +33 -15
- package/lib/src/app/openapi/sessions.yaml +12 -3
- package/lib/src/app/routers/apikeys.js +14 -2
- package/lib/src/app/routers/auth.js +31 -6
- package/lib/src/app/routers/control.js +13 -5
- package/lib/src/app/routers/dashboard.js +42 -27
- package/lib/src/app/routers/grid.js +5 -3
- package/lib/src/app/routers/profile.js +21 -0
- package/lib/src/app/routers/users.js +12 -6
- package/lib/src/app/swagger.js +2 -0
- package/lib/src/app/ws/h264StreamWs.js +10 -1
- package/lib/src/app/ws/nodeSocketRelay.js +77 -11
- package/lib/src/config.js +1 -0
- package/lib/src/dashboard/commandLogFields.js +42 -0
- package/lib/src/dashboard/commands.js +5 -5
- package/lib/src/dashboard/event-manager.js +64 -204
- package/lib/src/dashboard/services/failure-analysis-service.js +118 -105
- package/lib/src/dashboard/services/failureCategories.js +264 -0
- package/lib/src/dashboard/sessionCommandSummary.js +39 -0
- package/lib/src/data-service/device-store.js +14 -1
- package/lib/src/data-service/prisma-store.js +19 -7
- package/lib/src/data-service/web-config-service.js +7 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
- package/lib/src/device-managers/IOSDeviceManager.js +10 -0
- package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
- package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
- package/lib/src/device-utils.js +29 -3
- package/lib/src/gateway/defaultGateway.js +1 -1
- package/lib/src/gateway/forwardToNode.js +6 -11
- package/lib/src/gateway/healReport.js +87 -0
- package/lib/src/gateway/hubSessionToken.js +27 -4
- package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
- package/lib/src/gateway/sessionGateway.js +12 -2
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +32 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +3 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/helpers/outboundProxy.js +219 -0
- package/lib/src/index.js +21 -0
- package/lib/src/interceptors/CommandInterceptor.js +256 -196
- package/lib/src/interceptors/xenonScripts.js +28 -7
- package/lib/src/logger.js +3 -1
- package/lib/src/middleware/authMiddleware.js +15 -3
- package/lib/src/middleware/commandCaller.js +3 -3
- package/lib/src/middleware/verifyCredential.js +58 -1
- package/lib/src/plugin.js +16 -6
- package/lib/src/scripts/database-check.js +3 -3
- package/lib/src/scripts/initialize-database.js +35 -33
- package/lib/src/scripts/run-migrations.js +1079 -27
- package/lib/src/services/AICommandService.js +9 -2
- package/lib/src/services/AIService.js +192 -42
- package/lib/src/services/ApiKeyService.js +16 -5
- package/lib/src/services/EmailService.js +29 -0
- package/lib/src/services/InterceptorService.js +6 -2
- package/lib/src/services/OrphanSweeper.js +8 -0
- package/lib/src/services/SelectorStateService.js +11 -7
- package/lib/src/services/SelectorVerificationJob.js +21 -6
- package/lib/src/services/ServerManager.js +11 -1
- package/lib/src/services/SessionLifecycleService.js +118 -44
- package/lib/src/services/SocketClient.js +11 -0
- package/lib/src/services/SocketServer.js +380 -45
- package/lib/src/services/TeamService.js +5 -1
- package/lib/src/services/TracingService.js +171 -60
- package/lib/src/services/UserService.js +14 -3
- package/lib/src/services/UserSessionService.js +16 -4
- package/lib/src/services/bug-report/video-slice.js +4 -1
- package/lib/src/services/device-access/leaseOverride.js +7 -5
- package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
- package/lib/src/services/healing/HealEtalonService.js +3 -2
- package/lib/src/services/healing/HealingOrchestrator.js +57 -72
- package/lib/src/services/healing/OcrHealingProvider.js +41 -64
- package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
- package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
- package/lib/src/services/healing/fingerprintIdentity.js +126 -0
- package/lib/src/services/healing/resilioPath.js +274 -0
- package/lib/src/services/identity/identityChanges.js +41 -0
- package/lib/src/services/logcat/SessionDeviceLogs.js +358 -0
- package/lib/src/services/logcat/deviceLogBook.js +164 -0
- package/lib/src/services/logcat/iosDriverLog.js +178 -0
- package/lib/src/services/ocr/ocrData.js +118 -0
- package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
- package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
- package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
- package/lib/src/services/passwordResetLink.js +50 -9
- package/lib/src/services/passwordResetMode.js +3 -1
- package/lib/src/services/publicUrl.js +45 -0
- package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
- package/lib/src/services/session/sessionIdentity.js +5 -3
- package/lib/src/services/session/shutdownReason.js +26 -0
- package/lib/src/services/settings/aiEngineSettings.js +219 -0
- package/lib/src/services/token/JwtKeyService.js +8 -2
- package/lib/src/services/token/mcpTokenTtl.js +27 -0
- package/lib/src/services/token/mintedLifetime.js +51 -0
- package/lib/src/sessions/LocalSession.js +29 -1
- package/lib/src/sessions/RemoteSession.js +12 -2
- package/lib/src/sessions/SessionManager.js +4 -1
- package/lib/src/sessions/sessionMemory.js +25 -0
- package/lib/test/helpers/driver-log.js +38 -0
- package/lib/test/helpers/fake-ai-provider.js +188 -0
- package/lib/test/helpers/fake-http-proxy.js +106 -0
- package/lib/test/helpers/loki-stores.js +33 -0
- package/lib/test/helpers/otlp-probe.js +107 -0
- package/lib/test/helpers/page-sources.js +164 -0
- package/lib/test/helpers/scratch-database.js +57 -13
- package/lib/test/integration/androidDevices.spec.js +1 -1
- package/lib/test/integration/audit-ingest.spec.js +10 -10
- package/lib/test/integration/auth-flow.spec.js +9 -14
- package/lib/test/integration/auth-rate-limit.spec.js +6 -3
- package/lib/test/integration/bug-report-route.spec.js +22 -2
- package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
- package/lib/test/integration/forgot-password.spec.js +17 -18
- package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
- package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
- package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
- package/lib/test/integration/migration.spec.js +6 -1
- package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
- package/lib/test/integration/ports-router.spec.js +4 -4
- package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
- package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
- package/lib/test/integration/profile-tokens.spec.js +10 -16
- package/lib/test/integration/recordings-router.spec.js +16 -16
- package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
- package/lib/test/integration/role-matrix.spec.js +4 -9
- package/lib/test/integration/sdk-leases-router.spec.js +9 -9
- package/lib/test/integration/sdk-version.spec.js +2 -2
- package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
- package/lib/test/integration/selector-state-flow.spec.js +5 -5
- package/lib/test/integration/team-membership-migration.spec.js +73 -12
- package/lib/test/integration/team-visibility-apps.spec.js +15 -22
- package/lib/test/integration/team-visibility-control.spec.js +96 -69
- package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
- package/lib/test/integration/team-visibility-grid.spec.js +16 -15
- package/lib/test/integration/team-visibility-leases.spec.js +8 -11
- package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
- package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
- package/lib/test/integration/users-flow.spec.js +7 -12
- package/lib/test/integration/users-lockout.spec.js +5 -3
- package/lib/test/unit/EmailService.test.js +66 -0
- package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
- package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
- package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
- package/lib/test/unit/ai-engine-settings.spec.js +338 -0
- package/lib/test/unit/ai-provider-switch.spec.js +130 -0
- package/lib/test/unit/ai-rate-limit.spec.js +126 -0
- package/lib/test/unit/android-adb-retry.spec.js +112 -0
- package/lib/test/unit/android-h264-viewers.spec.js +272 -0
- package/lib/test/unit/android-preview-hold.spec.js +3 -2
- package/lib/test/unit/apikeys-router.spec.js +84 -0
- package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
- package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
- package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
- package/lib/test/unit/command-log-network-capture.spec.js +191 -0
- package/lib/test/unit/dashboard-events-device-logs.spec.js +255 -0
- package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
- package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
- package/lib/test/unit/database-check.spec.js +1 -0
- package/lib/test/unit/device-log-book.spec.js +180 -0
- package/lib/test/unit/device-utils.spec.js +13 -4
- package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
- package/lib/test/unit/event-log-session-data.spec.js +251 -0
- package/lib/test/unit/failure-analysis.spec.js +196 -0
- package/lib/test/unit/failure-categories.spec.js +130 -0
- package/lib/test/unit/failure-categorization.spec.js +393 -0
- package/lib/test/unit/fingerprint-identity.spec.js +433 -0
- package/lib/test/unit/h264-multiplexer.spec.js +46 -0
- package/lib/test/unit/heal-report.spec.js +71 -0
- package/lib/test/unit/healing-instrumentation.spec.js +6 -1
- package/lib/test/unit/healing-tier-gate.spec.js +74 -29
- package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +243 -1
- package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
- package/lib/test/unit/ios-driver-log.spec.js +168 -0
- package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
- package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
- package/lib/test/unit/lease/lease-override.spec.js +23 -6
- package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
- package/lib/test/unit/logcat-ws.spec.js +78 -37
- package/lib/test/unit/logger-json-levels.spec.js +44 -0
- package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
- package/lib/test/unit/observability-example-loki.spec.js +25 -0
- package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
- package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
- package/lib/test/unit/ocr-offline.spec.js +213 -0
- package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
- package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
- package/lib/test/unit/omni-vision.spec.js +5 -7
- package/lib/test/unit/openapi-for-site.spec.js +80 -0
- package/lib/test/unit/outbound-proxy.spec.js +320 -0
- package/lib/test/unit/package-engines.spec.js +27 -0
- package/lib/test/unit/passwordResetMode.test.js +10 -2
- package/lib/test/unit/port-exhaustion.spec.js +2 -1
- package/lib/test/unit/profile-router.test.js +122 -1
- package/lib/test/unit/proxy-option.spec.js +358 -0
- package/lib/test/unit/queue-requester.spec.js +1 -1
- package/lib/test/unit/reset-link-address.spec.js +266 -0
- package/lib/test/unit/reset-link.test.js +10 -4
- package/lib/test/unit/resilio-path-healing.spec.js +270 -0
- package/lib/test/unit/run-migrations-database.spec.js +807 -0
- package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
- package/lib/test/unit/run-migrations.test.js +864 -0
- package/lib/test/unit/selector-health-actions.spec.js +3 -1
- package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
- package/lib/test/unit/selector-state-service.spec.js +72 -23
- package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
- package/lib/test/unit/selector-verification-job.spec.js +28 -19
- package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
- package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
- package/lib/test/unit/self-healing-boot.spec.js +10 -5
- package/lib/test/unit/self-healing-setting.spec.js +7 -6
- package/lib/test/unit/session-app-resolution.spec.js +5 -1
- package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
- package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
- package/lib/test/unit/session-credential-strip.spec.js +1 -1
- package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
- package/lib/test/unit/session-device-logs.spec.js +356 -0
- package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
- package/lib/test/unit/session-log-indexes.spec.js +64 -3
- package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
- package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
- package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
- package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
- package/lib/test/unit/token-mint-limits.spec.js +165 -0
- package/lib/test/unit/tracing-export-routing.spec.js +137 -0
- package/lib/test/unit/tracing-session-spans.spec.js +337 -0
- package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
- package/lib/test/unit/verify-credential.spec.js +33 -0
- package/lib/test/unit/vision-assertion.spec.js +3 -2
- package/lib/test/unit/web-config-service.spec.js +19 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
- package/prisma/schema.prisma +3 -0
- package/schema.json +16 -12
- package/scripts/dev/readme-screenshots.js +276 -23
- package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
- package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
- package/scripts/dev/screenshot-fixtures/session.js +242 -0
- package/scripts/export-openapi.js +32 -0
- package/scripts/lib/openapi-for-site.js +79 -0
- package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
- package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
- package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
- package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
- package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
- package/lib/public/assets/index-BbKEXUFH.js +0 -219
- package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
- package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
- package/lib/src/scripts/prepare-prisma.js +0 -89
- /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
require("reflect-metadata");
|
|
16
|
+
const chai_1 = require("chai");
|
|
17
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
18
|
+
const http_1 = __importDefault(require("http"));
|
|
19
|
+
const https_1 = __importDefault(require("https"));
|
|
20
|
+
const typedi_1 = require("typedi");
|
|
21
|
+
const socket_io_1 = require("socket.io");
|
|
22
|
+
const ws_1 = require("ws");
|
|
23
|
+
const PluginContext_1 = require("../../src/PluginContext");
|
|
24
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
25
|
+
const outboundProxy_1 = require("../../src/helpers/outboundProxy");
|
|
26
|
+
const forwardToNode_1 = require("../../src/gateway/forwardToNode");
|
|
27
|
+
const hubSessionToken_1 = require("../../src/gateway/hubSessionToken");
|
|
28
|
+
const nodeWebDriverUrl_1 = require("../../src/gateway/nodeWebDriverUrl");
|
|
29
|
+
const nodeSocketRelay_1 = require("../../src/app/ws/nodeSocketRelay");
|
|
30
|
+
const RemoteSession_1 = require("../../src/sessions/RemoteSession");
|
|
31
|
+
const LocalSession_1 = require("../../src/sessions/LocalSession");
|
|
32
|
+
const SessionLifecycleService_1 = require("../../src/services/SessionLifecycleService");
|
|
33
|
+
const SocketClient_1 = require("../../src/services/SocketClient");
|
|
34
|
+
const InternalHttpClient_1 = require("../../src/InternalHttpClient");
|
|
35
|
+
const helpers_1 = require("../../src/helpers");
|
|
36
|
+
const node_forge_1 = require("node-forge");
|
|
37
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
38
|
+
const fake_http_proxy_1 = require("../helpers/fake-http-proxy");
|
|
39
|
+
const PROXY_ENV = [
|
|
40
|
+
'HTTP_PROXY',
|
|
41
|
+
'http_proxy',
|
|
42
|
+
'HTTPS_PROXY',
|
|
43
|
+
'https_proxy',
|
|
44
|
+
'NO_PROXY',
|
|
45
|
+
'no_proxy',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* The `proxy` plugin option reached one call: the create a hub sends to a node
|
|
49
|
+
* or a cloud provider. The session's commands, its screenshots and heartbeats,
|
|
50
|
+
* device control, the sockets, and a node's calls to its hub all took the
|
|
51
|
+
* environment's proxy instead, or none: a lab that set only the option had
|
|
52
|
+
* its sessions created through the proxy and then driven straight at the node.
|
|
53
|
+
*
|
|
54
|
+
* Every call one Xenon server makes to another, or to a cloud provider, now
|
|
55
|
+
* takes it. NO_PROXY still names the hosts that go direct, and so does any
|
|
56
|
+
* loopback host, which a proxy elsewhere can't reach.
|
|
57
|
+
*/
|
|
58
|
+
describe('the proxy plugin option', function () {
|
|
59
|
+
this.timeout(30000);
|
|
60
|
+
describe('the rule', () => {
|
|
61
|
+
const env = (vars) => vars;
|
|
62
|
+
const option = { host: 'squid.lab', port: 3128 };
|
|
63
|
+
it("is taken over the environment's proxy, for http and https alike", () => {
|
|
64
|
+
const vars = env({ HTTP_PROXY: 'http://env:1', HTTPS_PROXY: 'http://env:2' });
|
|
65
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab:4723/x', { env: vars, option })).to.equal('http://squid.lab:3128');
|
|
66
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('https://cloud.example/wd/hub', { env: vars, option })).to.equal('http://squid.lab:3128');
|
|
67
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('ws://node.lab:4723/x', { env: vars, option })).to.equal('http://squid.lab:3128');
|
|
68
|
+
});
|
|
69
|
+
it('is written as a URL: its protocol, its credentials escaped, or the string it is', () => {
|
|
70
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab/x', {
|
|
71
|
+
env: env({}),
|
|
72
|
+
option: {
|
|
73
|
+
protocol: 'https',
|
|
74
|
+
host: 'squid.lab',
|
|
75
|
+
port: 3129,
|
|
76
|
+
auth: { username: 'lab@x', password: 'p:ss' },
|
|
77
|
+
},
|
|
78
|
+
})).to.equal('https://lab%40x:p%3Ass@squid.lab:3129');
|
|
79
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab/x', { env: env({}), option: 'http://squid.lab:8080' })).to.equal('http://squid.lab:8080');
|
|
80
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab/x', { env: env({}), option: { host: 'squid.lab' } })).to.equal('http://squid.lab');
|
|
81
|
+
});
|
|
82
|
+
it('leaves a host NO_PROXY names direct', () => {
|
|
83
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab:4723/x', {
|
|
84
|
+
env: env({ NO_PROXY: '.lab' }),
|
|
85
|
+
option,
|
|
86
|
+
})).to.equal(undefined);
|
|
87
|
+
});
|
|
88
|
+
it('leaves loopback hosts direct; the environment still decides for them', () => {
|
|
89
|
+
for (const url of [
|
|
90
|
+
'http://localhost:4724/x',
|
|
91
|
+
'http://127.0.0.1:4724/x',
|
|
92
|
+
'http://127.1.2.3/x',
|
|
93
|
+
'http://[::1]:4724/x',
|
|
94
|
+
]) {
|
|
95
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)(url, { env: env({}), option }), url).to.equal(undefined);
|
|
96
|
+
}
|
|
97
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://127.0.0.1:4724/x', {
|
|
98
|
+
env: env({ HTTP_PROXY: 'http://env:1' }),
|
|
99
|
+
option,
|
|
100
|
+
})).to.equal('http://env:1');
|
|
101
|
+
});
|
|
102
|
+
it('is ignored, with a warning, when it names no host', () => {
|
|
103
|
+
const warn = sinon_1.default.stub();
|
|
104
|
+
(0, chai_1.expect)((0, outboundProxy_1.outboundProxyFor)('http://node.lab/x', {
|
|
105
|
+
env: env({ HTTP_PROXY: 'http://env:1' }),
|
|
106
|
+
option: { port: 3128 },
|
|
107
|
+
warn,
|
|
108
|
+
})).to.equal('http://env:1');
|
|
109
|
+
(0, chai_1.expect)(warn.calledOnce).to.equal(true);
|
|
110
|
+
(0, chai_1.expect)(warn.firstCall.args[0]).to.include('proxy');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('every call to another server goes through it', () => {
|
|
114
|
+
let proxy;
|
|
115
|
+
let context;
|
|
116
|
+
let savedContext;
|
|
117
|
+
let savedEnv;
|
|
118
|
+
let restoreRegs;
|
|
119
|
+
const servers = [];
|
|
120
|
+
const sockets = [];
|
|
121
|
+
const ios = [];
|
|
122
|
+
beforeEach(() => __awaiter(this, void 0, void 0, function* () {
|
|
123
|
+
savedEnv = Object.fromEntries(PROXY_ENV.map((k) => [k, process.env[k]]));
|
|
124
|
+
for (const k of PROXY_ENV)
|
|
125
|
+
delete process.env[k];
|
|
126
|
+
proxy = yield (0, fake_http_proxy_1.startFakeProxy)({
|
|
127
|
+
hosts: { 'node.test': '127.0.0.1', 'hub.test': '127.0.0.1' },
|
|
128
|
+
});
|
|
129
|
+
context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
130
|
+
savedContext = Object.assign({}, context);
|
|
131
|
+
const [, proxyPort] = proxy.url.replace('http://', '').split(':');
|
|
132
|
+
context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1', proxy: {
|
|
133
|
+
host: '127.0.0.1',
|
|
134
|
+
port: Number(proxyPort),
|
|
135
|
+
auth: { username: 'lab', password: 's3cret' },
|
|
136
|
+
} }), 4723, 'hub-1', '');
|
|
137
|
+
restoreRegs = (0, container_registration_1.saveRegistrations)(hubSessionToken_1.HubSessionTokenIssuer);
|
|
138
|
+
typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, {
|
|
139
|
+
tokenFor: () => __awaiter(this, void 0, void 0, function* () { return null; }),
|
|
140
|
+
createTokenFor: () => __awaiter(this, void 0, void 0, function* () { return null; }),
|
|
141
|
+
controlTokenFor: () => __awaiter(this, void 0, void 0, function* () { return null; }),
|
|
142
|
+
});
|
|
143
|
+
}));
|
|
144
|
+
afterEach(() => __awaiter(this, void 0, void 0, function* () {
|
|
145
|
+
restoreRegs();
|
|
146
|
+
Object.assign(context, savedContext);
|
|
147
|
+
for (const k of PROXY_ENV) {
|
|
148
|
+
if (savedEnv[k] === undefined)
|
|
149
|
+
delete process.env[k];
|
|
150
|
+
else
|
|
151
|
+
process.env[k] = savedEnv[k];
|
|
152
|
+
}
|
|
153
|
+
for (const io of ios.splice(0))
|
|
154
|
+
io.close();
|
|
155
|
+
for (const wss of sockets.splice(0)) {
|
|
156
|
+
for (const client of wss.clients)
|
|
157
|
+
client.terminate();
|
|
158
|
+
wss.close();
|
|
159
|
+
}
|
|
160
|
+
yield proxy.close();
|
|
161
|
+
yield Promise.all(servers.splice(0).map((s) => new Promise((resolve) => {
|
|
162
|
+
var _a;
|
|
163
|
+
(_a = s.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(s);
|
|
164
|
+
s.close(() => resolve());
|
|
165
|
+
})));
|
|
166
|
+
}));
|
|
167
|
+
/** A server on 127.0.0.1 answering every request with `body`, and its port. */
|
|
168
|
+
function server() {
|
|
169
|
+
return __awaiter(this, arguments, void 0, function* (body = {}, handler) {
|
|
170
|
+
const asked = [];
|
|
171
|
+
const s = http_1.default.createServer(handler !== null && handler !== void 0 ? handler : ((req, res) => {
|
|
172
|
+
asked.push(`${req.method} ${req.url}`);
|
|
173
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
174
|
+
res.end(JSON.stringify(body));
|
|
175
|
+
}));
|
|
176
|
+
servers.push(s);
|
|
177
|
+
yield new Promise((resolve) => s.listen(0, '127.0.0.1', () => resolve()));
|
|
178
|
+
return { port: s.address().port, asked, server: s };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
it('a command forwarded to a node, with the proxy credentials', () => __awaiter(this, void 0, void 0, function* () {
|
|
182
|
+
const { port } = yield server();
|
|
183
|
+
yield (0, forwardToNode_1.readAnswer)(yield (0, forwardToNode_1.sendToNode)({
|
|
184
|
+
url: `http://node.test:${port}/wd/session/s/url`,
|
|
185
|
+
method: 'GET',
|
|
186
|
+
headers: {},
|
|
187
|
+
}));
|
|
188
|
+
(0, chai_1.expect)(proxy.seen).to.include(`GET http://node.test:${port}/wd/session/s/url`);
|
|
189
|
+
(0, chai_1.expect)(proxy.authorizations[0]).to.equal(`Basic ${Buffer.from('lab:s3cret').toString('base64')}`);
|
|
190
|
+
}));
|
|
191
|
+
it("a remote session's own calls (its screenshot)", () => __awaiter(this, void 0, void 0, function* () {
|
|
192
|
+
const { port } = yield server({ value: 'iVBOR' });
|
|
193
|
+
const session = new RemoteSession_1.RemoteSession({
|
|
194
|
+
sessionId: 's',
|
|
195
|
+
device: { udid: 'p', host: `http://node.test:${port}` },
|
|
196
|
+
sessionResponse: {},
|
|
197
|
+
xenonOption: {},
|
|
198
|
+
baseUrl: `http://node.test:${port}/wd`,
|
|
199
|
+
});
|
|
200
|
+
(0, chai_1.expect)(yield session.getScreenShot()).to.equal('iVBOR');
|
|
201
|
+
(0, chai_1.expect)(proxy.seen).to.include(`GET http://node.test:${port}/wd/session/s/screenshot`);
|
|
202
|
+
}));
|
|
203
|
+
it("the lookup of a node's base path", () => __awaiter(this, void 0, void 0, function* () {
|
|
204
|
+
const { port } = yield server({ basePath: '/wd' });
|
|
205
|
+
(0, chai_1.expect)(yield (0, nodeWebDriverUrl_1.nodeWebDriverUrl)({ udid: 'p', host: `http://node.test:${port}` }, '')).to.equal(`http://node.test:${port}/wd`);
|
|
206
|
+
(0, chai_1.expect)(proxy.seen).to.include(`GET http://node.test:${port}/xenon/api/webdriver`);
|
|
207
|
+
}));
|
|
208
|
+
it('the create, as before', () => __awaiter(this, void 0, void 0, function* () {
|
|
209
|
+
const { port, asked } = yield server(undefined, (req, res) => {
|
|
210
|
+
asked.push(`${req.method} ${req.url}`);
|
|
211
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
212
|
+
res.end(req.url === '/xenon/api/webdriver'
|
|
213
|
+
? JSON.stringify({ basePath: '' })
|
|
214
|
+
: JSON.stringify({ value: { sessionId: 'made', capabilities: {} } }));
|
|
215
|
+
});
|
|
216
|
+
yield typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService).forwardSessionRequest({ udid: 'p', host: `http://node.test:${port}` }, { alwaysMatch: {}, firstMatch: [{}] });
|
|
217
|
+
(0, chai_1.expect)(proxy.seen).to.include(`POST http://node.test:${port}/session`);
|
|
218
|
+
}));
|
|
219
|
+
it('the create, straight to a node NO_PROXY names', () => __awaiter(this, void 0, void 0, function* () {
|
|
220
|
+
// node.test resolves only at the proxy, so going straight there fails:
|
|
221
|
+
// what is tested is that the proxy is never asked.
|
|
222
|
+
process.env.NO_PROXY = 'node.test';
|
|
223
|
+
const created = yield typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService).forwardSessionRequest({ udid: 'p', host: 'http://node.test:9' }, { alwaysMatch: {}, firstMatch: [{}] });
|
|
224
|
+
(0, chai_1.expect)(created).to.be.instanceOf(Error);
|
|
225
|
+
(0, chai_1.expect)(proxy.seen).to.deep.equal([]);
|
|
226
|
+
}));
|
|
227
|
+
it("Xenon's other internal calls (a health probe)", () => __awaiter(this, void 0, void 0, function* () {
|
|
228
|
+
const { port } = yield server({ ok: true });
|
|
229
|
+
(0, chai_1.expect)(yield (0, helpers_1.isXenonRunning)(`http://node.test:${port}`)).to.equal(true);
|
|
230
|
+
(0, chai_1.expect)(proxy.seen).to.include(`GET http://node.test:${port}/xenon/api/health`);
|
|
231
|
+
}));
|
|
232
|
+
it("the live-preview and logcat sockets to a node's phone", () => __awaiter(this, void 0, void 0, function* () {
|
|
233
|
+
const { port, server: s } = yield server({ ticket: 'node-ticket', expiresIn: 60 });
|
|
234
|
+
const wss = new ws_1.WebSocketServer({ server: s });
|
|
235
|
+
sockets.push(wss);
|
|
236
|
+
const socket = yield (0, nodeSocketRelay_1.openNodeSocket)({ udid: 'p', actor: { actorId: 'alice' }, path: 'logcat' }, {
|
|
237
|
+
findDevice: () => __awaiter(this, void 0, void 0, function* () {
|
|
238
|
+
return ({
|
|
239
|
+
udid: 'p',
|
|
240
|
+
host: `http://node.test:${port}`,
|
|
241
|
+
nodeId: 'node-1',
|
|
242
|
+
});
|
|
243
|
+
}),
|
|
244
|
+
controlToken: () => __awaiter(this, void 0, void 0, function* () { return null; }),
|
|
245
|
+
});
|
|
246
|
+
socket.terminate();
|
|
247
|
+
(0, chai_1.expect)(proxy.seen).to.include(`CONNECT node.test:${port}`);
|
|
248
|
+
}));
|
|
249
|
+
it("a node fetching its hub's keys", () => __awaiter(this, void 0, void 0, function* () {
|
|
250
|
+
const { port } = yield server({ keys: [] });
|
|
251
|
+
const token = [
|
|
252
|
+
Buffer.from(JSON.stringify({ alg: 'RS256', kid: 'k1' })).toString('base64url'),
|
|
253
|
+
Buffer.from(JSON.stringify({ sid: 's' })).toString('base64url'),
|
|
254
|
+
'c2lnbmF0dXJl',
|
|
255
|
+
].join('.');
|
|
256
|
+
yield new hubSessionToken_1.HubSessionTokenVerifier(`http://hub.test:${port}`)
|
|
257
|
+
.verify(token, 's')
|
|
258
|
+
.catch(() => undefined);
|
|
259
|
+
(0, chai_1.expect)(proxy.seen).to.include(`GET http://hub.test:${port}/xenon/api/auth/jwks.json`);
|
|
260
|
+
}));
|
|
261
|
+
it("a node's live-events connection to its hub", () => __awaiter(this, void 0, void 0, function* () {
|
|
262
|
+
var _a;
|
|
263
|
+
const { port, server: s } = yield server();
|
|
264
|
+
ios.push(new socket_io_1.Server(s));
|
|
265
|
+
const client = new SocketClient_1.SocketClient();
|
|
266
|
+
try {
|
|
267
|
+
client.initialize(`http://hub.test:${port}`, 'http://node.lab:4723');
|
|
268
|
+
const deadline = Date.now() + 10000;
|
|
269
|
+
while (!client.isConnected()) {
|
|
270
|
+
if (Date.now() > deadline)
|
|
271
|
+
throw new Error('the node never connected to its hub');
|
|
272
|
+
yield new Promise((r) => setTimeout(r, 25));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
(_a = client.socket) === null || _a === void 0 ? void 0 : _a.close();
|
|
277
|
+
}
|
|
278
|
+
(0, chai_1.expect)(proxy.seen.some((l) => l.startsWith(`GET http://hub.test:${port}/socket.io/`)), proxy.seen.join('\n')).to.equal(true);
|
|
279
|
+
}));
|
|
280
|
+
it("but not a session's call to this server, which carries its secret", () => __awaiter(this, void 0, void 0, function* () {
|
|
281
|
+
// Appium bound to a LAN address: the /wd-internal URL isn't a loopback
|
|
282
|
+
// name, so only the session's own `proxy: false` keeps it direct.
|
|
283
|
+
const session = new LocalSession_1.LocalSession({
|
|
284
|
+
sessionId: 's-1',
|
|
285
|
+
device: { udid: 'p' },
|
|
286
|
+
sessionResponse: {},
|
|
287
|
+
xenonOption: {},
|
|
288
|
+
driver: { opts: { address: 'node.test', port: 9, basePath: '/wd/hub' } },
|
|
289
|
+
});
|
|
290
|
+
yield session.getPageSource().catch(() => undefined);
|
|
291
|
+
(0, chai_1.expect)(proxy.seen).to.deep.equal([]);
|
|
292
|
+
}));
|
|
293
|
+
it('but not a call to this machine', () => __awaiter(this, void 0, void 0, function* () {
|
|
294
|
+
const { port, asked } = yield server({ ok: true });
|
|
295
|
+
(0, chai_1.expect)(yield (0, helpers_1.isXenonRunning)(`http://127.0.0.1:${port}`)).to.equal(true);
|
|
296
|
+
(0, chai_1.expect)(proxy.seen).to.deep.equal([]);
|
|
297
|
+
(0, chai_1.expect)(asked).to.include('GET /xenon/api/health');
|
|
298
|
+
}));
|
|
299
|
+
});
|
|
300
|
+
describe('TLS to the server behind it', () => {
|
|
301
|
+
let proxy;
|
|
302
|
+
let context;
|
|
303
|
+
let savedContext;
|
|
304
|
+
let tlsServer;
|
|
305
|
+
let port;
|
|
306
|
+
before(function () {
|
|
307
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
308
|
+
this.timeout(60000);
|
|
309
|
+
tlsServer = https_1.default.createServer(selfSignedCertificate('node.test'), (_req, res) => {
|
|
310
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
311
|
+
res.end('{"ok":true}');
|
|
312
|
+
});
|
|
313
|
+
yield new Promise((resolve) => tlsServer.listen(0, '127.0.0.1', () => resolve()));
|
|
314
|
+
port = tlsServer.address().port;
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
after(() => __awaiter(this, void 0, void 0, function* () {
|
|
318
|
+
yield new Promise((resolve) => tlsServer.close(() => resolve()));
|
|
319
|
+
}));
|
|
320
|
+
beforeEach(() => __awaiter(this, void 0, void 0, function* () {
|
|
321
|
+
proxy = yield (0, fake_http_proxy_1.startFakeProxy)({ hosts: { 'node.test': '127.0.0.1' } });
|
|
322
|
+
context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
323
|
+
savedContext = Object.assign({}, context);
|
|
324
|
+
const [, proxyPort] = proxy.url.replace('http://', '').split(':');
|
|
325
|
+
context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { proxy: { host: '127.0.0.1', port: Number(proxyPort) } }), 4723, 'hub-1', '');
|
|
326
|
+
}));
|
|
327
|
+
afterEach(() => __awaiter(this, void 0, void 0, function* () {
|
|
328
|
+
Object.assign(context, savedContext);
|
|
329
|
+
yield proxy.close();
|
|
330
|
+
}));
|
|
331
|
+
it('accepts a self-signed certificate when tlsRejectUnauthorized is false', () => __awaiter(this, void 0, void 0, function* () {
|
|
332
|
+
const answer = yield InternalHttpClient_1.InternalHttpClient.getClient(false).get(`https://node.test:${port}/x`);
|
|
333
|
+
(0, chai_1.expect)(answer.data).to.deep.equal({ ok: true });
|
|
334
|
+
(0, chai_1.expect)(proxy.seen).to.include(`CONNECT node.test:${port}`);
|
|
335
|
+
}));
|
|
336
|
+
it('refuses it otherwise', () => __awaiter(this, void 0, void 0, function* () {
|
|
337
|
+
const outcome = yield InternalHttpClient_1.InternalHttpClient.getClient(true)
|
|
338
|
+
.get(`https://node.test:${port}/x`)
|
|
339
|
+
.then(() => 'accepted', (err) => { var _a; return String((_a = err === null || err === void 0 ? void 0 : err.code) !== null && _a !== void 0 ? _a : err === null || err === void 0 ? void 0 : err.message); });
|
|
340
|
+
(0, chai_1.expect)(outcome).to.not.equal('accepted');
|
|
341
|
+
(0, chai_1.expect)(proxy.seen).to.include(`CONNECT node.test:${port}`);
|
|
342
|
+
}));
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
/** A certificate for `host`, signed by its own key: one no client trusts. */
|
|
346
|
+
function selfSignedCertificate(host) {
|
|
347
|
+
const keys = node_forge_1.pki.rsa.generateKeyPair(2048);
|
|
348
|
+
const cert = node_forge_1.pki.createCertificate();
|
|
349
|
+
cert.publicKey = keys.publicKey;
|
|
350
|
+
cert.serialNumber = '01';
|
|
351
|
+
cert.validity.notBefore = new Date(Date.now() - 60000);
|
|
352
|
+
cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
353
|
+
const subject = [{ name: 'commonName', value: host }];
|
|
354
|
+
cert.setSubject(subject);
|
|
355
|
+
cert.setIssuer(subject);
|
|
356
|
+
cert.sign(keys.privateKey, node_forge_1.md.sha256.create());
|
|
357
|
+
return { cert: node_forge_1.pki.certificateToPem(cert), key: node_forge_1.pki.privateKeyToPem(keys.privateKey) };
|
|
358
|
+
}
|
|
@@ -126,7 +126,7 @@ describe('createSession records who asked on the pending-session row', () => {
|
|
|
126
126
|
typedi_1.Container.set(JwtKeyService_1.JwtKeyService, {
|
|
127
127
|
verify: (t) => __awaiter(void 0, void 0, void 0, function* () {
|
|
128
128
|
const [sub, teamId] = t.replace(/^jwt:/, '').split('@');
|
|
129
|
-
return { sub, teamId: teamId !== null && teamId !== void 0 ? teamId : null };
|
|
129
|
+
return { sub, teamId: teamId !== null && teamId !== void 0 ? teamId : null, scopes: 'sessions' };
|
|
130
130
|
}),
|
|
131
131
|
});
|
|
132
132
|
teamRows = sinon_1.default.stub(prisma_1.prisma.teamMember, 'findMany').resolves([]);
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
require("reflect-metadata");
|
|
16
|
+
const chai_1 = require("chai");
|
|
17
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
18
|
+
const express_1 = __importDefault(require("express"));
|
|
19
|
+
const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
|
|
20
|
+
const typedi_1 = require("typedi");
|
|
21
|
+
const users_1 = require("../../src/app/routers/users");
|
|
22
|
+
const auth_1 = require("../../src/app/routers/auth");
|
|
23
|
+
const PasswordResetService_1 = require("../../src/services/PasswordResetService");
|
|
24
|
+
const EmailService_1 = require("../../src/services/EmailService");
|
|
25
|
+
const UserService_1 = require("../../src/services/UserService");
|
|
26
|
+
const prisma_1 = require("../../src/prisma");
|
|
27
|
+
const config_1 = require("../../src/config");
|
|
28
|
+
const passwordResetMode_1 = require("../../src/services/passwordResetMode");
|
|
29
|
+
const passwordResetLink_1 = require("../../src/services/passwordResetLink");
|
|
30
|
+
/**
|
|
31
|
+
* Where a password reset link that Xenon sends points.
|
|
32
|
+
*
|
|
33
|
+
* Through 2.14 it was built from the Host header of the request that asked
|
|
34
|
+
* for it. Anyone could ask for a reset of someone else's account with a Host
|
|
35
|
+
* of their own choosing (a matching Origin passes the CSRF check), and with
|
|
36
|
+
* SMTP set up Xenon emailed the victim a genuine link to the attacker's host:
|
|
37
|
+
* opening it handed the attacker the token, which opens the account.
|
|
38
|
+
*
|
|
39
|
+
* A link Xenon emails or logs is now built from XENON_PUBLIC_URL, the address
|
|
40
|
+
* people open the dashboard at. Without it Xenon sends no link at all: the
|
|
41
|
+
* sign-in page sends people to an administrator, and an administrator's
|
|
42
|
+
* "send reset link" hands the link back to them instead of emailing it.
|
|
43
|
+
*/
|
|
44
|
+
const RAW = 'RAWTOKEN_abcdefghijklmnopqrstuvwxyz0123456789';
|
|
45
|
+
const EVIL = 'attacker.example';
|
|
46
|
+
function withConfig(patch) {
|
|
47
|
+
const orig = {};
|
|
48
|
+
for (const k of Object.keys(patch)) {
|
|
49
|
+
orig[k] = config_1.config[k];
|
|
50
|
+
config_1.config[k] = patch[k];
|
|
51
|
+
}
|
|
52
|
+
return () => Object.assign(config_1.config, orig);
|
|
53
|
+
}
|
|
54
|
+
function until(pred_1) {
|
|
55
|
+
return __awaiter(this, arguments, void 0, function* (pred, ms = 3000) {
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
while (!pred()) {
|
|
58
|
+
if (Date.now() - t0 > ms)
|
|
59
|
+
throw new Error('timed out waiting for the route to finish');
|
|
60
|
+
yield new Promise((r) => setTimeout(r, 5));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
describe('password reset links point at XENON_PUBLIC_URL, never the request', () => {
|
|
65
|
+
let restore;
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
sinon_1.default.restore();
|
|
68
|
+
restore === null || restore === void 0 ? void 0 : restore();
|
|
69
|
+
restore = undefined;
|
|
70
|
+
});
|
|
71
|
+
function publicApp() {
|
|
72
|
+
const app = (0, express_1.default)();
|
|
73
|
+
app.use(express_1.default.json());
|
|
74
|
+
app.use('/auth', (0, auth_1.authPublicRouter)());
|
|
75
|
+
return app;
|
|
76
|
+
}
|
|
77
|
+
function usersApp() {
|
|
78
|
+
const app = (0, express_1.default)();
|
|
79
|
+
app.use(express_1.default.json());
|
|
80
|
+
app.use((req, _res, next) => {
|
|
81
|
+
req.auth = {
|
|
82
|
+
userId: 'admin1',
|
|
83
|
+
role: 'ADMIN',
|
|
84
|
+
scopes: 'admin,devices,sessions,read',
|
|
85
|
+
};
|
|
86
|
+
next();
|
|
87
|
+
});
|
|
88
|
+
app.use('/users', (0, users_1.usersRouter)());
|
|
89
|
+
return app;
|
|
90
|
+
}
|
|
91
|
+
const active = { id: 'u1', email: 'u@x.local', name: 'U', role: 'MEMBER', status: 'ACTIVE' };
|
|
92
|
+
describe('POST /auth/forgot-password', () => {
|
|
93
|
+
it('emails a link to XENON_PUBLIC_URL whatever Host the request names', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
+
restore = withConfig({
|
|
95
|
+
smtpUrl: 'smtp://mail.example.com:587',
|
|
96
|
+
publicUrl: 'https://xenon.example.com',
|
|
97
|
+
});
|
|
98
|
+
sinon_1.default.stub(typedi_1.Container.get(UserService_1.UserService), 'findByEmail').resolves(active);
|
|
99
|
+
sinon_1.default
|
|
100
|
+
.stub(typedi_1.Container.get(PasswordResetService_1.PasswordResetService), 'createToken')
|
|
101
|
+
.resolves({ raw: RAW, id: 't1' });
|
|
102
|
+
const send = sinon_1.default.stub(typedi_1.Container.get(EmailService_1.EmailService), 'send').resolves();
|
|
103
|
+
yield (0, loopbackRequest_1.default)(publicApp())
|
|
104
|
+
.post('/auth/forgot-password')
|
|
105
|
+
.set('Host', EVIL)
|
|
106
|
+
.set('X-Forwarded-Proto', 'http')
|
|
107
|
+
.send({ email: 'u@x.local' });
|
|
108
|
+
yield until(() => send.called);
|
|
109
|
+
const text = send.firstCall.args[0].text;
|
|
110
|
+
(0, chai_1.expect)(text).to.include(`https://xenon.example.com/xenon/reset-password#${RAW}`);
|
|
111
|
+
(0, chai_1.expect)(text).to.not.include(EVIL);
|
|
112
|
+
}));
|
|
113
|
+
it("builds a link that opens when XENON_PUBLIC_URL is the dashboard's address", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
114
|
+
restore = withConfig({
|
|
115
|
+
smtpUrl: 'smtp://mail.example.com:587',
|
|
116
|
+
publicUrl: 'http://localhost:4723/xenon/',
|
|
117
|
+
});
|
|
118
|
+
sinon_1.default.stub(typedi_1.Container.get(UserService_1.UserService), 'findByEmail').resolves(active);
|
|
119
|
+
sinon_1.default
|
|
120
|
+
.stub(typedi_1.Container.get(PasswordResetService_1.PasswordResetService), 'createToken')
|
|
121
|
+
.resolves({ raw: RAW, id: 't1' });
|
|
122
|
+
const send = sinon_1.default.stub(typedi_1.Container.get(EmailService_1.EmailService), 'send').resolves();
|
|
123
|
+
yield (0, loopbackRequest_1.default)(publicApp()).post('/auth/forgot-password').send({ email: 'u@x.local' });
|
|
124
|
+
yield until(() => send.called);
|
|
125
|
+
(0, chai_1.expect)(send.firstCall.args[0].text).to.include(`http://localhost:4723/xenon/reset-password#${RAW}`);
|
|
126
|
+
(0, chai_1.expect)(send.firstCall.args[0].text).to.not.include('/xenon/xenon/');
|
|
127
|
+
}));
|
|
128
|
+
it('sends nothing, and mints nothing, when XENON_PUBLIC_URL is not set', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
129
|
+
restore = withConfig({ smtpUrl: 'smtp://mail.example.com:587', publicUrl: undefined });
|
|
130
|
+
const find = sinon_1.default.stub(typedi_1.Container.get(UserService_1.UserService), 'findByEmail').resolves(active);
|
|
131
|
+
const create = sinon_1.default.stub(typedi_1.Container.get(PasswordResetService_1.PasswordResetService), 'createToken');
|
|
132
|
+
const send = sinon_1.default.stub(typedi_1.Container.get(EmailService_1.EmailService), 'send').resolves();
|
|
133
|
+
const r = yield (0, loopbackRequest_1.default)(publicApp())
|
|
134
|
+
.post('/auth/forgot-password')
|
|
135
|
+
.set('Host', EVIL)
|
|
136
|
+
.send({ email: 'u@x.local' });
|
|
137
|
+
yield until(() => find.called);
|
|
138
|
+
yield new Promise((resolve) => setImmediate(resolve));
|
|
139
|
+
(0, chai_1.expect)(r.status).to.equal(204);
|
|
140
|
+
(0, chai_1.expect)(create.called).to.equal(false);
|
|
141
|
+
(0, chai_1.expect)(send.called).to.equal(false);
|
|
142
|
+
}));
|
|
143
|
+
it('sends nothing through the log fallback without XENON_PUBLIC_URL either', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
144
|
+
restore = withConfig({
|
|
145
|
+
smtpUrl: undefined,
|
|
146
|
+
passwordResetLogFallback: true,
|
|
147
|
+
publicUrl: undefined,
|
|
148
|
+
});
|
|
149
|
+
const find = sinon_1.default.stub(typedi_1.Container.get(UserService_1.UserService), 'findByEmail').resolves(active);
|
|
150
|
+
const create = sinon_1.default.stub(typedi_1.Container.get(PasswordResetService_1.PasswordResetService), 'createToken');
|
|
151
|
+
yield (0, loopbackRequest_1.default)(publicApp()).post('/auth/forgot-password').send({ email: 'u@x.local' });
|
|
152
|
+
yield until(() => find.called);
|
|
153
|
+
yield new Promise((resolve) => setImmediate(resolve));
|
|
154
|
+
(0, chai_1.expect)(create.called).to.equal(false);
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
describe('GET /auth/options tells the sign-in page whether self-service reset works', () => {
|
|
158
|
+
it("is 'admin' with SMTP but no XENON_PUBLIC_URL, 'email' with both", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
159
|
+
restore = withConfig({ smtpUrl: 'smtp://mail.example.com:587', publicUrl: undefined });
|
|
160
|
+
(0, chai_1.expect)((yield (0, loopbackRequest_1.default)(publicApp()).get('/auth/options')).body.passwordReset).to.equal('admin');
|
|
161
|
+
config_1.config.publicUrl = 'https://xenon.example.com';
|
|
162
|
+
(0, chai_1.expect)((yield (0, loopbackRequest_1.default)(publicApp()).get('/auth/options')).body.passwordReset).to.equal('email');
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
describe('POST /users/:id/reset-link', () => {
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
sinon_1.default.stub(prisma_1.prisma.user, 'findUnique').resolves(active);
|
|
168
|
+
sinon_1.default
|
|
169
|
+
.stub(typedi_1.Container.get(PasswordResetService_1.PasswordResetService), 'createToken')
|
|
170
|
+
.resolves({ raw: RAW, id: 't1' });
|
|
171
|
+
});
|
|
172
|
+
it('emails a link to XENON_PUBLIC_URL whatever Host the request names', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
173
|
+
restore = withConfig({
|
|
174
|
+
smtpUrl: 'smtp://mail.example.com:587',
|
|
175
|
+
publicUrl: 'https://xenon.example.com',
|
|
176
|
+
});
|
|
177
|
+
const send = sinon_1.default.stub(typedi_1.Container.get(EmailService_1.EmailService), 'send').resolves();
|
|
178
|
+
const r = yield (0, loopbackRequest_1.default)(usersApp()).post('/users/u1/reset-link').set('Host', EVIL);
|
|
179
|
+
(0, chai_1.expect)(r.body).to.include({ emailed: true });
|
|
180
|
+
(0, chai_1.expect)(send.firstCall.args[0].text).to.include(`https://xenon.example.com/xenon/reset-password#${RAW}`);
|
|
181
|
+
(0, chai_1.expect)(send.firstCall.args[0].text).to.not.include(EVIL);
|
|
182
|
+
}));
|
|
183
|
+
it('without XENON_PUBLIC_URL, hands the link to the admin instead of emailing it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
184
|
+
restore = withConfig({ smtpUrl: 'smtp://mail.example.com:587', publicUrl: undefined });
|
|
185
|
+
const send = sinon_1.default.stub(typedi_1.Container.get(EmailService_1.EmailService), 'send').resolves();
|
|
186
|
+
const r = yield (0, loopbackRequest_1.default)(usersApp()).post('/users/u1/reset-link');
|
|
187
|
+
(0, chai_1.expect)(r.status).to.equal(200);
|
|
188
|
+
(0, chai_1.expect)(r.body.emailed).to.equal(false);
|
|
189
|
+
(0, chai_1.expect)(r.body.link).to.match(new RegExp(`/xenon/reset-password#${RAW}$`));
|
|
190
|
+
(0, chai_1.expect)(send.called).to.equal(false);
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
describe('resetLinkBase (XENON_PUBLIC_URL)', () => {
|
|
194
|
+
// The variable is the server's address. The dashboard's own address,
|
|
195
|
+
// with /xenon (README gives http://localhost:4723/xenon/), is taken too:
|
|
196
|
+
// kept as given it made /xenon/xenon/reset-password, which the dashboard
|
|
197
|
+
// routes to the sign-in page, losing the token.
|
|
198
|
+
it('is the server address, with or without a trailing slash', () => {
|
|
199
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl: 'https://xenon.example.com' })).to.equal('https://xenon.example.com');
|
|
200
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl: 'https://xenon.example.com/' })).to.equal('https://xenon.example.com');
|
|
201
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl: 'http://lab-mac:4723' })).to.equal('http://lab-mac:4723');
|
|
202
|
+
});
|
|
203
|
+
for (const publicUrl of [
|
|
204
|
+
'http://localhost:4723/xenon',
|
|
205
|
+
'http://localhost:4723/xenon/',
|
|
206
|
+
'http://localhost:4723/xenon//',
|
|
207
|
+
]) {
|
|
208
|
+
it(`takes the dashboard's address too: ${publicUrl}`, () => {
|
|
209
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl })).to.equal('http://localhost:4723');
|
|
210
|
+
(0, chai_1.expect)((0, passwordResetLink_1.buildResetLink)(String((0, passwordResetLink_1.resetLinkBase)({ publicUrl })), 'TOK')).to.equal('http://localhost:4723/xenon/reset-password#TOK');
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// The dashboard loads its pages, scripts and API from /xenon on the
|
|
214
|
+
// server's root, so it can't be served under another prefix: an address
|
|
215
|
+
// with any other path is a mistake, and no link is sent rather than one
|
|
216
|
+
// that can't open.
|
|
217
|
+
it('is null for an address with any other path', () => {
|
|
218
|
+
for (const publicUrl of [
|
|
219
|
+
'https://lab.example.com/qa/',
|
|
220
|
+
'https://lab.example.com/qa/xenon',
|
|
221
|
+
'http://localhost:4723/xenon/login',
|
|
222
|
+
]) {
|
|
223
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl }), publicUrl).to.equal(null);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
it('is null when unset, or not an http(s) address', () => {
|
|
227
|
+
for (const publicUrl of [
|
|
228
|
+
undefined,
|
|
229
|
+
'',
|
|
230
|
+
' ',
|
|
231
|
+
'not a url',
|
|
232
|
+
'ftp://x.example',
|
|
233
|
+
'javascript:alert(1)',
|
|
234
|
+
]) {
|
|
235
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl }), String(publicUrl)).to.equal(null);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
it('is null for an address with a query or fragment, which would swallow the token', () => {
|
|
239
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl: 'https://x.example/?a=1' })).to.equal(null);
|
|
240
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetLinkBase)({ publicUrl: 'https://x.example/#a' })).to.equal(null);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('passwordResetMode', () => {
|
|
244
|
+
it("is 'email' only with both SMTP and XENON_PUBLIC_URL", () => {
|
|
245
|
+
(0, chai_1.expect)((0, passwordResetMode_1.passwordResetMode)({ smtpUrl: 'smtp://m:587', publicUrl: 'https://x.example' })).to.equal('email');
|
|
246
|
+
(0, chai_1.expect)((0, passwordResetMode_1.passwordResetMode)({ smtpUrl: 'smtp://m:587' })).to.equal('admin');
|
|
247
|
+
(0, chai_1.expect)((0, passwordResetMode_1.passwordResetMode)({ publicUrl: 'https://x.example' })).to.equal('admin');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
// The email said "1 hour" whatever XENON_RESET_TOKEN_TTL_MS was.
|
|
251
|
+
describe('the email says how long the link really lasts', () => {
|
|
252
|
+
const user = { email: 'u@x.local', name: 'U' };
|
|
253
|
+
for (const [ms, words] of [
|
|
254
|
+
[60 * 60 * 1000, '1 hour'],
|
|
255
|
+
[30 * 60 * 1000, '30 minutes'],
|
|
256
|
+
[2 * 60 * 60 * 1000, '2 hours'],
|
|
257
|
+
[90 * 60 * 1000, '90 minutes'],
|
|
258
|
+
[24 * 60 * 60 * 1000, '24 hours'],
|
|
259
|
+
[60 * 1000, '1 minute'],
|
|
260
|
+
]) {
|
|
261
|
+
it(`${words} for ${ms} ms`, () => {
|
|
262
|
+
(0, chai_1.expect)((0, passwordResetLink_1.resetEmail)(user, 'https://x/l', ms).text).to.include(`This link expires in ${words}.`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|