From 44eb76bed71675ad1d756f5d8193c2cb7ef5e55f Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 10 Dec 2025 13:07:28 +0200 Subject: [PATCH] feat(iOS): Add Microsoft Intune MAM integration with multi-server support (#9312) * refactor: implement custom ExpoImage wrapper for cache control Add ExpoImage component with automatic cacheKey/cachePath management and replace all expo-image imports across the app * refactor(ios): convert Gekidou to CocoaPods Migrate from Swift Package Manager to CocoaPods, add Keychain write operations, refactor notification handler to remove react-native-notifications headers, and upgrade Swift to 5.0 * npm audit * update fastlane * feat(ci): integrate Intune MAM for enterprise builds with strict OSS protection Add Intune submodule, CI actions, Fastlane configuration, developer scripts, pre-commit hooks, and validation workflows to enable internal MAM builds while protecting OSS repository * fix tests by mocking @mattermost/intune * feat: implement Intune MAM integration with comprehensive security enforcement Add IntuneManager, refactor SecurityManager/SessionManager for MAM policies, implement native OIDC auth flow, add biometric enforcement, conditional launch blocking, and file protection controls * fix alerts when no server database is present * Unify cache strategy * fix emit config changed after it was stored in the db * Handle Mid-Session Enrollment Detection * fix ADALLogOverrideDisabled missing in Fastfile * fix flow for initial enrollment * fix and add unit tests * enable Intune configuration for PR and beta builds, CLIENT_ID should be changed before actual release * Update intune submodule with addressed feedback * fix validate-intune-clean workflow * feat(intune): add comprehensive error handling and SAML+Entra support Add production-ready error handling for native Entra authentication with user-friendly i18n messages, comprehensive test coverage, and support for Entra login when server requires SAML. * update i18n * update intune submodule * update build-pr token * fix race condition between server auth and intune enrollment * fix CI workflow to build with intune * use deploy key for intune submodule * set the config directly in the submodule .git * debug injection * try setting GIT_SSH_COMMAND * remove action debug * fix server url input * match pod cache with intune hash * Fastfile and envs * have workflows check for intune/.git * have ci cache intune frameworks as well * update Fastlane to set no-cache to artifacts uploaded * fix s3 upload * fix pblist template * Attempt to remove the cache control for PR uploads to s3 * use hash from commit for S3 path * Implement crash-resilient selective wipe with automatic retry and add removeInternetPassword to Gekidou Keychain * Fix surface errors from intune login * fix postinstall scripts * use cacheKey for draft md images * remove unnecessary double await during test * Have isMinimumLicenseTier accept valid license sku tier as target * Add missing Auth error messages * remove the last period for intune errors in i18n * do not call unenroll with wipe on manual logout * Fix tests and Intune error messages * do not filter any SSO type regardless of which is used for Intune * fix 412 to not retry * fix tests, app logs sharing and share_extension avatar cache * apply setScreenCapturePolicy on license change Co-authored-by: Eva Sarafianou * re-apply screen capture on enrollment Co-authored-by: Eva Sarafianou * use userData from intunr login and prevent getMe Co-authored-by: Eva Sarafianou * Check for Biometrics and Jailbreak as we used to --------- Co-authored-by: Eva Sarafianou --- .github/actions/prepare-ios-build/action.yaml | 51 +- .../actions/setup-intune-ssh-key/action.yaml | 27 + .github/actions/setup-intune/action.yaml | 60 ++ .github/actions/setup-ssh-key/action.yaml | 23 + .github/workflows/build-ios-beta.yml | 23 +- .github/workflows/build-ios-release.yml | 25 +- .github/workflows/build-pr.yml | 20 +- .github/workflows/e2e-detox-pr.yml | 8 +- .github/workflows/e2e-detox-release.yml | 8 +- .github/workflows/e2e-detox-scheduled.yml | 8 +- .github/workflows/e2e-ios-template.yml | 2 +- .github/workflows/github-release.yml | 20 +- .github/workflows/validate-intune-clean.yml | 145 +++ .gitignore | 7 + .gitmodules | 11 + app/actions/app/server.test.ts | 104 +- app/actions/app/server.ts | 47 +- app/actions/local/draft.ts | 67 +- app/actions/local/session/index.test.ts | 346 +++++++ app/actions/local/session/index.ts | 190 ++++ app/actions/local/systems.ts | 7 +- app/actions/local/user.test.ts | 37 + app/actions/local/user.ts | 11 + app/actions/remote/custom_emoji.test.ts | 18 + app/actions/remote/entry/login.ts | 5 +- app/actions/remote/session.test.ts | 172 +++- app/actions/remote/session.ts | 194 ++-- app/client/rest/users.test.ts | 31 + app/client/rest/users.ts | 22 +- .../slash_suggestion_item.tsx | 12 +- .../channel_bookmark/bookmark_icon.tsx | 7 +- .../channel_bookmark/bookmark_options.tsx | 1 + .../channel_bookmarks/channel_bookmarks.tsx | 6 +- .../profile_avatar.tsx | 6 +- app/components/emoji/emoji.tsx | 74 +- app/components/expo_image/index.tsx | 130 +++ app/components/files/files.test.tsx | 2 + app/components/files/files.tsx | 2 +- app/components/files/video_file.tsx | 4 +- .../floating_text_input_label.tsx | 1 + .../markdown/markdown_image/index.tsx | 9 +- .../markdown/markdown_table_image/index.tsx | 17 +- app/components/option_item/option_icon.tsx | 6 +- .../send_button/scheduled_post_tooltip.tsx | 5 +- app/components/post_draft/uploads/index.tsx | 2 +- .../post_list/post/avatar/avatar.tsx | 5 +- .../content/image_preview/image_preview.tsx | 12 +- .../attachment_author.test.tsx.snap | 4 + .../attachment_footer.test.tsx.snap | 2 + .../message_attachments/attachment_author.tsx | 6 +- .../message_attachments/attachment_footer.tsx | 6 +- .../attachment_image/index.tsx | 12 +- .../attachment_thumbnail.tsx | 7 +- .../opengraph/opengraph_image/index.tsx | 21 +- .../post/body/content/youtube/index.tsx | 7 +- app/components/profile_picture/image.tsx | 23 +- app/components/progressive_image/index.tsx | 17 +- app/components/slide_up_panel_item/index.tsx | 7 +- .../team_list/team_item/team_icon.tsx | 5 +- app/components/upload_item_shared/index.tsx | 7 +- .../__snapshots__/index.test.tsx.snap | 8 + app/components/user_list/index.test.tsx | 12 +- app/constants/database.ts | 1 + app/constants/device.ts | 3 - app/constants/events.ts | 2 + app/database/manager/__mocks__/index.ts | 6 +- app/database/manager/index.ts | 6 +- app/hooks/files.ts | 2 +- app/init/app.ts | 2 + app/managers/intune_manager/index.test.ts | 387 ++++++++ app/managers/intune_manager/index.ts | 357 +++++++ app/managers/intune_manager/types.ts | 148 +++ .../security_manager/event_handlers.test.ts | 835 ++++++++++++++++ app/managers/security_manager/index.test.ts | 572 ++++++----- app/managers/security_manager/index.ts | 917 +++++++++++++----- app/managers/security_manager/mam.test.ts | 717 ++++++++++++++ app/managers/session_manager.test.ts | 123 ++- app/managers/session_manager.ts | 179 ++-- .../screens/post_update/post_update.test.tsx | 7 +- app/queries/app/servers.test.ts | 3 +- app/queries/app/servers.ts | 1 + app/queries/servers/system.test.ts | 4 +- app/queries/servers/system.ts | 16 +- .../intro/direct_channel/group/group.tsx | 13 +- .../title/group_message/avatars/avatars.tsx | 7 +- .../picker/sections/emoji_image.tsx | 7 +- app/screens/gallery/footer/actions/index.tsx | 8 +- app/screens/gallery/footer/avatar/index.tsx | 13 +- app/screens/gallery/footer/footer.tsx | 13 + app/screens/gallery/gallery.tsx | 33 +- .../gallery/lightbox_swipeout/index.tsx | 5 +- .../gallery/lightbox_swipeout/lightbox.tsx | 18 +- .../gallery/renderers/document/document.tsx | 14 +- app/screens/gallery/renderers/image/index.tsx | 1 + .../gallery/renderers/image/transformer.tsx | 15 +- app/screens/gallery/renderers/video/error.tsx | 8 +- .../renderers/video/video_renderer.tsx | 4 +- .../scheduled_post_empty_component.tsx | 5 +- .../draft_scheduled_post_tooltip.tsx | 5 +- app/screens/home/index.tsx | 6 +- app/screens/in_app_notification/icon.tsx | 15 +- app/screens/login/index.tsx | 3 + app/screens/login/sso_options.tsx | 21 +- app/screens/report_a_problem/app_logs.tsx | 9 +- app/screens/server/index.tsx | 6 + app/screens/sso/components/auth_error.tsx | 2 +- app/screens/sso/index.tsx | 66 +- app/screens/sso/sso_intune.tsx | 68 ++ app/screens/user_profile/title/avatar.tsx | 12 +- app/screens/user_profile/title/index.tsx | 10 + app/utils/alerts/index.test.ts | 600 ++++++++++++ app/utils/alerts/index.ts | 469 +++++++++ app/utils/draft/index.ts | 2 +- app/utils/emoji/prefetch.test.ts | 29 +- app/utils/emoji/prefetch.ts | 23 +- app/utils/file/index.test.ts | 8 +- app/utils/file/index.ts | 9 +- app/utils/files.tsx | 2 +- app/utils/gallery/index.test.ts | 19 +- app/utils/gallery/index.ts | 14 +- app/utils/helpers.test.ts | 92 ++ app/utils/helpers.ts | 28 + app/utils/intune_errors.ts | 80 ++ app/utils/security.test.ts | 113 ++- app/utils/security.ts | 24 +- app/utils/server/index.test.ts | 1 + assets/base/i18n/en.json | 25 +- fastlane/.env.ios.beta | 5 +- fastlane/.env.ios.pr | 5 +- fastlane/Fastfile | 250 +++-- fastlane/Gemfile.lock | 62 +- fastlane/env_vars_example | 4 + .../xcshareddata/xcschemes/Gekidou.xcscheme | 67 -- ios/Gekidou/Gekidou.podspec | 23 + ios/Gekidou/Package.resolved | 16 - ios/Gekidou/Package.swift | 34 - ios/Gekidou/Sources/Gekidou/Keychain.swift | 111 ++- ios/GekidouWrapper.swift | 2 +- ios/Mattermost.xcodeproj/project.pbxproj | 83 +- .../xcschemes/Mattermost.xcscheme | 2 +- .../xcshareddata/swiftpm/Package.resolved | 97 -- ios/Mattermost/AppDelegate.mm | 44 +- ...tificationEventHandler+HandleReplyAction.h | 16 +- ...tificationEventHandler+HandleReplyAction.m | 267 ++--- ios/Podfile | 84 +- ios/Podfile.lock | 146 ++- ios/PrivacyInfo.xcprivacy | 1 + jest.config.js | 2 +- libraries/@mattermost/intune | 1 + package-lock.json | 65 +- package.json | 11 +- .../@mattermost+react-native-emm+1.6.2.patch | 18 + patches/expo-image+2.0.7.patch | 615 +++++++++++- patches/expo-video-thumbnails+9.0.3.patch | 109 ++- patches/react-native-navigation+7.45.0.patch | 17 +- scripts/intune-disable.sh | 47 + scripts/intune-init.sh | 51 + scripts/intune-status.sh | 60 ++ scripts/postinstall.sh | 5 +- scripts/pre-commit.sh | 118 +++ .../components/channel_item/avatar/avatar.tsx | 6 +- test/setup.ts | 115 ++- types/api/config.d.ts | 3 + types/screens/gallery.ts | 1 + types/security_manager/index.d.ts | 9 + 165 files changed, 9015 insertions(+), 1904 deletions(-) create mode 100644 .github/actions/setup-intune-ssh-key/action.yaml create mode 100644 .github/actions/setup-intune/action.yaml create mode 100644 .github/actions/setup-ssh-key/action.yaml create mode 100644 .github/workflows/validate-intune-clean.yml create mode 100644 .gitmodules create mode 100644 app/actions/local/session/index.test.ts create mode 100644 app/actions/local/session/index.ts create mode 100644 app/components/expo_image/index.tsx create mode 100644 app/managers/intune_manager/index.test.ts create mode 100644 app/managers/intune_manager/index.ts create mode 100644 app/managers/intune_manager/types.ts create mode 100644 app/managers/security_manager/event_handlers.test.ts create mode 100644 app/managers/security_manager/mam.test.ts create mode 100644 app/screens/sso/sso_intune.tsx create mode 100644 app/utils/alerts/index.test.ts create mode 100644 app/utils/alerts/index.ts create mode 100644 app/utils/intune_errors.ts delete mode 100644 ios/Gekidou/.swiftpm/xcode/xcshareddata/xcschemes/Gekidou.xcscheme create mode 100644 ios/Gekidou/Gekidou.podspec delete mode 100644 ios/Gekidou/Package.resolved delete mode 100644 ios/Gekidou/Package.swift delete mode 100644 ios/Mattermost.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 160000 libraries/@mattermost/intune create mode 100644 patches/@mattermost+react-native-emm+1.6.2.patch create mode 100755 scripts/intune-disable.sh create mode 100755 scripts/intune-init.sh create mode 100755 scripts/intune-status.sh create mode 100644 types/security_manager/index.d.ts diff --git a/.github/actions/prepare-ios-build/action.yaml b/.github/actions/prepare-ios-build/action.yaml index de299f394..8a9d54e41 100644 --- a/.github/actions/prepare-ios-build/action.yaml +++ b/.github/actions/prepare-ios-build/action.yaml @@ -1,29 +1,62 @@ name: prepare-ios-build description: Action to prepare environment for ios build +inputs: + intune-enabled: + description: 'Enable Intune MAM features (requires Xcode 26.1+)' + required: false + default: 'false' + intune-ssh-private-key: + description: 'SSH private key for Intune submodule repository access (required if intune-enabled is true)' + required: false + runs: using: composite steps: - name: ci/setup-xcode uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 with: - xcode-version: latest-stable + xcode-version: '26.1' - name: ci/prepare-mobile-build uses: ./.github/actions/prepare-mobile-build - - name: ci/install-pods-dependencies + - name: ci/setup-intune + if: inputs.intune-enabled == 'true' + uses: ./.github/actions/setup-intune + with: + ssh-private-key: ${{ inputs.intune-ssh-private-key }} + + - name: Get Intune submodule commit hash + if: inputs.intune-enabled == 'true' + id: intune-hash shell: bash run: | - echo "::group::install-pods-dependencies" - npm run ios-gems - npm run pod-install - echo "::endgroup::" + if [ -e "libraries/@mattermost/intune/.git" ]; then + INTUNE_HASH=$(cd libraries/@mattermost/intune && git rev-parse --short HEAD) + echo "hash=${INTUNE_HASH}" >> $GITHUB_OUTPUT + else + echo "hash=none" >> $GITHUB_OUTPUT + fi - name: Cache Pods uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: - path: Pods - key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} + path: | + ios/Pods + libraries/@mattermost/intune/ios/Frameworks + key: ${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }} restore-keys: | - ${{ runner.os }}-pods- + ${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}- + ${{ runner.os }}-pods-v3-intune-${{ inputs.intune-enabled }}- + + - name: ci/install-pods-dependencies + shell: bash + env: + INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }} + run: | + echo "::group::install-pods-dependencies" + echo "INTUNE_ENABLED=$INTUNE_ENABLED" + npm run ios-gems + npm run pod-install + echo "::endgroup::" diff --git a/.github/actions/setup-intune-ssh-key/action.yaml b/.github/actions/setup-intune-ssh-key/action.yaml new file mode 100644 index 000000000..5f446e695 --- /dev/null +++ b/.github/actions/setup-intune-ssh-key/action.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +# See LICENSE.txt for license information. + +name: Setup Intune SSH Key +description: Configure SSH private key for accessing Intune submodule repository + +inputs: + ssh-private-key: + description: 'SSH private key for Intune submodule repository access' + required: true + +runs: + using: composite + steps: + - name: Configure Intune submodule SSH key + shell: bash + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + # Configure Intune submodule SSH key (separate from main repo key) + INTUNE_KEY_PATH="${HOME}/.ssh/intune_submodule_ed25519" + echo -e '${{ inputs.ssh-private-key }}' > ${INTUNE_KEY_PATH} + chmod 0600 ${INTUNE_KEY_PATH} + ssh-keygen -y -f ${INTUNE_KEY_PATH} > ${INTUNE_KEY_PATH}.pub + + echo "✅ Intune submodule SSH key configured" diff --git a/.github/actions/setup-intune/action.yaml b/.github/actions/setup-intune/action.yaml new file mode 100644 index 000000000..582c60078 --- /dev/null +++ b/.github/actions/setup-intune/action.yaml @@ -0,0 +1,60 @@ +# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +# See LICENSE.txt for license information. + +name: Setup Intune +description: Initialize Intune submodule and link module for internal builds with MAM features + +inputs: + ssh-private-key: + description: 'SSH private key for Intune submodule repository access' + required: true + +runs: + using: composite + steps: + - name: Configure SSH for Intune submodule + uses: ./.github/actions/setup-intune-ssh-key + with: + ssh-private-key: ${{ inputs.ssh-private-key }} + + - name: Initialize Intune submodule + shell: bash + env: + GIT_SSH_COMMAND: ssh -i $HOME/.ssh/intune_submodule_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=no + run: | + echo "::group::Initialize Intune submodule" + # Check if submodule is already initialized + if [ -e "libraries/@mattermost/intune/.git" ]; then + echo "â„šī¸ Intune submodule already initialized, updating..." + git submodule update --remote libraries/@mattermost/intune + else + echo "đŸ“Ĩ Initializing Intune submodule..." + git submodule update --init --recursive libraries/@mattermost/intune + fi + + # Display submodule info + cd libraries/@mattermost/intune + echo "đŸ“Ļ Intune submodule initialized:" + echo " Branch: $(git rev-parse --abbrev-ref HEAD)" + echo " Commit: $(git rev-parse --short HEAD)" + echo " Remote: $(git remote get-url origin)" + cd ../.. + + echo "✅ Intune submodule ready" + echo "::endgroup::" + + - name: Link Intune module to node_modules + shell: bash + run: | + echo "::group::Link Intune module" + # Install the module without saving to package.json + npm install --install-links --no-save ./libraries/@mattermost/intune + + # Verify symlink was created + if [ -L "node_modules/@mattermost/intune" ] || [ -d "node_modules/@mattermost/intune" ]; then + echo "✅ Intune module linked to node_modules/@mattermost/intune" + else + echo "❌ Failed to link Intune module" + exit 1 + fi + echo "::endgroup::" diff --git a/.github/actions/setup-ssh-key/action.yaml b/.github/actions/setup-ssh-key/action.yaml new file mode 100644 index 000000000..eab351bbb --- /dev/null +++ b/.github/actions/setup-ssh-key/action.yaml @@ -0,0 +1,23 @@ +# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +# See LICENSE.txt for license information. + +name: Setup SSH Key +description: Configure SSH private key for accessing private repositories + +inputs: + ssh-private-key: + description: 'SSH private key for repository access' + required: true + +runs: + using: composite + steps: + - name: Configure SSH private key + shell: bash + run: | + SSH_KEY_PATH=~/.ssh/id_ed25519 + mkdir -p ~/.ssh + echo -e '${{ inputs.ssh-private-key }}' > ${SSH_KEY_PATH} + chmod 0600 ${SSH_KEY_PATH} + ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub + echo "✅ SSH key configured" diff --git a/.github/workflows/build-ios-beta.yml b/.github/workflows/build-ios-beta.yml index 86b8148e3..fce52cc9b 100644 --- a/.github/workflows/build-ios-beta.yml +++ b/.github/workflows/build-ios-beta.yml @@ -11,6 +11,7 @@ on: env: NODE_VERSION: 22.14.0 TERM: xterm + INTUNE_ENABLED: 1 jobs: test: @@ -22,7 +23,7 @@ jobs: uses: ./.github/actions/test build-ios-simulator: - runs-on: macos-14-large + runs-on: macos-15-large if: ${{ !contains(github.ref_name, 'beta-ios') }} needs: - test @@ -32,6 +33,9 @@ jobs: - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-ios-simulator env: @@ -50,7 +54,7 @@ jobs: path: Mattermost-simulator-x86_64.app.zip build-and-deploy-ios-beta: - runs-on: macos-14-large + runs-on: macos-15-large if: ${{ !contains(github.ref_name, 'beta-sim') }} needs: - test @@ -58,17 +62,16 @@ jobs: - name: ci/checkout-repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: ci/output-ssh-private-key - shell: bash - run: | - SSH_KEY_PATH=~/.ssh/id_ed25519 - mkdir -p ~/.ssh - echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH} - chmod 0600 ${SSH_KEY_PATH} - ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub + - name: ci/setup-ssh-key + uses: ./.github/actions/setup-ssh-key + with: + ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }} - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-and-deploy-ios-beta env: diff --git a/.github/workflows/build-ios-release.yml b/.github/workflows/build-ios-release.yml index fd65e316d..f34c4eb30 100644 --- a/.github/workflows/build-ios-release.yml +++ b/.github/workflows/build-ios-release.yml @@ -11,6 +11,7 @@ on: env: NODE_VERSION: 22.14.0 TERM: xterm + INTUNE_ENABLED: 1 jobs: test: @@ -22,7 +23,7 @@ jobs: uses: ./.github/actions/test build-and-deploy-ios-release: - runs-on: macos-14-large + runs-on: macos-15-large if: ${{ !contains(github.ref_name, 'release-sim') }} needs: - test @@ -30,17 +31,16 @@ jobs: - name: ci/checkout-repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: ci/setup-ssh-key + uses: ./.github/actions/setup-ssh-key + with: + ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }} + - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build - - - name: ci/output-ssh-private-key - shell: bash - run: | - SSH_KEY_PATH=~/.ssh/id_ed25519 - mkdir -p ~/.ssh - echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH} - chmod 0600 ${SSH_KEY_PATH} - ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-and-deploy-ios-release env: @@ -71,7 +71,7 @@ jobs: path: "*.ipa" build-ios-simulator: - runs-on: macos-14-large + runs-on: macos-15-large if: ${{ !contains(github.ref_name , 'release-ios') }} needs: - test @@ -81,6 +81,9 @@ jobs: - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-ios-simulator env: diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 1e740026e..b9ac0adbc 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -8,6 +8,7 @@ on: env: NODE_VERSION: 22.14.0 TERM: xterm + INTUNE_ENABLED: 1 jobs: test: @@ -22,7 +23,7 @@ jobs: uses: ./.github/actions/test build-ios-pr: - runs-on: macos-14-large + runs-on: macos-15-large if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }} needs: - test @@ -32,17 +33,16 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: ci/setup-ssh-key + uses: ./.github/actions/setup-ssh-key + with: + ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }} + - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build - - - name: ci/output-ssh-private-key - shell: bash - run: | - SSH_KEY_PATH=~/.ssh/id_ed25519 - mkdir -p ~/.ssh - echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH} - chmod 0600 ${SSH_KEY_PATH} - ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-ios-pr env: diff --git a/.github/workflows/e2e-detox-pr.yml b/.github/workflows/e2e-detox-pr.yml index 040814aa2..d1fde2f52 100644 --- a/.github/workflows/e2e-detox-pr.yml +++ b/.github/workflows/e2e-detox-pr.yml @@ -14,6 +14,9 @@ concurrency: group: "${{ github.workflow }}-${{ github.event.pull_request.number }}-${{ github.event.label.name }}" cancel-in-progress: true +env: + INTUNE_ENABLED: 1 + jobs: update-initial-status-ios: if: contains(github.event.label.name, 'E2E iOS tests for PR') @@ -46,7 +49,7 @@ jobs: build-ios-simulator: if: contains(github.event.label.name, 'E2E iOS tests for PR') - runs-on: macos-14 + runs-on: macos-15 needs: - update-initial-status-ios steps: @@ -55,6 +58,9 @@ jobs: - name: Prepare iOS Build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: Build iOS Simulator env: diff --git a/.github/workflows/e2e-detox-release.yml b/.github/workflows/e2e-detox-release.yml index dbc1e2509..81f366422 100644 --- a/.github/workflows/e2e-detox-release.yml +++ b/.github/workflows/e2e-detox-release.yml @@ -5,6 +5,9 @@ on: branches: - release-* +env: + INTUNE_ENABLED: 1 + jobs: update-initial-status-ios: runs-on: ubuntu-22.04 @@ -33,7 +36,7 @@ jobs: status: pending build-ios-simulator: - runs-on: macos-14 + runs-on: macos-15 needs: - update-initial-status-ios steps: @@ -42,6 +45,9 @@ jobs: - name: Prepare iOS Build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: Build iOS Simulator env: diff --git a/.github/workflows/e2e-detox-scheduled.yml b/.github/workflows/e2e-detox-scheduled.yml index c69e2ec45..3bb0a4e51 100644 --- a/.github/workflows/e2e-detox-scheduled.yml +++ b/.github/workflows/e2e-detox-scheduled.yml @@ -4,6 +4,9 @@ on: schedule: - cron: "0 0 * * 4,5" # Wednesday and Thursday midnight +env: + INTUNE_ENABLED: 1 + jobs: update-initial-status-ios: runs-on: ubuntu-22.04 @@ -32,7 +35,7 @@ jobs: status: pending build-ios-simulator: - runs-on: macos-14 + runs-on: macos-15 needs: - update-initial-status-ios steps: @@ -41,6 +44,9 @@ jobs: - name: Prepare iOS Build uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: Build iOS Simulator env: diff --git a/.github/workflows/e2e-ios-template.yml b/.github/workflows/e2e-ios-template.yml index 5944b290a..b63d22bd9 100644 --- a/.github/workflows/e2e-ios-template.yml +++ b/.github/workflows/e2e-ios-template.yml @@ -122,7 +122,7 @@ jobs: e2e-ios: name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }} - runs-on: macos-14 + runs-on: macos-15 continue-on-error: true timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }} env: diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 9af1d95ad..ff855065a 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -15,6 +15,7 @@ env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} SNYK_VERSION: "1.1297.2" CYCLONEDX_VERSION: "v0.27.2" + INTUNE_ENABLED: 1 jobs: test: @@ -26,24 +27,23 @@ jobs: uses: ./.github/actions/test build-ios-unsigned: - runs-on: macos-14-large + runs-on: macos-15-large needs: - test steps: - name: ci/checkout-repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: ci/setup-ssh-key + uses: ./.github/actions/setup-ssh-key + with: + ssh-private-key: ${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }} + - name: ci/prepare-ios-build uses: ./.github/actions/prepare-ios-build - - - name: ci/output-ssh-private-key - shell: bash - run: | - SSH_KEY_PATH=~/.ssh/id_ed25519 - mkdir -p ~/.ssh - echo -e '${{ secrets.MM_MOBILE_PRIVATE_DEPLOY_KEY }}' > ${SSH_KEY_PATH} - chmod 0600 ${SSH_KEY_PATH} - ssh-keygen -y -f ${SSH_KEY_PATH} > ${SSH_KEY_PATH}.pub + with: + intune-enabled: 'true' + intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} - name: ci/build-ios-unsigned env: diff --git a/.github/workflows/validate-intune-clean.yml b/.github/workflows/validate-intune-clean.yml new file mode 100644 index 000000000..a655694d2 --- /dev/null +++ b/.github/workflows/validate-intune-clean.yml @@ -0,0 +1,145 @@ +--- +name: Validate Intune Clean + +on: + pull_request: + paths: + - 'ios/Podfile' + - 'ios/Podfile.lock' + - 'package.json' + - 'package-lock.json' + - 'ios/Mattermost/Info.plist' + - 'ios/Mattermost/Mattermost.entitlements' + - 'ios/Mattermost.xcodeproj/project.pbxproj' + +jobs: + validate-podfile-lock: + name: Validate Podfile.lock is OSS-only + runs-on: ubuntu-latest + steps: + - name: ci/checkout-repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check Podfile.lock for Intune references + run: | + if grep -q "mattermost-intune\|IntuneMAMSwift" ios/Podfile.lock; then + echo "" + echo "❌ ERROR: Podfile.lock contains Intune dependencies" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The committed Podfile.lock should only contain OSS dependencies." + echo "Please restore the OSS version:" + echo "" + echo " git checkout main -- ios/Podfile.lock" + echo "" + echo "Or run: npm run intune:disable" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ Podfile.lock is clean (OSS-only)" + + - name: Check for package.json Intune reference + run: | + # Check only dependencies and devDependencies sections (not scripts) + if jq -e '.dependencies."@mattermost/intune" // .devDependencies."@mattermost/intune"' package.json > /dev/null 2>&1; then + echo "" + echo "❌ ERROR: package.json contains @mattermost/intune dependency" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The Intune library should NOT be in package.json dependencies." + echo "It is installed via --no-save flag during internal builds only." + echo "" + echo "The 'intune:link' script is OK - only dependencies are checked." + echo "" + echo "Please remove the @mattermost/intune entry from dependencies." + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ package.json is clean (no Intune in dependencies)" + + - name: Check for package-lock.json Intune reference + run: | + if grep -q "@mattermost/intune" package-lock.json 2>/dev/null; then + echo "" + echo "❌ ERROR: package-lock.json contains @mattermost/intune" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The Intune library should NOT be in package-lock.json." + echo "" + echo "Please run: npm install (without INTUNE_ENABLED set)" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ package-lock.json is clean (no Intune reference)" + + - name: Check Info.plist for Intune configuration + run: | + if grep -q "IntuneMAMSettings\|msauth\.com\.microsoft\.intunemam\|mattermost-intunemam\|mmauthbeta-intunemam\|intunemam-mtd\|msauthv2\|msauthv3" ios/Mattermost/Info.plist; then + echo "" + echo "❌ ERROR: Info.plist contains Intune configuration" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The Info.plist should NOT contain Intune-specific settings." + echo "Intune configuration is applied by Fastlane during internal builds only." + echo "" + echo "Please restore the OSS version:" + echo "" + echo " git checkout main -- ios/Mattermost/Info.plist" + echo "" + echo "Or run: npm run intune:disable" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ Info.plist is clean (no Intune configuration)" + + - name: Check entitlements for Intune keychain groups + run: | + if grep -q "com\.microsoft\.adalcache\|com\.microsoft\.intune\.mam\|\.intunemam" ios/Mattermost/Mattermost.entitlements 2>/dev/null; then + echo "" + echo "❌ ERROR: Mattermost.entitlements contains Intune keychain groups" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The entitlements should NOT contain Intune-specific keychain groups." + echo "Intune keychain groups are added by Fastlane during internal builds only." + echo "" + echo "Please restore the OSS version:" + echo "" + echo " git checkout main -- ios/Mattermost/Mattermost.entitlements" + echo "" + echo "Or run: npm run intune:disable" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ Mattermost.entitlements is clean (no Intune keychain groups)" + + - name: Check project.pbxproj for Intune frameworks + run: | + if grep -q "MSAL\|IntuneMAM\|IntuneMAMSwift" ios/Mattermost.xcodeproj/project.pbxproj 2>/dev/null; then + echo "" + echo "❌ ERROR: project.pbxproj contains Intune framework references" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The project.pbxproj should NOT contain Intune framework references." + echo "Intune frameworks (MSAL, IntuneMAM, IntuneMAMSwift) are added by Fastlane during internal builds only." + echo "" + echo "Please restore the OSS version:" + echo "" + echo " git checkout main -- ios/Mattermost.xcodeproj/project.pbxproj" + echo "" + echo "Or run: npm run intune:disable" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + exit 1 + fi + echo "✅ project.pbxproj is clean (no Intune framework references)" diff --git a/.gitignore b/.gitignore index f51348c65..f02102ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -124,5 +124,12 @@ libraries/**/**/.build android/app/src/main/res/raw/* .aider* +# Intune submodule (private repository - internal builds only) +# Track submodule reference but ignore content +libraries/@mattermost/intune/* +!libraries/@mattermost/intune/.gitkeep + +# Prevent committing Intune module in node_modules +node_modules/@mattermost/intune # Claude Code .claude/settings.local.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..3950a5fa1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,11 @@ +# Intune MAM library submodule (private repository - internal builds only) +# To pin to a specific tag/commit: +# cd libraries/@mattermost/intune +# git checkout +# cd ../.. +# git add libraries/@mattermost/intune +# git commit -m "Update Intune submodule to " + +[submodule "libraries/@mattermost/intune"] + path = libraries/@mattermost/intune + url = git@github.com:mattermost/mattermost-mobile-intune.git diff --git a/app/actions/app/server.test.ts b/app/actions/app/server.test.ts index c130015a1..43ad00d11 100644 --- a/app/actions/app/server.test.ts +++ b/app/actions/app/server.test.ts @@ -2,17 +2,17 @@ // See LICENSE.txt for license information. import {createIntl} from 'react-intl'; +import {DeviceEventEmitter} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {doPing} from '@actions/remote/general'; import {fetchConfigAndLicense} from '@actions/remote/systems'; -import {Preferences, Screens} from '@constants'; +import {Events, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getTranslations} from '@i18n'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; -import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers'; -import {getSecurityConfig} from '@queries/servers/system'; +import {getServer, getServerByIdentifier} from '@queries/app/servers'; import TestHelper from '@test/test_helper'; import {logError} from '@utils/log'; import {canReceiveNotifications} from '@utils/push_proxy'; @@ -20,17 +20,13 @@ import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@uti import * as Actions from './server'; -import type {ServerDatabase} from '@typings/database/database'; import type ServersModel from '@typings/database/models/app/servers'; jest.mock('@queries/app/servers'); jest.mock('@queries/servers/system'); -jest.mock('@database/manager', () => ({ - getServerDatabaseAndOperator: jest.fn(), - setActiveServerDatabase: jest.fn(), - getActiveServerUrl: jest.fn(), -})); +jest.mock('@database/manager'); jest.mock('@managers/security_manager'); + jest.mock('@managers/websocket_manager'); jest.mock('@utils/log'); jest.mock('@utils/push_proxy'); @@ -43,58 +39,29 @@ const translations = getTranslations(DEFAULT_LOCALE); const intl = createIntl({locale: DEFAULT_LOCALE, messages: translations}); const theme = Preferences.THEMES.denim; -describe('initializeSecurityManager', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return when no servers are found', async () => { - const mockQuery = TestHelper.mockQuery([]); - jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery); - await Actions.initializeSecurityManager(); - expect(SecurityManager.init).not.toHaveBeenCalled(); - }); - - it('should initialize SecurityManager with querying configurations', async () => { - const servers = [{url: 'server1'}, {url: 'server2'}] as ServersModel[]; - const mockQuery = TestHelper.mockQuery(servers); - jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery); - jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((serverUrl) => ({database: `db_${serverUrl}`} as unknown as ServerDatabase)); - const config = { - SiteName: 'Site', - MobileEnableBiometrics: 'true', - MobilePreventScreenCapture: 'true', - MobileJailbreakProtection: 'true', - } as unknown as ClientConfig; - jest.mocked(getSecurityConfig).mockImplementation((db) => Promise.resolve({ - ...config, - key: `config_${db}`, - })); - - await Actions.initializeSecurityManager(); - - expect(SecurityManager.init).toHaveBeenCalledWith({ - server1: {...config, key: 'config_db_server1'}, - server2: {...config, key: 'config_db_server2'}, - }, undefined); - }); - - it('should log error when querying configuration fails', async () => { - const servers = [{url: 'server1'}] as ServersModel[]; - const mockQuery = TestHelper.mockQuery(servers); - jest.mocked(queryAllActiveServers).mockReturnValueOnce(mockQuery); - jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => { - throw new Error('test error'); - }); - - await Actions.initializeSecurityManager(); - - expect(logError).toHaveBeenCalledWith('initializeSecurityManager', expect.any(Error)); - }); -}); - // Tests for switchToServer describe('switchToServer', () => { + const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit'); + + beforeAll(() => { + // Register the SecurityManager listener manually since the mocked SecurityManager doesn't run its constructor + DeviceEventEmitter.addListener(Events.ACTIVE_SERVER_CHANGED, jest.mocked(SecurityManager).setActiveServer); + }); + + beforeEach(async () => { + // Initialize the database for each test + await DatabaseManager.init(['serverUrl']); + }); + + afterEach(async () => { + // Clean up + await DatabaseManager.destroyServerDatabase('serverUrl'); + emitSpy.mockClear(); + jest.mocked(SecurityManager).setActiveServer.mockClear(); + jest.mocked(SecurityManager).isDeviceJailbroken.mockClear(); + jest.mocked(SecurityManager).authenticateWithBiometricsIfNeeded.mockClear(); + }); + it('should log error when server is not found', async () => { jest.mocked(getServer).mockResolvedValueOnce(undefined); await Actions.switchToServer('serverUrl', theme, intl, jest.fn()); @@ -103,15 +70,26 @@ describe('switchToServer', () => { it('should switch to server when lastActiveAt is set', async () => { const server = {url: 'serverUrl', lastActiveAt: 123} as ServersModel; + const setActiveSpy = jest.spyOn(DatabaseManager, 'setActiveServerDatabase'); jest.mocked(getServer).mockResolvedValueOnce(server); - jest.mocked(SecurityManager.isDeviceJailbroken).mockResolvedValueOnce(false); - jest.mocked(SecurityManager.authenticateWithBiometricsIfNeeded).mockResolvedValueOnce(true); + jest.mocked(SecurityManager).isDeviceJailbroken.mockResolvedValueOnce(false); + jest.mocked(SecurityManager).authenticateWithBiometricsIfNeeded.mockResolvedValueOnce(true); await Actions.switchToServer('serverUrl', theme, intl, jest.fn()); + // Wait for the async database operation to complete (setActiveServerDatabase is called without await) + await TestHelper.wait(10); + const options = { + skipJailbreakCheck: true, + skipBiometricCheck: true, + skipMAMEnrollmentCheck: false, + forceSwitch: false, + }; + expect(Navigation.updateProps).toHaveBeenCalledWith(Screens.HOME, {extra: undefined}); - expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('serverUrl'); - expect(SecurityManager.setActiveServer).toHaveBeenCalledWith('serverUrl'); + expect(setActiveSpy).toHaveBeenCalledWith('serverUrl', options); + expect(emitSpy).toHaveBeenCalledWith(Events.ACTIVE_SERVER_CHANGED, {serverUrl: 'serverUrl', options}); + expect(SecurityManager.setActiveServer).toHaveBeenCalledWith({serverUrl: 'serverUrl', options}); expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('serverUrl', 'Server Switch'); }); diff --git a/app/actions/app/server.ts b/app/actions/app/server.ts index 8a9d34440..e586a52b8 100644 --- a/app/actions/app/server.ts +++ b/app/actions/app/server.ts @@ -9,49 +9,13 @@ import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; -import {getServer, getServerByIdentifier, queryAllActiveServers} from '@queries/app/servers'; -import {getSecurityConfig} from '@queries/servers/system'; +import {getServer, getServerByIdentifier} from '@queries/app/servers'; import {logError} from '@utils/log'; import {canReceiveNotifications} from '@utils/push_proxy'; import {alertServerAlreadyConnected, alertServerError, loginToServer} from '@utils/server'; import type {IntlShape} from 'react-intl'; -export async function initializeSecurityManager() { - const servers = await queryAllActiveServers()?.fetch(); - if (!servers?.length) { - return; - } - - const promises: Array> = []; - const results: Record = {}; - - for (const server of servers) { - try { - const {database} = DatabaseManager.getServerDatabaseAndOperator(server.url); - const promise = getSecurityConfig(database).then((config) => [server.url, config] as [string, SecurityClientConfig | null]); - promises.push(promise); - } catch (error) { - logError('initializeSecurityManager', error); - continue; - } - } - - const resolvedConfigs = await Promise.allSettled(promises); - - for (const result of resolvedConfigs) { - if (result.status === 'fulfilled') { - const [url, config] = result.value; - if (config && Object.keys(config).length > 0) { // Ensure the object is not empty - results[url] = config; - } - } - } - - const serverUrl = await DatabaseManager.getActiveServerUrl(); - SecurityManager.init(results, serverUrl); -} - export async function switchToServer(serverUrl: string, theme: Theme, intl: IntlShape, callback?: () => void) { const server = await getServer(serverUrl); if (!server) { @@ -67,10 +31,15 @@ export async function switchToServer(serverUrl: string, theme: Theme, intl: Intl const authenticated = await SecurityManager.authenticateWithBiometricsIfNeeded(server.url); if (authenticated) { Navigation.updateProps(Screens.HOME, {extra: undefined}); - DatabaseManager.setActiveServerDatabase(server.url); - SecurityManager.setActiveServer(server.url); + DatabaseManager.setActiveServerDatabase(server.url, { + skipJailbreakCheck: true, + skipBiometricCheck: true, + skipMAMEnrollmentCheck: false, + forceSwitch: false, + }); WebsocketManager.initializeClient(server.url, 'Server Switch'); } + return; } diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index 647d7f518..358d1db77 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {DeviceEventEmitter, Image} from 'react-native'; +import {Image} from 'expo-image'; +import {DeviceEventEmitter} from 'react-native'; import {Navigation, Screens} from '@constants'; import DatabaseManager from '@database/manager'; @@ -10,8 +11,11 @@ import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from import {addChannelToTeamHistory} from '@queries/servers/team'; import {goToScreen, popTo} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; +import {getExtensionFromMime} from '@utils/file'; import {isTablet} from '@utils/helpers'; import {logError} from '@utils/log'; +import {removeImageProxyForKey} from '@utils/markdown'; +import {urlSafeBase64Encode} from '@utils/security'; import {isParsableUrl} from '@utils/url'; import type {DraftScreenTab} from '@constants/draft'; @@ -296,49 +300,42 @@ export async function updateDraftMarkdownImageMetadata({ } } -async function getImageMetadata(url: string) { - let height = 0; - let width = 0; +async function getImageMetadata(serverUrl: string, url: string) { let format; - await new Promise((resolve) => { - Image.getSize( - url, - (imageWidth, imageHeight) => { - width = imageWidth; - height = imageHeight; - resolve(null); - }, - (error) => { - logError('Failed getImageMetadata to get image size', error); - }, - ); - }); + const sourceKey = removeImageProxyForKey(url); + const cacheKey = `uid-${urlSafeBase64Encode(sourceKey)}`; + const image = await Image.loadAsync({uri: url, cacheKey, cachePath: urlSafeBase64Encode(serverUrl)}); - /** - * Regex Explanation: - * \. - Matches a literal period (e.g., before "jpg"). - * (\w+) - Captures the file extension (letters, digits, or underscores). - * (?=\?|$) - Ensures the extension is followed by "?" or the end of the URL. - * - * * Example Matches: - * "https://example.com/image.jpg" -> Matches "jpg" - * "https://example.com/image.png?size=1" -> Matches "png" - * "https://example.com/file" -> No match (no file extension). - */ - const match = url.match(/\.(\w+)(?=\?|$)/); - if (match) { - format = match[1]; + if (image.mediaType) { + format = getExtensionFromMime(image.mediaType); + } else { + /** + * Regex Explanation: + * \. - Matches a literal period (e.g., before "jpg"). + * (\w+) - Captures the file extension (letters, digits, or underscores). + * (?=\?|$) - Ensures the extension is followed by "?" or the end of the URL. + * + * * Example Matches: + * "https://example.com/image.jpg" -> Matches "jpg" + * "https://example.com/image.png?size=1" -> Matches "png" + * "https://example.com/file" -> No match (no file extension). + */ + const match = url.match(/\.(\w+)(?=\?|$)/); + if (match) { + format = match[1]; + } } + return { - height, - width, + height: image.height, + width: image.width, format, frame_count: 1, url, }; } -export async function parseMarkdownImages(markdown: string, imageMetadata: Dictionary) { +export async function parseMarkdownImages(serverUrl: string, markdown: string, imageMetadata: Dictionary) { // Regex break down // ([a-zA-Z][a-zA-Z\d+\-.]*):\/\/ - Matches any valid scheme (protocol), such as http, https, ftp, mailto, file, etc. // [^\s()<>]+ - Matches the main part of the URL, excluding spaces, parentheses, and angle brackets. @@ -350,7 +347,7 @@ export async function parseMarkdownImages(markdown: string, imageMetadata: Dicti const promises = matches.reduce>>((result, match) => { const imageUrl = match[1]; if (isParsableUrl(imageUrl)) { - result.push(getImageMetadata(imageUrl)); + result.push(getImageMetadata(serverUrl, imageUrl)); } return result; }, []); diff --git a/app/actions/local/session/index.test.ts b/app/actions/local/session/index.test.ts new file mode 100644 index 000000000..f3322bb19 --- /dev/null +++ b/app/actions/local/session/index.test.ts @@ -0,0 +1,346 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetInfo, {type NetInfoState} from '@react-native-community/netinfo'; +import {Platform} from 'react-native'; + +import {removePushDisabledInServerAcknowledged} from '@actions/app/global'; +import DatabaseManager from '@database/manager'; +import {resetMomentLocale} from '@i18n'; +import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; +import PushNotifications from '@init/push_notifications'; +import NetworkManager from '@managers/network_manager'; +import WebsocketManager from '@managers/websocket_manager'; +import {getDeviceToken} from '@queries/app/global'; +import {getExpiredSession} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; +import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; +import {clearCookiesForServer, getCSRFFromCookie, urlSafeBase64Encode} from '@utils/security'; + +import {cancelAllSessionNotifications, cancelSessionNotification, findSession, terminateSession} from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; +import type {ServerDatabase, ServerDatabases} from '@typings/database/database'; +import type UserModel from '@typings/database/models/servers/user'; + +// Mock all dependencies +jest.mock('@react-native-community/netinfo'); +jest.mock('expo-image', () => ({ + Image: { + clearDiskCache: jest.fn(), + }, +})); +jest.mock('@actions/app/global'); +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn(), + getActiveServerDatabase: jest.fn(), + destroyServerDatabase: jest.fn(), + deleteServerDatabase: jest.fn(), + serverDatabases: {}, +})); +jest.mock('@i18n', () => ({ + resetMomentLocale: jest.fn(), +})); +jest.mock('@init/credentials'); +jest.mock('@init/push_notifications', () => ({ + removeServerNotifications: jest.fn(), + cancelScheduleNotification: jest.fn(), +})); +jest.mock('@managers/network_manager', () => ({ + invalidateClient: jest.fn(), +})); +jest.mock('@managers/websocket_manager', () => ({ + invalidateClient: jest.fn(), +})); +jest.mock('@queries/app/global'); +jest.mock('@queries/servers/system'); +jest.mock('@queries/servers/user'); +jest.mock('@utils/file'); +jest.mock('@utils/security'); + +describe('session actions', () => { + const mockServerUrl = 'https://example.com'; + const mockDatabase = {database: 'mockDb'}; + const mockOperator = { + handleSystem: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findSession', () => { + const mockSessions: Session[] = [ + {id: 'session1', device_id: 'device123', props: {csrf: 'csrf123', os: 'ios'}} as Session, + {id: 'session2', device_id: 'device456', props: {csrf: 'csrf456', os: 'android'}} as Session, + {id: 'session3', device_id: 'device789', props: {csrf: 'csrf789', os: 'ios'}} as Session, + ]; + + beforeEach(() => { + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({ + database: mockDatabase as unknown as Database, + operator: mockOperator as unknown as ServerDataOperator, + }); + }); + + it('should find session by expired session ID', async () => { + const expiredSession = {id: 'session2', notificationId: '123'}; + jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration); + jest.mocked(getDeviceToken).mockResolvedValue('device999'); + jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999'); + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toEqual(mockSessions[1]); + expect(getExpiredSession).toHaveBeenCalledWith(mockDatabase); + }); + + it('should find session by device token', async () => { + jest.mocked(getExpiredSession).mockResolvedValue(undefined); + jest.mocked(getDeviceToken).mockResolvedValue('device456'); + jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999'); + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toEqual(mockSessions[1]); + expect(getDeviceToken).toHaveBeenCalled(); + }); + + it('should find session by CSRF token', async () => { + jest.mocked(getExpiredSession).mockResolvedValue(undefined); + jest.mocked(getDeviceToken).mockResolvedValue('device999'); + jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf789'); + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toEqual(mockSessions[2]); + expect(getCSRFFromCookie).toHaveBeenCalledWith(mockServerUrl); + }); + + it('should find session by platform OS', async () => { + Platform.OS = 'android'; + jest.mocked(getExpiredSession).mockResolvedValue(undefined); + jest.mocked(getDeviceToken).mockResolvedValue('device999'); + jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999'); + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toEqual(mockSessions[1]); + }); + + it('should return undefined when no session matches', async () => { + jest.mocked(getExpiredSession).mockResolvedValue(undefined); + jest.mocked(getDeviceToken).mockResolvedValue('device999'); + jest.mocked(getCSRFFromCookie).mockResolvedValue('csrf999'); + Platform.OS = 'web'; + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toBeUndefined(); + }); + + it('should handle errors gracefully and return undefined', async () => { + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => { + throw new Error('Database error'); + }); + + const result = await findSession(mockServerUrl, mockSessions); + + expect(result).toBeUndefined(); + }); + }); + + describe('cancelAllSessionNotifications', () => { + it('should cancel notifications for all servers with credentials', async () => { + const mockCredentials = [ + {serverUrl: 'https://server1.com', userId: 'user1', token: 'token1'}, + {serverUrl: 'https://server2.com', userId: 'user2', token: 'token2'}, + ]; + jest.mocked(getAllServerCredentials).mockResolvedValue(mockCredentials); + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({ + database: mockDatabase as unknown as Database, + operator: mockOperator as unknown as ServerDataOperator, + }); + jest.mocked(getExpiredSession).mockResolvedValue({ + id: 'session1', + notificationId: '123', + } as SessionExpiration); + jest.mocked(NetInfo.fetch).mockResolvedValue({ + isInternetReachable: true, + } as NetInfoState); + + await cancelAllSessionNotifications(); + + expect(getAllServerCredentials).toHaveBeenCalled(); + expect(DatabaseManager.getServerDatabaseAndOperator).toHaveBeenCalledTimes(2); + }); + + it('should handle empty credentials list', async () => { + jest.mocked(getAllServerCredentials).mockResolvedValue([]); + + await cancelAllSessionNotifications(); + + expect(getAllServerCredentials).toHaveBeenCalled(); + expect(DatabaseManager.getServerDatabaseAndOperator).not.toHaveBeenCalled(); + }); + }); + + describe('cancelSessionNotification', () => { + beforeEach(() => { + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({ + database: mockDatabase as unknown as Database, + operator: mockOperator as unknown as ServerDataOperator, + }); + }); + + it('should cancel notification when expired session has notification ID and internet is reachable', async () => { + const expiredSession = {id: 'session1', notificationId: '123'}; + jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration); + jest.mocked(NetInfo.fetch).mockResolvedValue({ + isInternetReachable: true, + } as NetInfoState); + + const result = await cancelSessionNotification(mockServerUrl); + + expect(PushNotifications.cancelScheduleNotification).toHaveBeenCalledWith(123); + expect(mockOperator.handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: 'sessionExpiration', + value: '', + }], + prepareRecordsOnly: false, + }); + expect(result).toEqual({}); + }); + + it('should not cancel notification when no notification ID', async () => { + const expiredSession = {id: 'session1', notificationId: ''}; + jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration); + jest.mocked(NetInfo.fetch).mockResolvedValue({ + isInternetReachable: true, + } as NetInfoState); + + const result = await cancelSessionNotification(mockServerUrl); + + expect(PushNotifications.cancelScheduleNotification).not.toHaveBeenCalled(); + expect(mockOperator.handleSystem).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('should not cancel notification when internet not reachable', async () => { + const expiredSession = {id: 'session1', notificationId: '123'}; + jest.mocked(getExpiredSession).mockResolvedValue(expiredSession as SessionExpiration); + jest.mocked(NetInfo.fetch).mockResolvedValue({ + isInternetReachable: false, + } as NetInfoState); + + const result = await cancelSessionNotification(mockServerUrl); + + expect(PushNotifications.cancelScheduleNotification).not.toHaveBeenCalled(); + expect(mockOperator.handleSystem).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('should handle errors gracefully and return error object', async () => { + const error = new Error('Database error'); + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => { + throw error; + }); + + const result = await cancelSessionNotification(mockServerUrl); + + expect(result).toEqual({error}); + }); + }); + + describe('terminateSession', () => { + const encodedServerUrl = 'aHR0cHM6Ly9leGFtcGxlLmNvbQ=='; + + beforeEach(() => { + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({ + database: mockDatabase as unknown as Database, + operator: mockOperator as unknown as ServerDataOperator, + }); + jest.mocked(getExpiredSession).mockResolvedValue(undefined); + jest.mocked(NetInfo.fetch).mockResolvedValue({ + isInternetReachable: false, + } as NetInfoState); + jest.mocked(urlSafeBase64Encode).mockReturnValue(encodedServerUrl); + jest.mocked(getCurrentUser).mockResolvedValue(undefined); + (DatabaseManager.serverDatabases as ServerDatabases) = {}; + }); + + it('should call all cleanup functions in correct order for removeServer=true', async () => { + await terminateSession(mockServerUrl, true); + + // Verify all cleanup functions called + expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl); + expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl); + expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); + expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); + expect(removePushDisabledInServerAcknowledged).toHaveBeenCalledWith(encodedServerUrl); + expect(DatabaseManager.destroyServerDatabase).toHaveBeenCalledWith(mockServerUrl); + expect(resetMomentLocale).toHaveBeenCalled(); + expect(clearCookiesForServer).toHaveBeenCalledWith(mockServerUrl); + expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl); + expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput'); + expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails'); + }); + + it('should call deleteServerDatabase when removeServer=false', async () => { + await terminateSession(mockServerUrl, false); + + expect(DatabaseManager.deleteServerDatabase).toHaveBeenCalledWith(mockServerUrl); + expect(DatabaseManager.destroyServerDatabase).not.toHaveBeenCalled(); + expect(removePushDisabledInServerAcknowledged).not.toHaveBeenCalled(); + }); + + it('should clear cookies for server', async () => { + await terminateSession(mockServerUrl, true); + + expect(clearCookiesForServer).toHaveBeenCalledWith(mockServerUrl); + }); + + it('should clear image cache with URL-safe encoded server URL', async () => { + await terminateSession(mockServerUrl, true); + + expect(urlSafeBase64Encode).toHaveBeenCalledWith(mockServerUrl); + }); + + it('should delete file caches for server and common directories', async () => { + await terminateSession(mockServerUrl, true); + + expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl); + expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput'); + expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails'); + }); + + it('should reset locale with user locale when active server database exists', async () => { + const mockUser = {locale: 'es'}; + const mockServerDatabase = {database: 'serverDb'} as unknown as ServerDatabase; + + (DatabaseManager.serverDatabases as ServerDatabases) = {[mockServerUrl]: mockServerDatabase}; + jest.mocked(DatabaseManager.getActiveServerDatabase).mockResolvedValue(mockServerDatabase as unknown as Database); + jest.mocked(getCurrentUser).mockResolvedValue(mockUser as unknown as UserModel); + + await terminateSession(mockServerUrl, true); + + // Wait for the async resetLocale to complete (not awaited in implementation) + await new Promise((resolve) => setImmediate(resolve)); + + expect(resetMomentLocale).toHaveBeenCalledWith('es'); + }); + + it('should reset locale to default when no active server database', async () => { + (DatabaseManager.serverDatabases as ServerDatabases) = {}; + + await terminateSession(mockServerUrl, true); + + // Wait for the async resetLocale to complete (not awaited in implementation) + await new Promise((resolve) => setImmediate(resolve)); + + expect(resetMomentLocale).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/app/actions/local/session/index.ts b/app/actions/local/session/index.ts new file mode 100644 index 000000000..3a2823d12 --- /dev/null +++ b/app/actions/local/session/index.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetInfo from '@react-native-community/netinfo'; +import {Platform} from 'react-native'; + +import {removePushDisabledInServerAcknowledged} from '@actions/app/global'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {resetMomentLocale} from '@i18n'; +import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; +import PushNotifications from '@init/push_notifications'; +import NetworkManager from '@managers/network_manager'; +import WebsocketManager from '@managers/websocket_manager'; +import {getDeviceToken} from '@queries/app/global'; +import {getExpiredSession} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; +import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; +import {logError, logWarning} from '@utils/log'; +import {clearCookiesForServer, getCSRFFromCookie, urlSafeBase64Encode} from '@utils/security'; + +const resetLocale = async () => { + if (Object.keys(DatabaseManager.serverDatabases).length) { + const serverDatabase = await DatabaseManager.getActiveServerDatabase(); + const user = await getCurrentUser(serverDatabase!); + resetMomentLocale(user?.locale); + } else { + resetMomentLocale(); + } +}; + +export async function findSession(serverUrl: string, sessions: Session[]) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const expiredSession = await getExpiredSession(database); + const deviceToken = await getDeviceToken(); + + // First try and find the session by the given identifier + let session = sessions.find((s) => s.id === expiredSession?.id); + if (session) { + return session; + } + + // Next try and find the session by deviceId + if (deviceToken) { + session = sessions.find((s) => s.device_id === deviceToken); + if (session) { + return session; + } + } + + // Next try and find the session by the CSRF token + const csrfToken = await getCSRFFromCookie(serverUrl); + if (csrfToken) { + session = sessions.find((s) => s.props?.csrf === csrfToken); + if (session) { + return session; + } + } + + // Next try and find the session based on the OS + // if multiple sessions exists with the same os type this can be inaccurate + session = sessions.find((s) => s.props?.os.toLowerCase() === Platform.OS); + if (session) { + return session; + } + } catch (e) { + logError('findSession', e); + } + + // At this point we did not find the session + return undefined; +} + +export const cancelAllSessionNotifications = async () => { + const serverCredentials = await getAllServerCredentials(); + for (const {serverUrl} of serverCredentials) { + cancelSessionNotification(serverUrl); + } +}; + +export const cancelSessionNotification = async (serverUrl: string) => { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const expiredSession = await getExpiredSession(database); + const rechable = (await NetInfo.fetch()).isInternetReachable; + + if (expiredSession?.notificationId && rechable) { + PushNotifications.cancelScheduleNotification(parseInt(expiredSession.notificationId, 10)); + operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION, + value: '', + }], + prepareRecordsOnly: false, + }); + } + + return {}; + } catch (e) { + logError('cancelSessionNotification', e); + return {error: e}; + } +}; + +export const terminateSession = async (serverUrl: string, removeServer: boolean) => { + const errors: Array<{operation: string; error: unknown}> = []; + + // Helper to safely execute operations and optionally track errors + const safeExecute = async (operation: string, fn: () => Promise, critical = true) => { + try { + const result = await fn(); + + // Check if function returned {error} + if (result && typeof result === 'object' && 'error' in result && critical) { + errors.push({operation, error: result.error}); + } + } catch (error) { + if (critical) { + errors.push({operation, error}); + } else { + // Log but don't track as failure + logWarning(`terminateSession: ${operation} failed (non-critical)`, error); + } + } + }; + + // Cancel session notification (critical) + await safeExecute('cancelSessionNotification', async () => { + await cancelSessionNotification(serverUrl); + }, false); + + // Remove server credentials (critical) + await safeExecute('removeServerCredentials', async () => { + await removeServerCredentials(serverUrl); + }); + + // Remove push notifications (synchronous, no error handling needed) + PushNotifications.removeServerNotifications(serverUrl); + + // Invalidate clients (synchronous, no error handling needed) + NetworkManager.invalidateClient(serverUrl); + WebsocketManager.invalidateClient(serverUrl); + + // Remove push disabled acknowledgment (non-critical) + if (removeServer) { + await safeExecute('removePushDisabledInServerAcknowledged', async () => { + const result = await removePushDisabledInServerAcknowledged(urlSafeBase64Encode(serverUrl)); + if (result && typeof result === 'object' && 'error' in result) { + throw result.error; + } + }, false); + } + + // Database operations (critical) + await safeExecute('databaseOperation', async () => { + if (removeServer) { + await DatabaseManager.destroyServerDatabase(serverUrl); + } else { + await DatabaseManager.deleteServerDatabase(serverUrl); + } + }); + + // Reset locale (non-critical) + await safeExecute('resetLocale', async () => { + await resetLocale(); + }, false); + + // Clear cookies (synchronous) + clearCookiesForServer(serverUrl); + + // Delete file caches (critical - we need to wipe local data) + await safeExecute('deleteFileCache', async () => { + await deleteFileCache(serverUrl); + }); + + await safeExecute('deleteFileCacheMmPasteInput', async () => { + await deleteFileCacheByDir('mmPasteInput'); + }); + + await safeExecute('deleteFileCacheThumbnails', async () => { + await deleteFileCacheByDir('thumbnails'); + }); + + if (errors.length > 0) { + return {error: errors}; + } + + return {}; +}; diff --git a/app/actions/local/systems.ts b/app/actions/local/systems.ts index ff3707a36..639aaf67c 100644 --- a/app/actions/local/systems.ts +++ b/app/actions/local/systems.ts @@ -3,7 +3,9 @@ import {Q} from '@nozbe/watermelondb'; import deepEqual from 'deep-equal'; +import {DeviceEventEmitter} from 'react-native'; +import {Events} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getServerCredentials} from '@init/credentials'; @@ -36,6 +38,7 @@ export async function storeConfigAndLicense(serverUrl: string, config: ClientCon if (systems.length) { await operator.handleSystem({systems, prepareRecordsOnly: false}); + DeviceEventEmitter.emit(Events.LICENSE_CHANGED, {serverUrl, license}); } return await storeConfig(serverUrl, config); @@ -76,7 +79,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde } if (configsToDelete.length || configsToUpdate.length) { - return operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly}); + const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly}); + DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config}); + return results; } } catch (error) { logError('storeConfig', error); diff --git a/app/actions/local/user.test.ts b/app/actions/local/user.test.ts index 1b73ac19d..29f904ad4 100644 --- a/app/actions/local/user.test.ts +++ b/app/actions/local/user.test.ts @@ -11,6 +11,7 @@ import { updateRecentCustomStatuses, updateLocalUser, storeProfile, + getCurrentUserLocale, } from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -127,3 +128,39 @@ describe('storeProfile', () => { }); }); +describe('getCurrentUserLocale', () => { + it('should return user locale when user exists', async () => { + const userWithLocale = TestHelper.fakeUser({ + id: 'userid', + locale: 'es', + }); + await operator.handleUsers({users: [userWithLocale], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: userWithLocale.id}], prepareRecordsOnly: false}); + + const locale = await getCurrentUserLocale(serverUrl); + expect(locale).toBe('es'); + }); + + it('should return DEFAULT_LOCALE when user has no locale', async () => { + const userWithoutLocale = TestHelper.fakeUser({ + id: 'userid', + locale: '', + }); + await operator.handleUsers({users: [userWithoutLocale], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: userWithoutLocale.id}], prepareRecordsOnly: false}); + + const locale = await getCurrentUserLocale(serverUrl); + expect(locale).toBe('en'); + }); + + it('should return DEFAULT_LOCALE when no user exists', async () => { + const locale = await getCurrentUserLocale(serverUrl); + expect(locale).toBe('en'); + }); + + it('should return DEFAULT_LOCALE when database not found', async () => { + const locale = await getCurrentUserLocale('foo'); + expect(locale).toBe('en'); + }); +}); + diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts index 1ee5b08fc..e9b0ca1e2 100644 --- a/app/actions/local/user.ts +++ b/app/actions/local/user.ts @@ -3,6 +3,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {DEFAULT_LOCALE} from '@i18n'; import {getRecentCustomStatuses} from '@queries/servers/system'; import {getCurrentUser, getUserById} from '@queries/servers/user'; import {logError} from '@utils/log'; @@ -160,3 +161,13 @@ export const storeProfile = async (serverUrl: string, profile: UserProfile) => { return {error}; } }; + +export const getCurrentUserLocale = async (serverUrl: string): Promise => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const user = await getCurrentUser(database); + return user?.locale || DEFAULT_LOCALE; + } catch { + return DEFAULT_LOCALE; + } +}; diff --git a/app/actions/remote/custom_emoji.test.ts b/app/actions/remote/custom_emoji.test.ts index 797e6102a..8580d5a78 100644 --- a/app/actions/remote/custom_emoji.test.ts +++ b/app/actions/remote/custom_emoji.test.ts @@ -31,6 +31,9 @@ beforeEach(async () => { describe('fetchCustomEmojis', () => { it('should fetch custom emojis successfully', async () => { const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, getCustomEmojis: jest.fn().mockResolvedValue(mockEmojis), getCustomEmojiImageUrl: jest.fn(), }; @@ -45,6 +48,9 @@ describe('fetchCustomEmojis', () => { it('should handle error during fetch custom emojis', async () => { const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, getCustomEmojis: jest.fn().mockRejectedValue(error), getCustomEmojiImageUrl: jest.fn(), }; @@ -65,6 +71,9 @@ describe('searchCustomEmojis', () => { it('should search custom emojis successfully', async () => { const term = 'emoji'; const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, searchCustomEmoji: jest.fn().mockResolvedValue(mockEmojis), getCustomEmojiImageUrl: jest.fn(), }; @@ -80,6 +89,9 @@ describe('searchCustomEmojis', () => { it('should handle error during search custom emojis', async () => { const term = 'emoji'; const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, searchCustomEmoji: jest.fn().mockRejectedValue(error), getCustomEmojiImageUrl: jest.fn(), }; @@ -99,6 +111,9 @@ describe('searchCustomEmojis', () => { describe('fetchEmojisByName', () => { it('should fetch emojis by name successfully', async () => { const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, getCustomEmojiByName: jest.fn().mockResolvedValue(emoji), getCustomEmojiImageUrl: jest.fn(), }; @@ -112,6 +127,9 @@ describe('fetchEmojisByName', () => { it('should handle no emojis', async () => { const mockClient = { + apiClient: { + baseUrl: serverUrl, + }, getCustomEmojiByName: jest.fn().mockRejectedValue('error message'), getCustomEmojiImageUrl: jest.fn(), }; diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index a605b92ce..724ea2340 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -4,6 +4,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import {getServerCredentials} from '@init/credentials'; +import IntuneManager from '@managers/intune_manager'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; @@ -34,10 +35,10 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: const credentials = await getServerCredentials(serverUrl); if (credentials?.token) { - SecurityManager.addServer(serverUrl, clData.config, true); + const intunePolicy = await IntuneManager.getPolicy(serverUrl); + SecurityManager.addServer(serverUrl, clData.config, false, intunePolicy); WebsocketManager.createClient(serverUrl, credentials.token, credentials.preauthSecret); await WebsocketManager.initializeClient(serverUrl, 'Login'); - SecurityManager.setActiveServer(serverUrl); } return {}; diff --git a/app/actions/remote/session.test.ts b/app/actions/remote/session.test.ts index b36a35511..92a9e621e 100644 --- a/app/actions/remote/session.test.ts +++ b/app/actions/remote/session.test.ts @@ -4,11 +4,13 @@ import {createIntl} from 'react-intl'; import {Alert, DeviceEventEmitter, Platform} from 'react-native'; +import {cancelSessionNotification, findSession} from '@actions/local/session'; import {Events} from '@constants'; import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import WebsocketManager from '@managers/websocket_manager'; +import TestHelper from '@test/test_helper'; import {logWarning} from '@utils/log'; import { @@ -17,11 +19,10 @@ import { fetchSessions, login, logout, - cancelSessionNotification, + nativeEntraLogin, scheduleSessionNotification, sendPasswordResetEmail, ssoLogin, - findSession, } from './session'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -35,7 +36,7 @@ const intl = createIntl({ const serverUrl = 'baseHandler.test.com'; let operator: ServerDataOperator; -const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; +const user1 = TestHelper.fakeUser({id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''}); const session1 = {id: 'sessionid1', user_id: user1.id, device_id: 'deviceid', props: {csrf: 'csrfid'}} as Session; @@ -45,6 +46,7 @@ const throwFunc = () => { const mockClient = { login: jest.fn(() => user1), + loginByIntune: jest.fn().mockResolvedValue(user1), setCSRFToken: jest.fn(), setClientCredentials: jest.fn(), getClientConfigOld: jest.fn(() => ({})), @@ -491,3 +493,167 @@ describe('logout', () => { } }); }); + +describe('nativeEntraLogin', () => { + const serverDisplayName = 'Test Server'; + const serverIdentifier = 'test-server-id'; + const intuneScope = 'api://test-scope/.default'; + + const mockTokens = { + accessToken: 'mock-access-token', + idToken: 'mock-id-token', + identity: { + upn: 'test@example.com', + tid: 'tenant-id', + oid: 'object-id', + }, + }; + + let IntuneManager: any; + + beforeEach(() => { + // Get the mocked IntuneManager + IntuneManager = require('@managers/intune_manager').default; + + // Reset all mocks + jest.clearAllMocks(); + + // Set up default implementations + IntuneManager.login.mockResolvedValue(mockTokens); + IntuneManager.enrollServer.mockResolvedValue(undefined); + IntuneManager.isManagedServer.mockResolvedValue(false); + + mockClient.loginByIntune.mockReset().mockImplementation(() => Promise.resolve(user1)); + mockGetCSRFFromCookie.mockImplementation(() => Promise.resolve('csrfid')); + }); + + it('should successfully login on first try without enrollment', async () => { + IntuneManager.isManagedServer.mockResolvedValue(false); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(false); + expect(IntuneManager.login).toHaveBeenCalledWith(serverUrl, [intuneScope]); + expect(mockClient.loginByIntune).toHaveBeenCalledWith(mockTokens.accessToken, expect.any(String)); + expect(mockClient.setCSRFToken).toHaveBeenCalledWith('csrfid'); + expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity); + }); + + it('should handle 401 token expiration and retry with refreshed token', async () => { + const refreshedTokens = { + ...mockTokens, + accessToken: 'refreshed-access-token', + }; + + mockClient.loginByIntune.mockRejectedValueOnce({status_code: 401} as never).mockResolvedValueOnce(user1); + IntuneManager.login.mockResolvedValueOnce(mockTokens).mockResolvedValueOnce(refreshedTokens); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(false); + expect(IntuneManager.login).toHaveBeenCalledTimes(2); + expect(mockClient.loginByIntune).toHaveBeenCalledTimes(2); + expect(mockClient.loginByIntune).toHaveBeenNthCalledWith(2, refreshedTokens.accessToken, expect.any(String)); + }); + + it('should handle 412 MAM enrollment required', async () => { + const error = {status_code: 412}; + mockClient.loginByIntune.mockRejectedValueOnce(error); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(error); + }); + + it('should handle 400 LDAP user missing error', async () => { + const error = { + status_code: 400, + server_error_id: 'ent.intune.login.ldap_user_missing.app_error', + }; + mockClient.loginByIntune.mockRejectedValueOnce(error); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(error); + }); + + it('should handle 409 user deactivated error', async () => { + const error = {status_code: 409}; + mockClient.loginByIntune.mockRejectedValueOnce(error); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(error); + }); + + it('should handle 428 account creation blocked error', async () => { + const error = { + status_code: 428, + server_error_id: 'ent.intune.login.account_creation_blocked.app_error', + }; + mockClient.loginByIntune.mockRejectedValueOnce(error); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(error); + }); + + it('should handle MSAL login failure', async () => { + const msalError = { + domain: 'MSALErrorDomain', + code: -50005, + message: 'User canceled authentication', + }; + IntuneManager.login.mockRejectedValueOnce(msalError); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(msalError); + }); + + it('should enroll in MAM after successful login if not already managed', async () => { + IntuneManager.isManagedServer.mockResolvedValue(false); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(false); + expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl); + expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity); + }); + + it('should skip MAM enrollment if already managed', async () => { + IntuneManager.isManagedServer.mockResolvedValue(true); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(false); + expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl); + expect(IntuneManager.enrollServer).not.toHaveBeenCalled(); + }); + + it('should fail if post-login MAM enrollment fails', async () => { + const enrollmentError = new Error('Enrollment failed'); + IntuneManager.isManagedServer.mockResolvedValue(false); + IntuneManager.enrollServer.mockRejectedValueOnce(enrollmentError); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(enrollmentError); + }); + + it('should handle generic server errors', async () => { + const error = {status_code: 500, message: 'Internal server error'}; + mockClient.loginByIntune.mockRejectedValueOnce(error); + + const result = await nativeEntraLogin(serverUrl, serverDisplayName, serverIdentifier, intuneScope); + + expect(result.failed).toBe(true); + expect(result.error).toEqual(error); + }); +}); diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index dc0485a31..9da70139b 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -1,19 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import NetInfo from '@react-native-community/netinfo'; import {defineMessages, type IntlShape} from 'react-intl'; -import {Alert, DeviceEventEmitter, Platform, type AlertButton} from 'react-native'; +import {Alert, DeviceEventEmitter, type AlertButton} from 'react-native'; +import {cancelSessionNotification, findSession} from '@actions/local/session'; import {Database, Events} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; -import PushNotifications from '@init/push_notifications'; +import IntuneManager from '@managers/intune_manager'; import NetworkManager from '@managers/network_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getDeviceToken} from '@queries/app/global'; import {getServerDisplayName} from '@queries/app/servers'; -import {getCurrentUserId, getExpiredSession} from '@queries/servers/system'; +import {getCurrentUserId} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; import {resetToHome} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; @@ -26,6 +26,7 @@ import {getServerUrlAfterRedirect} from '@utils/url'; import {loginEntry} from './entry'; +import type {Client} from '@client/rest'; import type {LoginArgs} from '@typings/database/database'; const HTTP_UNAUTHORIZED = 401; @@ -172,6 +173,7 @@ type LogoutOptions = { removeServer?: boolean; skipEvents?: boolean; logoutOnAlert?: boolean; + skipAlert?: boolean; // Skip showing alert dialog (for automated wipes) }; export const logout = async ( @@ -182,6 +184,7 @@ export const logout = async ( removeServer = false, skipEvents = false, logoutOnAlert = false, + skipAlert = false, }: LogoutOptions = {}) => { if (!skipServerLogout) { let loggedOut = false; @@ -196,7 +199,7 @@ export const logout = async ( logWarning('An error occurred logging out from the server', serverUrl, getFullErrorMessage(error)); } - if (!loggedOut) { + if (!loggedOut && !skipAlert) { const title = intl?.formatMessage(logoutMessages.title) || logoutMessages.title.defaultMessage; const bodyMessage = logoutOnAlert ? logoutMessages.bodyForced : logoutMessages.body; @@ -232,30 +235,6 @@ export const logout = async ( return {data: true}; }; -export const cancelSessionNotification = async (serverUrl: string) => { - try { - const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const expiredSession = await getExpiredSession(database); - const rechable = (await NetInfo.fetch()).isInternetReachable; - - if (expiredSession?.notificationId && rechable) { - PushNotifications.cancelScheduleNotification(parseInt(expiredSession.notificationId, 10)); - operator.handleSystem({ - systems: [{ - id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION, - value: '', - }], - prepareRecordsOnly: false, - }); - } - - return {}; - } catch (e) { - logError('cancelSessionNotification', e); - return {error: e}; - } -}; - export const scheduleSessionNotification = async (serverUrl: string) => { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -303,18 +282,13 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) = } }; -export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise => { +const completeSSOLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, client: Client, userData?: UserProfile, skipChecks = false): Promise => { const database = DatabaseManager.appDatabase?.database; if (!database) { return {error: 'App database not found', failed: true}; } try { - const client = NetworkManager.getClient(serverUrl); - - client.setClientCredentials(bearerToken, preauthSecret); - client.setCSRFToken(csrfToken); - // Setting up active database for this SSO login flow const server = await DatabaseManager.createServerDatabase({ config: { @@ -324,7 +298,9 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser displayName: serverDisplayName, }, }); - const user = await client.getMe(); + + const user = userData || await client.getMe(); + await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false}); await server?.operator.handleSystem({ systems: [{ @@ -341,100 +317,100 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser try { await addPushProxyVerificationStateFromLogin(serverUrl); const {error} = await loginEntry({serverUrl}); - await DatabaseManager.setActiveServerDatabase(serverUrl); + await DatabaseManager.setActiveServerDatabase(serverUrl, { + skipMAMEnrollmentCheck: skipChecks, + skipJailbreakCheck: skipChecks, + skipBiometricCheck: skipChecks, + }); return {error, failed: false}; } catch (error) { return {error, failed: false}; } }; +export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string, preauthSecret?: string): Promise => { + const client = NetworkManager.getClient(serverUrl); + + client.setClientCredentials(bearerToken, preauthSecret); + client.setCSRFToken(csrfToken); + + const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client); + return result; +}; + export const ssoLoginWithCodeExchange = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, loginCode: string, samlChallenge: Pick, preauthSecret?: string): Promise => { - const database = DatabaseManager.appDatabase?.database; - if (!database) { - return {error: 'App database not found', failed: true}; - } + const client = NetworkManager.getClient(serverUrl); + const {token, csrf} = await client.exchangeSsoLoginCode(loginCode, samlChallenge.codeVerifier, samlChallenge.state); - try { - const client = NetworkManager.getClient(serverUrl); - const {token, csrf} = await client.exchangeSsoLoginCode(loginCode, samlChallenge.codeVerifier, samlChallenge.state); + client.setClientCredentials(token, preauthSecret); + client.setCSRFToken(csrf); - client.setClientCredentials(token, preauthSecret); - client.setCSRFToken(csrf); - - const server = await DatabaseManager.createServerDatabase({ - config: { - dbName: serverUrl, - serverUrl, - identifier: serverIdentifier, - displayName: serverDisplayName, - }, - }); - const user = await client.getMe(); - await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false}); - await server?.operator.handleSystem({ - systems: [{ - id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID, - value: user.id, - }], - prepareRecordsOnly: false, - }); - } catch (error) { - logDebug('error on ssoLoginWithCodeExchange', getFullErrorMessage(error)); - return {error, failed: true}; - } - - try { - await addPushProxyVerificationStateFromLogin(serverUrl); - const {error} = await loginEntry({serverUrl}); - await DatabaseManager.setActiveServerDatabase(serverUrl); - return {error, failed: false}; - } catch (error) { - return {error, failed: false}; - } + const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client); + return result; }; -export async function findSession(serverUrl: string, sessions: Session[]) { +export const nativeEntraLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, intuneScope: string): Promise => { try { - const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const expiredSession = await getExpiredSession(database); + // Step 1: Acquire MSAL tokens with IntuneScope + const tokens = await IntuneManager.login(serverUrl, [intuneScope]); + const {accessToken, identity} = tokens; + + // Step 2: POST accessToken to /oauth/intune to exchange for session token + const client = NetworkManager.getClient(serverUrl); const deviceToken = await getDeviceToken(); + let csrfToken: string; + let userData: UserProfile | undefined; - // First try and find the session by the given identifier hyqddef7jjdktqiyy36gxa8sqy - let session = sessions.find((s) => s.id === expiredSession?.id); - if (session) { - return session; - } - - // Next try and find the session by deviceId - if (deviceToken) { - session = sessions.find((s) => s.device_id === deviceToken); - if (session) { - return session; + try { + userData = await client.loginByIntune(accessToken, deviceToken); + csrfToken = await getCSRFFromCookie(serverUrl); + } catch (error) { + if (isErrorWithStatusCode(error)) { + switch (error.status_code) { + case 401: { + // Token expired/invalid - try refreshing + logDebug('nativeEntraLogin: Token expired, retrying'); + const refreshedTokens = await IntuneManager.login(serverUrl, [intuneScope]); + userData = await client.loginByIntune(refreshedTokens.accessToken, deviceToken); + csrfToken = await getCSRFFromCookie(serverUrl); + break; + } + default: + // 400: LDAP user missing + // 409: User locked/disabled + // 428: Account creation blocked + // All other errors - throw for i18n handling + throw error; + } + } else { + throw error; } } - // Next try and find the session by the CSRF token - const csrfToken = await getCSRFFromCookie(serverUrl); - if (csrfToken) { - session = sessions.find((s) => s.props?.csrf === csrfToken); - if (session) { - return session; + client.setCSRFToken(csrfToken); + + // Step 3: Complete SSO login flow (sets up database, etc.) + const result = await completeSSOLogin(serverUrl, serverDisplayName, serverIdentifier, client, userData, true); + + // Step 4: Enroll in MAM if not already enrolled (if 412 was not triggered) + if (result && !result.failed) { + try { + const isManaged = await IntuneManager.isManagedServer(serverUrl); + if (!isManaged) { + await IntuneManager.enrollServer(serverUrl, identity); + } + } catch (error) { + logWarning('Intune MAM enrollment failed, MAM protection may not be configured properly', error); + throw error; } } - // Next try and find the session based on the OS - // if multiple sessions exists with the same os type this can be inaccurate - session = sessions.find((s) => s.props?.os.toLowerCase() === Platform.OS); - if (session) { - return session; - } - } catch (e) { - logError('findSession', e); + return result; + } catch (error) { + logError('nativeEntraLogin failed', error); + return {error, failed: true}; } - - // At this point we did not find the session - return undefined; -} +}; export const getUserLoginType = async (serverUrl: string, loginId: string) => { try { diff --git a/app/client/rest/users.test.ts b/app/client/rest/users.test.ts index 4fbc0cb1c..200f656fd 100644 --- a/app/client/rest/users.test.ts +++ b/app/client/rest/users.test.ts @@ -175,6 +175,37 @@ describe('ClientUsers', () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, defaultExpectedOptions, false); }); + test('loginByIntune', async () => { + const accessToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'; + const deviceId = 'deviceId'; + const expectedUrl = '/oauth/intune'; + const expectedOptions = { + method: 'post', + body: { + device_id: deviceId, + access_token: accessToken, + }, + headers: {'Cache-Control': 'no-store'}, + }; + + await client.loginByIntune(accessToken, deviceId); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions, false); + + // Test with default deviceId + const defaultExpectedOptions = { + method: 'post', + body: { + device_id: '', + access_token: accessToken, + }, + headers: {'Cache-Control': 'no-store'}, + }; + + await client.loginByIntune(accessToken); + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, defaultExpectedOptions, false); + }); + test('logout', async () => { const expectedUrl = `${client.getUsersRoute()}/logout`; const expectedOptions = {method: 'post'}; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index f9c0e5ea6..495d718ba 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -20,6 +20,7 @@ export interface ClientUsersMix { login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise; loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise; loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise; + loginByIntune: (accessToken: string, deviceId?: string) => Promise; logout: () => Promise; getProfiles: (page?: number, perPage?: number, options?: Record) => Promise; getProfilesByIds: (userIds: string[], options?: Record, groupLabel?: RequestGroupLabel) => Promise; @@ -144,7 +145,7 @@ const ClientUsers = >(superclass: TBase) = }; loginById = async (id: string, password: string, token = '', deviceId = '') => { - const body: any = { + const body = { device_id: deviceId, id, password, @@ -164,6 +165,25 @@ const ClientUsers = >(superclass: TBase) = return resp?.data; }; + loginByIntune = async (accessToken: string, deviceId = '') => { + const body = { + device_id: deviceId, + access_token: accessToken, + }; + + const resp = await this.doFetch( + '/oauth/intune', + { + method: 'post', + body, + headers: {'Cache-Control': 'no-store'}, + }, + false, + ); + + return resp?.data; + }; + loginByMagicLinkLogin = async (token: string, deviceId = '') => { const body = { magic_link_token: token, diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx index 7025927e6..f9f9210e0 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -2,16 +2,17 @@ // See LICENSE.txt for license information. import base64 from 'base-64'; -import {Image} from 'expo-image'; import React, {useCallback, useMemo} from 'react'; import {Text, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {SvgXml} from 'react-native-svg'; import CompassIcon from '@components/compass_icon'; +import ExpoImage from '@components/expo_image'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {COMMAND_SUGGESTION_ERROR} from '@constants/apps'; import {useTheme} from '@context/theme'; +import {urlSafeBase64Encode} from '@utils/security'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; const slashIcon = require('@assets/images/autocomplete/slash_command.png'); @@ -106,9 +107,10 @@ const SlashSuggestionItem = ({ } let image = ( - ); if (icon === COMMAND_SUGGESTION_ERROR) { @@ -120,7 +122,8 @@ const SlashSuggestionItem = ({ ); } else if (icon.startsWith('http')) { image = ( - @@ -142,7 +145,8 @@ const SlashSuggestionItem = ({ } } else { image = ( - diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx index 1b30ba721..d7a33a579 100644 --- a/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx @@ -1,14 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image, type ImageStyle} from 'expo-image'; +import {type ImageStyle} from 'expo-image'; import React, {useState, useCallback} from 'react'; import {type StyleProp, type TextStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import Emoji from '@components/emoji'; +import ExpoImage from '@components/expo_image'; import FileIcon from '@components/files/file_icon'; import {useTheme} from '@context/theme'; +import {urlSafeBase64Encode} from '@utils/security'; type Props = { emoji?: string; @@ -40,8 +42,9 @@ const BookmarkIcon = ({emoji, emojiSize, emojiStyle, file, genericStyle, iconSiz ); } else if (imageUrl && !emoji && !hasImageError) { return ( - { if (files.length) { - const items = files.map((f) => fileToGalleryItem(f, f.user_id)); + const items = files.map((f) => fileToGalleryItem(f, f.user_id, undefined, 0, f.id)); openGalleryAtIndex(galleryIdentifier, idx, items); } }, [files, galleryIdentifier])); @@ -108,7 +108,9 @@ const ChannelBookmarks = ({ publicLinkEnabled, ]); - const renderItemSeparator = useCallback(() => (), []); + const renderItemSeparator = useCallback(() => ( + + ), [styles.emptyItemSeparator]); const onScrolled = useCallback((e: NativeSyntheticEvent) => { setAllowEndFade(isCloseToBottom(e.nativeEvent)); diff --git a/app/components/draft_scheduled_post_header/profile_avatar.tsx b/app/components/draft_scheduled_post_header/profile_avatar.tsx index 131743061..b278f22a5 100644 --- a/app/components/draft_scheduled_post_header/profile_avatar.tsx +++ b/app/components/draft_scheduled_post_header/profile_avatar.tsx @@ -1,15 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image} from 'expo-image'; import React, {useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrlFromUser} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; +import ExpoImage from '@components/expo_image'; import {useServerUrl} from '@context/server'; import {changeOpacity} from '@utils/theme'; +import {getLastPictureUpdate} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; @@ -42,7 +43,8 @@ const ProfileAvatar = ({ let picture; if (uri) { picture = ( - diff --git a/app/components/emoji/emoji.tsx b/app/components/emoji/emoji.tsx index 37b3bac24..796eb4506 100644 --- a/app/components/emoji/emoji.tsx +++ b/app/components/emoji/emoji.tsx @@ -2,13 +2,13 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {Image as ExpoImage} from 'expo-image'; import React from 'react'; -import {Image, Platform, StyleSheet, Text} from 'react-native'; +import {StyleSheet, Text} from 'react-native'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {fetchCustomEmojiInBatch} from '@actions/remote/custom_emoji'; +import ExpoImage from '@components/expo_image'; import {useServerUrl} from '@context/server'; import NetworkManager from '@managers/network_manager'; import {queryCustomEmojisByName} from '@queries/servers/custom_emoji'; @@ -102,56 +102,38 @@ const Emoji = (props: EmojiProps) => { return null; } - return Platform.select({ - ios: ( - - ), - android: ( - - ), - }); + return ( + + ); } if (!imageUrl) { return null; } - return Platform.select({ - ios: ( - - ), - android: ( - - ), - }); + return ( + + ); }; const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => { diff --git a/app/components/expo_image/index.tsx b/app/components/expo_image/index.tsx new file mode 100644 index 000000000..9dbffd7ca --- /dev/null +++ b/app/components/expo_image/index.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Image, ImageBackground, type ImageBackgroundProps, type ImageProps, type ImageSource} from 'expo-image'; +import React, {forwardRef, useMemo} from 'react'; +import Animated from 'react-native-reanimated'; + +import {useServerUrl} from '@context/server'; +import {urlSafeBase64Encode} from '@utils/security'; + +type ExpoImagePropsWithId = ImageProps & {id: string}; +type ExpoImagePropsMemoryOnly = ImageProps & {cachePolicy: 'memory'; id?: string}; +type ExpoImageProps = ExpoImagePropsWithId | ExpoImagePropsMemoryOnly; + +type ExpoImageBackgroundPropsWithId = ImageBackgroundProps & {id: string}; +type ExpoImageBackgroundPropsMemoryOnly = ImageBackgroundProps & {cachePolicy: 'memory'; id?: string}; +type ExpoImageBackgroundProps = ExpoImageBackgroundPropsWithId | ExpoImageBackgroundPropsMemoryOnly; + +const ExpoImage = forwardRef(({id, ...props}, ref) => { + const serverUrl = useServerUrl(); + + /** + * SECURITY NOTE: cachePath uses base64 encoding for URL safety, NOT encryption. + * Server URLs are not considered sensitive information, and this encoding is purely + * for filesystem path compatibility (avoiding special characters in directory names). + */ + const cachePath = useMemo(() => urlSafeBase64Encode(serverUrl), [serverUrl]); + const source: ImageSource = useMemo(() => { + if (typeof props.source === 'number') { + return props.source; + } + + // Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching) + if (id) { + return { + ...props.source, + cacheKey: id, + cachePath, + }; + } + + return props.source; + }, [id, props.source, cachePath]); + + // Process placeholder to add cachePath and cacheKey if it has a uri + const placeholder: ImageSource | undefined = useMemo(() => { + if (!props.placeholder || typeof props.placeholder === 'number' || typeof props.placeholder === 'string') { + return props.placeholder; + } + + // If placeholder has a uri and id is provided, add cachePath and cacheKey + if (props.placeholder.uri && id) { + return { + ...props.placeholder, + cacheKey: `${id}-thumb`, + cachePath, + }; + } + + return props.placeholder; + }, [props.placeholder, id, cachePath]); + + return ( + + ); +}); +ExpoImage.displayName = 'ExpoImage'; + +const ExpoImageBackground = ({id, ...props}: ExpoImageBackgroundProps) => { + const serverUrl = useServerUrl(); + const cachePath = useMemo(() => urlSafeBase64Encode(serverUrl), [serverUrl]); + const source: ImageSource = useMemo(() => { + if (typeof props.source === 'number') { + return props.source; + } + + // Only add cacheKey and cachePath if id is provided (i.e., not memory-only caching) + if (id) { + return { + ...props.source, + cacheKey: id, + cachePath, + }; + } + + return props.source; + }, [id, props.source, cachePath]); + + // Process placeholder to add cachePath and cacheKey if it has a uri + const placeholder: ImageSource | undefined = useMemo(() => { + if (!props.placeholder || typeof props.placeholder === 'number' || typeof props.placeholder === 'string') { + return props.placeholder; + } + + // If placeholder has a uri and id is provided, add cachePath and cacheKey + if (props.placeholder.uri && id) { + return { + ...props.placeholder, + cacheKey: `${id}-thumb`, + cachePath, + }; + } + + return props.placeholder; + }, [props.placeholder, id, cachePath]); + + return ( + + {props.children} + + ); +}; + +const ExpoImageAnimated = Animated.createAnimatedComponent(ExpoImage); + +export { + ExpoImageAnimated, + ExpoImageBackground, +}; + +export default ExpoImage; diff --git a/app/components/files/files.test.tsx b/app/components/files/files.test.tsx index 944e7f406..1ec5ae09d 100644 --- a/app/components/files/files.test.tsx +++ b/app/components/files/files.test.tsx @@ -296,6 +296,7 @@ describe('Files', () => { type: 'image', uri: file.uri || '', width: file.width, + cacheKey: file.id || '', })); const {getByTestId} = render( @@ -342,6 +343,7 @@ describe('Files', () => { type: 'image', uri: file.uri || '', width: file.width, + cacheKey: file.id || '', })); const {getByTestId} = render( diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 336fc6211..cf21c93f4 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -77,7 +77,7 @@ const Files = ({ }; const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => { - const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps)); + const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps, 0, f.id)); openGalleryAtIndex(galleryIdentifier, idx, items); }, [filesForGallery, galleryIdentifier, postProps])); diff --git a/app/components/files/video_file.tsx b/app/components/files/video_file.tsx index ef0da7fd5..335d3425a 100644 --- a/app/components/files/video_file.tsx +++ b/app/components/files/video_file.tsx @@ -14,6 +14,7 @@ import {useTheme} from '@context/theme'; import {getServerCredentials} from '@init/credentials'; import {fileExists} from '@utils/file'; import {calculateDimensions} from '@utils/images'; +import {urlSafeBase64Encode} from '@utils/security'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import FileIcon from './file_icon'; @@ -108,7 +109,8 @@ const VideoFile = ({ if (cred?.token) { headers.Authorization = `Bearer ${cred.token}`; } - const {uri, height, width} = await getThumbnailAsync(data.localPath || videoUrl, {time: 1000, headers}); + const cachePath = urlSafeBase64Encode(serverUrl); + const {uri, height, width} = await getThumbnailAsync(data.localPath || videoUrl, {time: 1000, headers, cachePath}); data.mini_preview = uri; data.height = height; data.width = width; diff --git a/app/components/floating_input/floating_text_input_label.tsx b/app/components/floating_input/floating_text_input_label.tsx index 0e49aac40..97025ab13 100644 --- a/app/components/floating_input/floating_text_input_label.tsx +++ b/app/components/floating_input/floating_text_input_label.tsx @@ -27,6 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ borderColor: changeOpacity(theme.centerChannelColor, 0.16), borderRadius: 4, ...typography('Body', 200), + lineHeight: undefined, }, })); diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index 5e4116661..ef703a5d7 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -22,10 +22,10 @@ import {useGalleryItem} from '@hooks/gallery'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; import {lookupMimeType} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; -import {generateId} from '@utils/general'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images'; import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown'; +import {urlSafeBase64Encode} from '@utils/security'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url'; @@ -88,15 +88,16 @@ const MarkdownImage = ({ const isTablet = useIsTablet(); const style = getStyleSheet(theme); const managedConfig = useManagedConfig(); + const sourceKey = removeImageProxyForKey(source); // Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents const genericFileRef = useRef(null); if (genericFileRef.current === null) { - genericFileRef.current = generateId('uid'); + genericFileRef.current = `uid-${urlSafeBase64Encode(sourceKey)}`; } const genericFileId = genericFileRef.current; - const metadata = secureGetFromRecord(imagesMetadata, removeImageProxyForKey(source)) || Object.values(imagesMetadata || {})[0]; + const metadata = secureGetFromRecord(imagesMetadata, sourceKey) || Object.values(imagesMetadata || {})[0]; const [failed, setFailed] = useState(() => isGifTooLarge(metadata)); const serverUrl = useServerUrl(); const galleryIdentifier = `${postId}-${genericFileId}-${location}`; @@ -129,7 +130,7 @@ const MarkdownImage = ({ const handlePreviewImage = useCallback(() => { const item: GalleryItemType = { - ...fileToGalleryItem(fileInfo), + ...fileToGalleryItem(fileInfo, undefined, undefined, 0, fileInfo.id), mime_type: lookupMimeType(fileInfo.name), type: 'image', }; diff --git a/app/components/markdown/markdown_table_image/index.tsx b/app/components/markdown/markdown_table_image/index.tsx index 2eb5ab935..c3942bfb0 100644 --- a/app/components/markdown/markdown_table_image/index.tsx +++ b/app/components/markdown/markdown_table_image/index.tsx @@ -12,9 +12,9 @@ import {useServerUrl} from '@context/server'; import {useGalleryItem} from '@hooks/gallery'; import {lookupMimeType} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; -import {generateId} from '@utils/general'; import {calculateDimensions, isGifTooLarge} from '@utils/images'; import {removeImageProxyForKey} from '@utils/markdown'; +import {urlSafeBase64Encode} from '@utils/security'; import {secureGetFromRecord} from '@utils/types'; import {safeDecodeURIComponent} from '@utils/url'; @@ -48,10 +48,13 @@ const MarkTableImage = ({ }: MarkdownTableImageProps) => { const sourceKey = removeImageProxyForKey(source); const metadata = secureGetFromRecord(imagesMetadata, sourceKey); - const fileId = useRef(generateId('uid')).current; + const fileId = useRef(null); + if (fileId.current === null) { + fileId.current = `uid-${urlSafeBase64Encode(sourceKey)}`; + } const [failed, setFailed] = useState(isGifTooLarge(metadata)); const currentServerUrl = useServerUrl(); - const galleryIdentifier = `${postId}-${fileId}-${location}`; + const galleryIdentifier = `${postId}-${fileId.current}-${location}`; const getImageSource = useCallback(() => { let uri = source; @@ -83,7 +86,7 @@ const MarkTableImage = ({ } return { - id: fileId, + id: fileId.current || '', name: filename, extension, has_preview_image: true, @@ -95,7 +98,7 @@ const MarkTableImage = ({ size: 0, user_id: '', }; - }, [fileId, getImageSource, metadata?.height, metadata?.width, postId]); + }, [getImageSource, metadata?.height, metadata?.width, postId]); const handlePreviewImage = useCallback(() => { const file = getFileInfo(); @@ -103,7 +106,7 @@ const MarkTableImage = ({ return; } const item: GalleryItemType = { - ...fileToGalleryItem(file), + ...fileToGalleryItem(file, undefined, undefined, 0, file.id), type: 'image', }; openGalleryAtIndex(galleryIdentifier, 0, [item]); @@ -136,7 +139,7 @@ const MarkTableImage = ({ > { if (isValidUrl(icon) && !failedToLoadImage) { return ( - { testID='scheduled_post_tutorial_tooltip' > - { - const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId)); + const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId, undefined, 0, f.id || f.clientId)); const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId); openGalleryAtIndex(galleryIdentifier, index, items, true); }, [currentUserId, galleryIdentifier]); diff --git a/app/components/post_list/post/avatar/avatar.tsx b/app/components/post_list/post/avatar/avatar.tsx index 2d9e2bb55..25b185c7c 100644 --- a/app/components/post_list/post/avatar/avatar.tsx +++ b/app/components/post_list/post/avatar/avatar.tsx @@ -1,13 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image} from 'expo-image'; import React, {useCallback, type ReactNode} from 'react'; import {useIntl} from 'react-intl'; import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import CompassIcon from '@components/compass_icon'; +import ExpoImage from '@components/expo_image'; import ProfilePicture from '@components/profile_picture'; import {View as ViewConstant} from '@constants'; import {useServerUrl} from '@context/server'; @@ -55,7 +55,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: if (overrideIconUrl) { const source = {uri: overrideIconUrl}; iconComponent = ( - (null); const [imageUrl, setImageUrl] = useState(expandedLink || link); + if (fileId.current === null) { + fileId.current = `uid-${urlSafeBase64Encode(imageUrl)}`; + } const isTablet = useIsTablet(); const imageProps = secureGetFromRecord(metadata?.images, link); const dimensions = calculateDimensions(imageProps?.height, imageProps?.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet)); @@ -65,7 +68,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m const onPress = () => { const item: GalleryItemType = { - id: fileId, + id: fileId.current || '', postId, uri: imageUrl, width: imageProps?.width || 0, @@ -74,6 +77,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m mime_type: lookupMimeType(imageUrl) || 'image/png', type: 'image', lastPictureUpdate: 0, + cacheKey: fileId.current || '', }; openGalleryAtIndex(galleryIdentifier, 0, [item]); }; @@ -129,7 +133,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m { return ( {Boolean(icon) && - { return ( {Boolean(icon) && - { const galleryIdentifier = `${postId}-AttachmentImage-${location}`; const [error, setError] = useState(false); - const fileId = useRef(generateId('uid')).current; + const fileId = useRef(null); + if (fileId.current === null) { + fileId.current = `uid-${urlSafeBase64Encode(imageUrl)}`; + } const isTablet = useIsTablet(); const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet, true)); const style = getStyleSheet(theme); @@ -67,7 +70,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId const onPress = () => { const item: GalleryItemType = { - id: fileId, + id: fileId.current || '', postId, uri: imageUrl, width: imageMetadata.width, @@ -76,6 +79,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId mime_type: lookupMimeType(imageUrl) || 'image/png', type: 'image', lastPictureUpdate: 0, + cacheKey: fileId.current || '', }; openGalleryAtIndex(galleryIdentifier, 0, [item]); }; @@ -105,7 +109,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId { return ( - { - const fileId = useRef(generateId('uid')).current; + const fileId = useRef(null); + if (fileId.current === null) { + fileId.current = `uid-opengraph-image-${postId}`; + } const dimensions = useWindowDimensions(); const style = getStyleSheet(theme); const galleryIdentifier = `${postId}-OpenGraphImage-${location}`; @@ -67,7 +70,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph const bestDimensions = useMemo(() => ({ height: MAX_IMAGE_HEIGHT, width: layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width), - }), [isReplyPost, dimensions]); + }), [layoutWidth, isReplyPost, dimensions.height, dimensions.width]); const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height'); const imageUrl = (bestImage.secure_url || bestImage.url)!; const imagesMetadata = metadata?.images; @@ -91,7 +94,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph const onPress = () => { const item: GalleryItemType = { - id: fileId, + id: fileId.current!, postId, uri: imageUrl, width: imageDimensions.width, @@ -100,6 +103,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph mime_type: lookupMimeType(imageUrl) || 'image/png', type: 'image', lastPictureUpdate: 0, + cacheKey: fileId.current!, }; openGalleryAtIndex(galleryIdentifier, 0, [item]); }; @@ -120,13 +124,14 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph - - + diff --git a/app/components/post_list/post/body/content/youtube/index.tsx b/app/components/post_list/post/body/content/youtube/index.tsx index 502558be4..42ec03bfe 100644 --- a/app/components/post_list/post/body/content/youtube/index.tsx +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -1,11 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ImageBackground} from 'expo-image'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {StyleSheet, TouchableOpacity} from 'react-native'; +import {ExpoImageBackground} from '@components/expo_image'; import {useIsTablet} from '@hooks/device'; import {calculateDimensions, getViewPortWidth} from '@utils/images'; import {changeOpacity} from '@utils/theme'; @@ -88,13 +88,14 @@ const YouTube = ({isReplyPost, layoutWidth, metadata}: YouTubeProps) => { style={[styles.imageContainer, {height: dimensions.height, width: dimensions.width}]} onPress={playYouTubeVideo} > - - + ); }; diff --git a/app/components/profile_picture/image.tsx b/app/components/profile_picture/image.tsx index 5b1d61e52..8af76bd79 100644 --- a/app/components/profile_picture/image.tsx +++ b/app/components/profile_picture/image.tsx @@ -1,13 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image as ExpoImage, type ImageSource} from 'expo-image'; +import {type ImageSource} from 'expo-image'; import React, {useMemo} from 'react'; -import Animated from 'react-native-reanimated'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrlFromUser} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; +import {ExpoImageAnimated} from '@components/expo_image'; import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; @@ -25,8 +25,6 @@ type Props = { url?: string; }; -const AnimatedImage = Animated.createAnimatedComponent(ExpoImage); - const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { icon: { @@ -62,6 +60,13 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => { // in the containing object (author). // eslint-disable-next-line react-hooks/exhaustive-deps }, [author, serverUrl, source, lastPictureUpdateAt]); + const id = useMemo(() => { + if (author) { + return `user-${author.id}-${lastPictureUpdateAt}`; + } + + return undefined; + }, [author, lastPictureUpdateAt]); if (typeof source === 'string') { return ( @@ -75,8 +80,9 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => { if (imgSource?.uri?.startsWith('file://')) { return ( - { if (imgSource) { return ( - ; @@ -65,14 +64,14 @@ const ProgressiveImage = ({ if (isBackgroundImage && imageUri) { return ( - ]} > {children} - + ); } @@ -80,7 +79,8 @@ const ProgressiveImage = ({ if (defaultSource) { return ( - - = [imageStyles]; imageStyle.push({width: 24, height: 24}); image = ( - diff --git a/app/components/team_sidebar/team_list/team_item/team_icon.tsx b/app/components/team_sidebar/team_list/team_item/team_icon.tsx index 838330e42..1c5a76b7f 100644 --- a/app/components/team_sidebar/team_list/team_item/team_icon.tsx +++ b/app/components/team_sidebar/team_list/team_item/team_icon.tsx @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image} from 'expo-image'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View, Text, type StyleProp, type TextStyle} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildTeamIconUrl} from '@actions/remote/team'; +import ExpoImage from '@components/expo_image'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import NetworkManager from '@managers/network_manager'; @@ -124,7 +124,8 @@ export default function TeamIcon({ ); } else { teamIconContent = ( - - ); @@ -237,7 +238,7 @@ export default function UploadItemShared({ /> ); - }, [file.uri, imageFileData, forwardRef, inViewPort, isImageFile, isShareExtension, style.imageOnlyThumbnail, style.imageOnlyImage, style.iconContainer, fileForCheck]); + }, [isImageFile, style.iconContainer, style.imageOnlyThumbnail, style.imageOnlyImage, fileForCheck, isShareExtension, imageFileData, forwardRef, inViewPort, file.uri]); const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || ''; const formattedSize = getFormattedFileSize(file.size || 0); diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index 91eb54f10..3c7e787e4 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -807,6 +807,8 @@ exports[`components/channel_list_row should show results and tutorial 1`] = ` source={ [ { + "cacheKey": "user-1-123456", + "cachePath": "", "uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456", }, ] @@ -1148,6 +1150,8 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` source={ [ { + "cacheKey": "user-1-123456", + "cachePath": "", "uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456", }, ] @@ -1527,6 +1531,8 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] source={ [ { + "cacheKey": "user-1-123456", + "cachePath": "", "uri": "https://community.mattermost.com/api/v4/users/1/image?_=123456", }, ] @@ -1767,6 +1773,8 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`] source={ [ { + "cacheKey": "user-2-123456", + "cachePath": "", "uri": "https://community.mattermost.com/api/v4/users/2/image?_=123456", }, ] diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx index df20b2614..a938a8deb 100644 --- a/app/components/user_list/index.test.tsx +++ b/app/components/user_list/index.test.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React, {type ComponentProps} from 'react'; -import {Image} from 'react-native'; import {Screens} from '@constants'; import {Ringtone} from '@constants/calls'; @@ -93,20 +92,11 @@ describe('components/channel_list_row', () => { }, }; - const originalResolveAssetSource = Image.resolveAssetSource; + // const originalResolveAssetSource = Image.resolveAssetSource; beforeAll(async () => { const server = await TestHelper.setupServerDatabase(); database = server.database; - - // This is needed to properly populate the URLs until - // https://github.com/facebook/react-native/pull/43497 - // gets into React Native Jest code. - Image.resolveAssetSource = jest.fn().mockImplementation((source) => source); - }); - - afterAll(() => { - Image.resolveAssetSource = originalResolveAssetSource; }); function getBaseProps(): ComponentProps { diff --git a/app/constants/database.ts b/app/constants/database.ts index a487e6025..852adf00f 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -88,6 +88,7 @@ export const GLOBAL_IDENTIFIERS = { LAST_VIEWED_CHANNEL: 'lastViewedChannel', LAST_VIEWED_THREAD: 'lastViewedThread', PUSH_DISABLED_ACK: 'pushDisabledAck', + CACHE_MIGRATION: 'cacheMigration', }; export enum OperationType { diff --git a/app/constants/device.ts b/app/constants/device.ts index 62a9fedb2..7488c6e5a 100644 --- a/app/constants/device.ts +++ b/app/constants/device.ts @@ -1,12 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {cacheDirectory} from 'expo-file-system'; - import {isTablet} from '@utils/helpers'; export default { - DOCUMENTS_PATH: `${cacheDirectory}/Documents`, IS_TABLET: isTablet(), PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn', PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn', diff --git a/app/constants/events.ts b/app/constants/events.ts index 83844663c..61344211d 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -10,6 +10,7 @@ export default keyMirror({ CLOSE_BOTTOM_SHEET: null, CLOSE_GALLERY: null, CONFIG_CHANGED: null, + LICENSE_CHANGED: null, FREEZE_SCREEN: null, GALLERY_ACTIONS: null, LEAVE_CHANNEL: null, @@ -34,5 +35,6 @@ export default keyMirror({ JOIN_CALL_BAR_VISIBLE: null, DRAFT_SWIPEABLE: null, ACTIVE_SCREEN: null, + ACTIVE_SERVER_CHANGED: null, FILE_ADD_REMOVED: null, }); diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 7a2a29bcb..11b8484d4 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -7,6 +7,7 @@ import logger from '@nozbe/watermelondb/utils/common/logger'; import {deleteAsync} from 'expo-file-system'; import {DeviceEventEmitter, Platform} from 'react-native'; +import {Events} from '@constants'; import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; @@ -243,15 +244,16 @@ class DatabaseManagerSingleton { return undefined; }; - public setActiveServerDatabase = async (serverUrl: string): Promise => { + public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise => { if (this.appDatabase?.database) { const database = this.appDatabase?.database; await database.write(async () => { const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch(); if (servers.length) { - servers[0].update((server: ServersModel) => { + await servers[0].update((server: ServersModel) => { server.lastActiveAt = Date.now(); }); + DeviceEventEmitter.emit(Events.ACTIVE_SERVER_CHANGED, {serverUrl, options}); } }); } diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 91578c63e..84d75d7ec 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -8,6 +8,7 @@ import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'; import {deleteAsync, documentDirectory, getInfoAsync, makeDirectoryAsync, moveAsync} from 'expo-file-system'; import {DeviceEventEmitter, Platform} from 'react-native'; +import {Events} from '@constants'; import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; @@ -334,15 +335,16 @@ class DatabaseManagerSingleton { * @param {string} serverUrl * @returns {Promise} */ - public setActiveServerDatabase = async (serverUrl: string): Promise => { + public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise => { if (this.appDatabase?.database) { const database = this.appDatabase?.database; await database.write(async () => { const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch(); if (servers.length) { - servers[0].update((server: ServersModel) => { + await servers[0].update((server: ServersModel) => { server.lastActiveAt = Date.now(); }); + DeviceEventEmitter.emit(Events.ACTIVE_SERVER_CHANGED, {serverUrl, options}); } }); } diff --git a/app/hooks/files.ts b/app/hooks/files.ts index f12bc5030..4101fee94 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -45,7 +45,7 @@ const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], let {width, height} = fileInfo; if (imageFile && !width) { - const size = await getImageSize(uri); + const size = await getImageSize(serverUrl, uri, b.fileId); width = size.width; height = size.height; } diff --git a/app/init/app.ts b/app/init/app.ts index bb4362299..4be8e11fb 100644 --- a/app/init/app.ts +++ b/app/init/app.ts @@ -9,6 +9,7 @@ import ManagedApp from '@init/managed_app'; import PushNotifications from '@init/push_notifications'; import GlobalEventHandler from '@managers/global_event_handler'; import NetworkManager from '@managers/network_manager'; +import SecurityManager from '@managers/security_manager'; import SessionManager from '@managers/session_manager'; import WebsocketManager from '@managers/websocket_manager'; import {registerScreens} from '@screens/index'; @@ -44,6 +45,7 @@ export async function initialize() { await DatabaseManager.init(serverUrls); await NetworkManager.init(serverCredentials); + await SecurityManager.init(); GlobalEventHandler.init(); ManagedApp.init(); diff --git a/app/managers/intune_manager/index.test.ts b/app/managers/intune_manager/index.test.ts new file mode 100644 index 000000000..24f44e4bd --- /dev/null +++ b/app/managers/intune_manager/index.test.ts @@ -0,0 +1,387 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Unmock IntuneManager for this test file since we're testing it +jest.unmock('@managers/intune_manager'); + +import {Platform, type EventSubscription} from 'react-native'; + +import {License} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getConfig, getLicense} from '@queries/servers/system'; +import {isMinimumLicenseTier} from '@utils/helpers'; + +import IntuneManager from '.'; + +import type {MSALIdentity, MSALTokens, IntunePolicy} from './types'; +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn(), + getActiveServerUrl: jest.fn(), +})); +jest.mock('@queries/servers/system'); +jest.mock('@utils/alerts'); +jest.mock('@utils/helpers'); +jest.mock('@mattermost/react-native-emm'); +jest.mock('@managers/security_manager', () => ({ + __esModule: true, + default: {}, +})); + +const mockedGetConfig = jest.mocked(getConfig); +const mockedGetLicense = jest.mocked(getLicense); +const mockedIsMinimumLicenseTier = jest.mocked(isMinimumLicenseTier); + +// Get mocked Intune from the global mock +const Intune = jest.requireMock<{default: any}>('@mattermost/intune').default; + +describe('IntuneManager', () => { + const serverUrl = 'https://example.com'; + const mockDatabase = {} as unknown as Database; + + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({ + database: mockDatabase, + operator: {} as ServerDataOperator, + }); + + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl); + }); + + describe('isIntuneMAMEnabledForServer', () => { + it('should return false when database does not exist', async () => { + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => { + throw new Error('Database not found'); + }); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(false); + }); + + it('should return false when license is below EnterpriseAdvanced tier', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(false); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(false); + expect(mockedIsMinimumLicenseTier).toHaveBeenCalledWith({}, License.SKU_SHORT_NAME.EnterpriseAdvanced); + }); + + it('should return false when IntuneMAMEnabled is false', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(false); + }); + + it('should return false when IntuneScope is missing', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(false); + }); + + it('should return false when IntuneAuthService is missing', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(false); + }); + + it('should return true when all conditions are met', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + + const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + expect(result).toBe(true); + }); + }); + + describe('login', () => { + const scopes = ['User.Read', 'custom.scope']; + const mockTokens: MSALTokens = { + idToken: 'id_token', + accessToken: 'access_token', + identity: {upn: 'user@example.com', tid: 'tenant_id', oid: 'object_id'}, + }; + + it('should return tokens on successful login', async () => { + jest.mocked(Intune.login).mockResolvedValue(mockTokens); + + const result = await IntuneManager.login(serverUrl, scopes); + expect(result).toEqual(mockTokens); + expect(jest.mocked(Intune.login)).toHaveBeenCalledWith(serverUrl, scopes); + }); + + it('should throw error on login failure', async () => { + const error = new Error('Login failed'); + jest.mocked(Intune.login).mockRejectedValue(error); + + await expect(IntuneManager.login(serverUrl, scopes)).rejects.toThrow('Login failed'); + }); + }); + + describe('enrollServer', () => { + const mockIdentity: MSALIdentity = { + upn: 'user@example.com', + tid: 'tenant_id', + oid: 'object_id', + }; + + it('should call enrollInMAM on successful enrollment', async () => { + jest.mocked(Intune.enrollInMAM).mockResolvedValue(); + + await IntuneManager.enrollServer(serverUrl, mockIdentity); + expect(jest.mocked(Intune.enrollInMAM)).toHaveBeenCalledWith(serverUrl, mockIdentity); + }); + + it('should throw error on enrollment failure', async () => { + const error = new Error('Enrollment failed'); + jest.mocked(Intune.enrollInMAM).mockRejectedValue(error); + + await expect(IntuneManager.enrollServer(serverUrl, mockIdentity)).rejects.toThrow('Enrollment failed'); + }); + }); + + describe('unenrollServer', () => { + it('should skip unenrollment when server not enrolled', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(false); + + await IntuneManager.unenrollServer(serverUrl, false); + expect(jest.mocked(Intune.deregisterAndUnenroll)).not.toHaveBeenCalled(); + }); + + it('should clear current identity when unenrolling active server', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(Intune.setCurrentIdentity).mockResolvedValue(); + jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue(); + + await IntuneManager.unenrollServer(serverUrl, false); + expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null); + expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false); + }); + + it('should not clear identity when unenrolling non-active server', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('https://other.com'); + jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue(); + + await IntuneManager.unenrollServer(serverUrl, false); + expect(jest.mocked(Intune.setCurrentIdentity)).not.toHaveBeenCalled(); + expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false); + }); + + it('should call deregisterAndUnenroll with doWipe = true', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue(); + + await IntuneManager.unenrollServer(serverUrl, true); + expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, true); + }); + + it('should catch and log errors on unenrollment failure', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(Intune.deregisterAndUnenroll).mockRejectedValue(new Error('Unenroll failed')); + + await IntuneManager.unenrollServer(serverUrl, false); + }); + }); + + describe('isManagedServer', () => { + it('should return true when server is managed', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + + const result = await IntuneManager.isManagedServer(serverUrl); + expect(result).toBe(true); + expect(jest.mocked(Intune.isManagedServer)).toHaveBeenCalledWith(serverUrl); + }); + + it('should return false when server is not managed', async () => { + jest.mocked(Intune.isManagedServer).mockResolvedValue(false); + + const result = await IntuneManager.isManagedServer(serverUrl); + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + jest.mocked(Intune.isManagedServer).mockRejectedValue(new Error('Check failed')); + + const result = await IntuneManager.isManagedServer(serverUrl); + expect(result).toBe(false); + }); + }); + + describe('setCurrentIdentity', () => { + it('should set null identity', async () => { + jest.mocked(Intune.setCurrentIdentity).mockResolvedValue(); + + await IntuneManager.setCurrentIdentity(null); + expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null); + }); + + it('should set identity when server is licensed', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + jest.mocked(Intune.setCurrentIdentity).mockResolvedValue(); + + await IntuneManager.setCurrentIdentity(serverUrl); + expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(serverUrl); + }); + + it('should clear identity when server is not licensed', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(false); + jest.mocked(Intune.setCurrentIdentity).mockResolvedValue(); + + await IntuneManager.setCurrentIdentity(serverUrl); + expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null); + }); + }); + + describe('getPolicy', () => { + const mockPolicy: IntunePolicy = { + isPINRequired: true, + isContactSyncAllowed: false, + isWidgetContentSyncAllowed: false, + isSpotlightIndexingAllowed: false, + areSiriIntentsAllowed: false, + areAppIntentsAllowed: false, + isAppSharingAllowed: false, + isManagedBrowserRequired: false, + isFileEncryptionRequired: false, + isScreenCaptureAllowed: false, + shouldFileProviderEncryptFiles: false, + notificationPolicy: 0, + allowedSaveLocations: { + Other: false, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: false, + PhotoLibrary: false, + CameraRoll: false, + FilesApp: false, + iCloudDrive: false, + }, + allowedOpenLocations: 0, + }; + + it('should return null when Intune not enabled for server', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(false); + + const result = await IntuneManager.getPolicy(serverUrl); + expect(result).toBeNull(); + }); + + it('should return null when server not managed', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + jest.mocked(Intune.isManagedServer).mockResolvedValue(false); + + const result = await IntuneManager.getPolicy(serverUrl); + expect(result).toBeNull(); + }); + + it('should return policy when server is managed', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'saml'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(Intune.getPolicy).mockResolvedValue(mockPolicy); + + const result = await IntuneManager.getPolicy(serverUrl); + expect(result).toEqual(mockPolicy); + expect(jest.mocked(Intune.getPolicy)).toHaveBeenCalledWith(serverUrl); + }); + + it('should return null on error', async () => { + mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig); + mockedGetLicense.mockResolvedValue({} as ClientLicense); + mockedIsMinimumLicenseTier.mockReturnValue(true); + jest.mocked(Intune.isManagedServer).mockResolvedValue(true); + jest.mocked(Intune.getPolicy).mockRejectedValue(new Error('Get policy failed')); + + const result = await IntuneManager.getPolicy(serverUrl); + expect(result).toBeNull(); + }); + }); + + describe('Event Subscription Methods', () => { + const mockHandler = jest.fn(); + const mockSubscription = {remove: jest.fn()} as unknown as EventSubscription; + + beforeEach(() => { + jest.mocked(Intune.onIntunePolicyChanged).mockReturnValue(mockSubscription); + jest.mocked(Intune.onIntuneEnrollmentChanged).mockReturnValue(mockSubscription); + jest.mocked(Intune.onIntuneWipeRequested).mockReturnValue(mockSubscription); + jest.mocked(Intune.onIntuneAuthRequired).mockReturnValue(mockSubscription); + jest.mocked(Intune.onIntuneConditionalLaunchBlocked).mockReturnValue(mockSubscription); + jest.mocked(Intune.onIntuneIdentitySwitchRequired).mockReturnValue(mockSubscription); + }); + + describe('subscribeToPolicyChanges', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToPolicyChanges(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntunePolicyChanged)).toHaveBeenCalledWith(mockHandler); + }); + }); + + describe('subscribeToEnrollmentChanges', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToEnrollmentChanges(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntuneEnrollmentChanged)).toHaveBeenCalledWith(mockHandler); + }); + }); + + describe('subscribeToWipeRequests', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToWipeRequests(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntuneWipeRequested)).toHaveBeenCalledWith(mockHandler); + }); + }); + + describe('subscribeToAuthRequired', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToAuthRequired(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntuneAuthRequired)).toHaveBeenCalledWith(mockHandler); + }); + }); + + describe('subscribeToConditionalLaunchBlocked', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToConditionalLaunchBlocked(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntuneConditionalLaunchBlocked)).toHaveBeenCalledWith(mockHandler); + }); + }); + + describe('subscribeToIdentitySwitchRequired', () => { + it('should return EventSubscription when Intune library available', () => { + const result = IntuneManager.subscribeToIdentitySwitchRequired(mockHandler); + expect(result).toBe(mockSubscription); + expect(jest.mocked(Intune.onIntuneIdentitySwitchRequired)).toHaveBeenCalledWith(mockHandler); + }); + }); + }); +}); diff --git a/app/managers/intune_manager/index.ts b/app/managers/intune_manager/index.ts new file mode 100644 index 000000000..744845294 --- /dev/null +++ b/app/managers/intune_manager/index.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Emm from '@mattermost/react-native-emm'; +import {Platform, type EventSubscription} from 'react-native'; + +import {getCurrentUserLocale} from '@actions/local/user'; +import {License} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getConfig, getLicense} from '@queries/servers/system'; +import {showBiometricFailureAlertForOrganization} from '@utils/alerts'; +import {isMinimumLicenseTier} from '@utils/helpers'; +import {logDebug, logError, logWarning} from '@utils/log'; + +import type { + IntuneAuthRequiredEvent, + IntuneConditionalLaunchBlockedEvent, + IntuneEnrollmentChangedEvent, + IntuneIdentitySwitchRequiredEvent, + IntunePolicyChangedEvent, + IntuneWipeRequestedEvent, + MSALIdentity, + MSALTokens, + IntuneSpec, + IntunePolicy, +} from './types'; + +let Intune: IntuneSpec | null = null; +if (Platform.OS === 'ios') { + try { + Intune = require('@mattermost/intune').default; + } catch { + // Intune library not available + logWarning('Intune library not available - MAM features disabled'); + } +} + +/** + * IntuneManager - Thin wrapper for Microsoft Intune MAM integration + * + * Provides helper methods for enrollment, policy access, and identity management. + * Event handling is delegated to SecurityManager via subscription methods. + */ +export class IntuneManagerSingleton { + + isIntuneEnabledForConfigAndLicense(config: ClientConfig, license?: ClientLicense): boolean { + return Boolean(Intune) && isMinimumLicenseTier(license, License.SKU_SHORT_NAME.EnterpriseAdvanced) && + config.IntuneMAMEnabled === 'true' && Boolean(config.IntuneScope) && Boolean(config.IntuneAuthService); + } + + async isIntuneMAMEnabledForServer(serverUrl: string): Promise { + if (!Intune) { + return false; + } + + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const config = await getConfig(database); + const license = await getLicense(database); + return this.isIntuneEnabledForConfigAndLicense(config, license); + } catch { + return false; + } + } + + /** + * Native OIDC login: acquire MSAL tokens with custom scopes + * @param serverUrl - The Mattermost server URL (used to store scope) + * @param scopes - Array of OAuth scopes (e.g., IntuneScope from server) + * @returns MSALTokens containing idToken, accessToken, and identity + */ + async login(serverUrl: string, scopes: string[]): Promise { + if (!Intune) { + throw new Error('IntuneManager: Intune library not available'); + } + + try { + logDebug('IntuneManager: Starting native OIDC login'); + const tokens = await Intune.login(serverUrl, scopes); + return tokens; + } catch (error) { + logError('IntuneManager: Native OIDC login failed', error); + throw error; + } + } + + /** + * Enroll a server in MAM + * Automatically acquires MAM consent before enrolling to prevent duplicate prompts + * @param serverUrl - The Mattermost server URL + * @param identity - The MSAL identity (upn, tid, oid) + */ + async enrollServer(serverUrl: string, identity: MSALIdentity): Promise { + if (!Intune) { + logWarning('IntuneManager: Cannot enroll in MAM - Intune library not available'); + return; + } + + try { + logDebug('IntuneManager: Enrolling in MAM'); + await Intune.enrollInMAM(serverUrl, identity); + } catch (error) { + logError('IntuneManager: MAM enrollment failed', error); + throw error; + } + } + + /** + * Unenroll a server from Intune MAM + * @param serverUrl - The Mattermost server URL + * @param doWipe - Whether to perform selective wipe + */ + async unenrollServer(serverUrl: string, doWipe: boolean): Promise { + if (!Intune) { + logWarning('IntuneManager: Cannot unenroll - Intune library not available'); + return; + } + + try { + const currentServer = await DatabaseManager.getActiveServerUrl(); + const isManaged = await Intune.isManagedServer(serverUrl); + if (!isManaged) { + logDebug('IntuneManager: Server not enrolled in MAM, skipping unenrollment'); + return; + } + logDebug('IntuneManager: Starting unenrollment', {doWipe}); + if (currentServer === serverUrl) { + await Intune.setCurrentIdentity(null); + } + await Intune.deregisterAndUnenroll(serverUrl, doWipe); + } catch (error) { + logError('IntuneManager: Unenrollment failed', error); + } + } + + /** + * Cleanup storage and MSAL account after selective wipe completes + * This is called by SecurityManager after wipe operations complete successfully + * to remove the OID-to-serverUrl mappings from keychain and delete the MSAL account. + * @param oid - The Object ID (OID) to cleanup + */ + async cleanupAfterWipe(oid: string): Promise { + if (!Intune) { + return; + } + + try { + await Intune.cleanupAfterWipe(oid); + } catch (error) { + logError('IntuneManager: Cleanup after wipe failed', error); + } + } + + /** + * Check if a server is managed by Intune + * @param serverUrl - The Mattermost server URL + * @returns true if server is Intune-managed + */ + async isManagedServer(serverUrl: string): Promise { + if (!Intune) { + return false; + } + + try { + return await Intune.isManagedServer(serverUrl); + } catch (error) { + logError('IntuneManager: Failed to check managed status', error); + return false; + } + } + + /** + * Set the current identity for Intune SDK context + * @param serverUrl - The server URL to set as current, or null to clear + */ + async setCurrentIdentity(serverUrl: string | null): Promise { + if (!Intune) { + return; + } + + try { + if (serverUrl) { + const isEnabled = await this.isIntuneMAMEnabledForServer(serverUrl); + if (!isEnabled) { + logDebug('IntuneManager: Server is not licensed or configured, clearing current identity'); + await Intune.setCurrentIdentity(null); + logDebug('IntuneManager: Current identity cleared'); + return; + } + } + + await Intune.setCurrentIdentity(serverUrl); + const identity = serverUrl ? 'set' : 'cleared'; + logDebug(`IntuneManager: Current identity ${identity}`); + } catch (error) { + logError('IntuneManager: Failed to set current identity', error); + if (serverUrl) { + await new Promise((resolve) => setTimeout(resolve, 250)); + Emm.enableBlurScreen(true); + Emm.applyBlurEffect(20); + const locale = await getCurrentUserLocale(serverUrl); + await showBiometricFailureAlertForOrganization(serverUrl, locale, () => { + Emm.removeBlurEffect(); + this.setCurrentIdentity(serverUrl); + }); + Emm.enableBlurScreen(false); + } + } + } + + /** + * Get the Intune policy for a server + * @param serverUrl - The Mattermost server URL + * @returns IntunePolicy if server is managed, null otherwise + */ + async getPolicy(serverUrl: string): Promise { + if (!Intune) { + return null; + } + + try { + const isIntuneEnabled = await this.isIntuneMAMEnabledForServer(serverUrl); + if (!isIntuneEnabled) { + return null; + } + const isManaged = await Intune.isManagedServer(serverUrl); + if (!isManaged) { + return null; + } + + return await Intune.getPolicy(serverUrl); + } catch (error) { + logError('IntuneManager: Failed to get policy', error); + return null; + } + } + + /** + * Subscribe to Intune policy change events + * @param handler - The callback to handle policy changes + * @returns EventSubscription that can be removed + */ + subscribeToPolicyChanges(handler: (event: IntunePolicyChangedEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntunePolicyChanged(handler); + } + + /** + * Subscribe to Intune enrollment change events + * @param handler - The callback to handle enrollment changes + * @returns EventSubscription that can be removed + */ + subscribeToEnrollmentChanges(handler: (event: IntuneEnrollmentChangedEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntuneEnrollmentChanged(handler); + } + + /** + * Subscribe to Intune wipe requested events + * @param handler - The callback to handle wipe requests + * @returns EventSubscription that can be removed + */ + subscribeToWipeRequests(handler: (event: IntuneWipeRequestedEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntuneWipeRequested(handler); + } + + /** + * Subscribe to Intune auth required events + * @param handler - The callback to handle auth requirements + * @returns EventSubscription that can be removed + */ + subscribeToAuthRequired(handler: (event: IntuneAuthRequiredEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntuneAuthRequired(handler); + } + + /** + * Subscribe to Intune conditional launch blocked events + * @param handler - The callback to handle conditional launch blocks + * @returns EventSubscription that can be removed + */ + subscribeToConditionalLaunchBlocked(handler: (event: IntuneConditionalLaunchBlockedEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntuneConditionalLaunchBlocked(handler); + } + + /** + * Subscribe to Intune identity switch required events + * @param handler - The callback to handle identity switch requirements + * @returns EventSubscription that can be removed + */ + subscribeToIdentitySwitchRequired(handler: (event: IntuneIdentitySwitchRequiredEvent) => void): EventSubscription | undefined { + if (!Intune) { + return undefined; + } + + return Intune.onIntuneIdentitySwitchRequired(handler); + } + + /** + * Report wipe completion status to native layer + * Clears pending state if successful, retains for retry if failed + * @param oid - The OID to report completion for + * @param success - Whether the wipe was successful + */ + async reportWipeComplete(oid: string, success: boolean): Promise { + if (!Intune) { + return; + } + + try { + await Intune.reportWipeComplete(oid, success); + logDebug(`IntuneManager: Wipe completion reported (success: ${success})`); + } catch (error) { + logError('IntuneManager: Failed to report wipe completion', error); + } + } + + /** + * Get pending wipes that need to be retried + * Filters out stale wipes (> 7 days old) automatically + * @returns Array of pending wipes (empty array if none) + */ + async getPendingWipes(): Promise> { + if (!Intune) { + return []; + } + + try { + const pendingWipes = await Intune.getPendingWipes(); + logDebug(`IntuneManager: Retrieved ${pendingWipes.length} pending wipe(s)`); + return pendingWipes; + } catch (error) { + logError('IntuneManager: Failed to get pending wipes', error); + return []; + } + } +} + +const IntuneManager = new IntuneManagerSingleton(); +export default IntuneManager; diff --git a/app/managers/intune_manager/types.ts b/app/managers/intune_manager/types.ts new file mode 100644 index 000000000..d65bb71ac --- /dev/null +++ b/app/managers/intune_manager/types.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * Re-exports Intune types and enums with fallback stubs when disabled. + * This allows other files to import Intune types without conditional logic. + * + * IMPORTANT: This file duplicates types from @mattermost/intune library. + * When updating Intune library types, update these types to match. + */ + +import {Platform, type EventSubscription} from 'react-native'; + +// Try to import enums from Intune library at runtime +let IntuneAuthRequiredReasonsExport: Record | null = null; +let IntuneConditionalLaunchBlockedReasonsExport: Record | null = null; + +if (Platform.OS === 'ios') { + try { + const errors = require('@mattermost/intune/src/errors'); + IntuneAuthRequiredReasonsExport = errors.IntuneAuthRequiredReasons; + IntuneConditionalLaunchBlockedReasonsExport = errors.IntuneConditionalLaunchBlockedReasons; + } catch { + // Intune library not available - use stub enums below + } +} + +// Export enum objects - can be used both as type and runtime value +// These match the string values used by the native iOS module +export const IntuneConditionalLaunchBlockedReasons = IntuneConditionalLaunchBlockedReasonsExport ?? { + LAUNCH_BLOCKED: 'launch_blocked' as const, + LAUNCH_CANCELED: 'launch_canceled' as const, +}; + +export const IntuneAuthRequiredReasons = IntuneAuthRequiredReasonsExport ?? { + CONSENT_DENIED: 'consent_denied' as const, + AUTH_FAILED: 'auth_failed' as const, + TOKEN_REFRESH_FAILED: 'refresh_token_failed' as const, +}; + +// ============================================================================ +// Type definitions - must match @mattermost/intune library types exactly +// ============================================================================ + +export type IntuneMAMSaveLocation = { + Other: boolean; + OneDriveForBusiness: boolean; + SharePoint: boolean; + LocalDrive: boolean; + PhotoLibrary: boolean; + CameraRoll: boolean; + FilesApp: boolean; + iCloudDrive: boolean; +}; + +export type IntunePolicy = Readonly<{ + isPINRequired: boolean; + isContactSyncAllowed: boolean; + isWidgetContentSyncAllowed: boolean; + isSpotlightIndexingAllowed: boolean; + areSiriIntentsAllowed: boolean; + areAppIntentsAllowed: boolean; + isAppSharingAllowed: boolean; + shouldFileProviderEncryptFiles: boolean; + isManagedBrowserRequired: boolean; + isFileEncryptionRequired: boolean; + isScreenCaptureAllowed: boolean; + notificationPolicy: number; + allowedSaveLocations: IntuneMAMSaveLocation; + allowedOpenLocations: number; +}>; + +export type MSALIdentity = Readonly<{ + upn: string; + tid: string; + oid: string; +}>; + +export type MSALTokens = Readonly<{ + idToken: string; + accessToken: string; + identity: MSALIdentity; +}>; + +export type IntuneAuthRequiredEvent = Readonly<{ + oid: string; + serverUrls: string[]; + reason?: string; +}>; + +export type IntuneConditionalLaunchBlockedEvent = Readonly<{ + oid: string; + serverUrls: string[]; + reason: string; +}>; + +export type IntuneEnrollmentChangedEvent = Readonly<{ + enrolled: boolean; + oid: string; + serverUrls: string[]; + reason?: string; +}>; + +export type IntuneIdentitySwitchRequiredEvent = Readonly<{ + oid: string; + serverUrls: string[]; + reason: string; +}>; + +export type IntunePolicyChangedEvent = Readonly<{ + oid: string; + changed: boolean; + removed?: boolean; + policy?: IntunePolicy | null; + serverUrls: string[]; +}>; + +export type IntuneWipeRequestedEvent = Readonly<{ + oid: string; + serverUrls: string[]; +}>; + +export type PendingWipe = Readonly<{ + oid: string; + serverUrls: string[]; + timestamp: number; +}>; + +export type IntuneSpec = { + addListener: (eventType: string) => void; + removeListeners: (count: number) => void; + login(serverUrl: string, scopes: string[]): Promise; + enrollInMAM(serverUrl: string, identity: MSALIdentity): Promise; + isManagedServer(serverUrl: string): Promise; + deregisterAndUnenroll(serverUrl: string, doWipe: boolean): Promise; + cleanupAfterWipe(oid: string): Promise; + reportWipeComplete(oid: string, success: boolean): Promise; + getPendingWipes(): Promise; + setCurrentIdentity(serverUrl: string | null): Promise; + getPolicy(serverUrl: string): Promise; + + onIntuneEnrollmentChanged: (listener: (event: IntuneEnrollmentChangedEvent) => void) => EventSubscription; + onIntunePolicyChanged: (listener: (event: IntunePolicyChangedEvent) => void) => EventSubscription; + onIntuneWipeRequested: (listener: (event: IntuneWipeRequestedEvent) => void) => EventSubscription; + onIntuneAuthRequired: (listener: (event: IntuneAuthRequiredEvent) => void) => EventSubscription; + onIntuneConditionalLaunchBlocked: (listener: (event: IntuneConditionalLaunchBlockedEvent) => void) => EventSubscription; + onIntuneIdentitySwitchRequired: (listener: (event: IntuneIdentitySwitchRequiredEvent) => void) => EventSubscription; +} diff --git a/app/managers/security_manager/event_handlers.test.ts b/app/managers/security_manager/event_handlers.test.ts new file mode 100644 index 000000000..bd7e0a287 --- /dev/null +++ b/app/managers/security_manager/event_handlers.test.ts @@ -0,0 +1,835 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import Emm from '@mattermost/react-native-emm'; +import {isRootedExperimentalAsync} from 'expo-device'; +import {type AppStateStatus} from 'react-native'; + +import DatabaseManager from '@database/manager'; +import {getServerCredentials} from '@init/credentials'; +import IntuneManager from '@managers/intune_manager'; +import { + IntuneConditionalLaunchBlockedReasons, + type IntuneAuthRequiredEvent, + type IntuneConditionalLaunchBlockedEvent, + type IntuneEnrollmentChangedEvent, + type IntuneIdentitySwitchRequiredEvent, + type IntunePolicy, + type IntunePolicyChangedEvent, + type IntuneWipeRequestedEvent, +} from '@managers/intune_manager/types'; +import TestHelper from '@test/test_helper'; +import * as alerts from '@utils/alerts'; +import {toMilliseconds} from '@utils/datetime'; + +import SecurityManager from '.'; + +jest.mock('@mattermost/react-native-emm', () => ({ + isDeviceSecured: jest.fn(), + authenticate: jest.fn(), + openSecuritySettings: jest.fn(), + exitApp: jest.fn(), + enableBlurScreen: jest.fn(), + applyBlurEffect: jest.fn(), + removeBlurEffect: jest.fn(), + addListener: jest.fn(), + setAppGroupId: jest.fn(), + getManagedConfig: jest.fn(() => ({})), +})); + +jest.mock('expo-device', () => ({ + isRootedExperimentalAsync: jest.fn(), +})); +jest.mock('@actions/local/session', () => ({terminateSession: jest.fn(() => Promise.resolve())})); +jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))})); +jest.mock('@actions/remote/session', () => ({ + logout: jest.fn(() => Promise.resolve()), +})); +jest.mock('@utils/log', () => ({ + logError: jest.fn(), + logDebug: jest.fn(), +})); +jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)})); +jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})})); +jest.mock('@database/manager', () => ({ + getActiveServerUrl: jest.fn(), + getServerDatabaseAndOperator: jest.fn(), +})); + +describe('SecurityManager - Event Handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + SecurityManager.initialized = false; + SecurityManager.serverConfig = {}; + SecurityManager.activeServer = undefined; + }); + + describe('onAppStateChange', () => { + test('should handle app state changes', async () => { + await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); + await SecurityManager.setActiveServer({serverUrl: 'server-8'}); + await SecurityManager.onAppStateChange('background' as AppStateStatus); + expect(SecurityManager.backgroundSince).toBeGreaterThan(0); + await SecurityManager.onAppStateChange('active' as AppStateStatus); + expect(SecurityManager.backgroundSince).toBe(0); + }); + + test('should call biometric authentication app state changes', async () => { + const authenticateWithBiometrics = jest.spyOn(SecurityManager, 'authenticateWithBiometrics'); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false); + await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); + await SecurityManager.setActiveServer({serverUrl: 'server-8'}); + SecurityManager.onAppStateChange('background' as AppStateStatus); + SecurityManager.backgroundSince = Date.now() - toMilliseconds({minutes: 5, seconds: 1}); + SecurityManager.onAppStateChange('active' as AppStateStatus); + await TestHelper.wait(300); + expect(authenticateWithBiometrics).toHaveBeenCalledWith('server-8'); + }); + }); + + describe('Intune Event Handlers', () => { + const serverUrl1 = 'https://test1.server.com'; + const serverUrl2 = 'https://test2.server.com'; + const mockPolicy: IntunePolicy = { + isPINRequired: true, + isContactSyncAllowed: false, + isWidgetContentSyncAllowed: false, + isSpotlightIndexingAllowed: false, + areSiriIntentsAllowed: false, + areAppIntentsAllowed: false, + isAppSharingAllowed: false, + shouldFileProviderEncryptFiles: true, + isManagedBrowserRequired: false, + isFileEncryptionRequired: true, + isScreenCaptureAllowed: false, + notificationPolicy: 0, + allowedSaveLocations: { + Other: false, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: false, + PhotoLibrary: false, + CameraRoll: false, + FilesApp: false, + iCloudDrive: false, + }, + allowedOpenLocations: 0, + }; + + beforeEach(async () => { + await SecurityManager.init(); + await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig); + await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig); + }); + + afterEach(() => { + SecurityManager.cleanup(); + }); + + describe('onIntunePolicyChanged', () => { + test('should skip if not initialized', () => { + SecurityManager.cleanup(); + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: true, + removed: false, + policy: mockPolicy, + serverUrls: [serverUrl1], + }; + + SecurityManager.onIntunePolicyChanged(event); + + // After cleanup, handler should return early without modifying config + // Server still exists with null policy (from addServer) + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.intunePolicy).toBeUndefined(); + }); + + test('should update policy for affected servers when policy changed', () => { + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: true, + removed: false, + policy: mockPolicy, + serverUrls: [serverUrl1, serverUrl2], + }; + + SecurityManager.onIntunePolicyChanged(event); + + const config1 = SecurityManager.getServerConfig(serverUrl1); + const config2 = SecurityManager.getServerConfig(serverUrl2); + expect(config1?.intunePolicy).toEqual(mockPolicy); + expect(config2?.intunePolicy).toEqual(mockPolicy); + }); + + test('should remove policy when policy removed', () => { + // First set a policy + SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: false, + removed: true, + policy: null, + serverUrls: [serverUrl1], + }; + + SecurityManager.onIntunePolicyChanged(event); + + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.intunePolicy).toBeNull(); + }); + + test('should re-apply screen capture policy when active server affected', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: true, + removed: false, + policy: mockPolicy, + serverUrls: [serverUrl1], + }; + + SecurityManager.onIntunePolicyChanged(event); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not re-apply when active server not affected', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: true, + removed: false, + policy: mockPolicy, + serverUrls: [serverUrl2], + }; + + SecurityManager.onIntunePolicyChanged(event); + + // Should not be called again for serverUrl1 + expect(setScreenCaptureSpy).not.toHaveBeenCalled(); + setScreenCaptureSpy.mockRestore(); + }); + + test('should handle multiple servers in event', () => { + const event: IntunePolicyChangedEvent = { + oid: 'object-id', + changed: true, + removed: false, + policy: mockPolicy, + serverUrls: [serverUrl1, serverUrl2], + }; + + SecurityManager.onIntunePolicyChanged(event); + + const config1 = SecurityManager.getServerConfig(serverUrl1); + const config2 = SecurityManager.getServerConfig(serverUrl2); + expect(config1?.intunePolicy).toEqual(mockPolicy); + expect(config2?.intunePolicy).toEqual(mockPolicy); + }); + }); + + describe('onEnrollmentChanged', () => { + test('should call handleEnrollmentSuccess when enrolled', async () => { + const handleSuccessSpy = jest.spyOn(SecurityManager as any, 'handleEnrollmentSuccess'); + const event: IntuneEnrollmentChangedEvent = { + oid: 'object-id', + enrolled: true, + reason: 'user_enrolled', + serverUrls: [serverUrl1], + }; + + SecurityManager.onEnrollmentChanged(event); + + await TestHelper.wait(10); + expect(handleSuccessSpy).toHaveBeenCalledWith([serverUrl1]); + handleSuccessSpy.mockRestore(); + }); + + test('should call handleUnenrollment when not enrolled', async () => { + const handleUnenrollmentSpy = jest.spyOn(SecurityManager as any, 'handleUnenrollment'); + const event: IntuneEnrollmentChangedEvent = { + oid: 'object-id', + enrolled: false, + reason: 'user_unenrolled', + serverUrls: [serverUrl1], + }; + + SecurityManager.onEnrollmentChanged(event); + + await TestHelper.wait(10); + expect(handleUnenrollmentSpy).toHaveBeenCalledWith([serverUrl1], 'user_unenrolled'); + handleUnenrollmentSpy.mockRestore(); + }); + }); + + describe('onWipeRequested', () => { + beforeEach(() => { + jest.mocked(getServerCredentials).mockResolvedValue({serverUrl: serverUrl1, userId: 'user-id', token: 'token'}); + }); + + test('should wipe background servers first, then active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined); + + const event: IntuneWipeRequestedEvent = { + oid: 'object-id', + serverUrls: [serverUrl1, serverUrl2], + }; + + await SecurityManager.onWipeRequested(event); + + // Background server wiped first with skipEvents=true + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl2, true); + + // Active server wiped last with skipEvents=false + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, false); + + performWipeSpy.mockRestore(); + }); + + test('should proceed with wipe even without credentials (already deleted by native)', async () => { + jest.mocked(getServerCredentials).mockResolvedValue(null); + const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(true); + + const event: IntuneWipeRequestedEvent = { + oid: 'object-id', + serverUrls: [serverUrl1], + }; + + await SecurityManager.onWipeRequested(event); + + // Should still call wipe to clean up database (credentials already deleted by native) + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true); + performWipeSpy.mockRestore(); + }); + + test('should remove active server after wipe', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined); + const removeServerSpy = jest.spyOn(SecurityManager, 'removeServer'); + + const event: IntuneWipeRequestedEvent = { + oid: 'object-id', + serverUrls: [serverUrl1], + }; + + await SecurityManager.onWipeRequested(event); + + expect(removeServerSpy).toHaveBeenCalledWith(serverUrl1); + removeServerSpy.mockRestore(); + }); + }); + + describe('onAuthRequired', () => { + test('should apply blur screen and trigger wipe', async () => { + const showAlertSpy = jest.spyOn(alerts, 'showAuthenticationRequiredAlert').mockImplementation(async (_reason, _locale, onDismiss) => { + if (onDismiss) { + onDismiss(); + } + }); + const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined); + + const event: IntuneAuthRequiredEvent = { + oid: 'object-id', + serverUrls: [serverUrl1], + }; + + await SecurityManager.onAuthRequired(event); + + expect(Emm.applyBlurEffect).toHaveBeenCalled(); + expect(showAlertSpy).toHaveBeenCalled(); + await TestHelper.wait(10); + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true); + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + + showAlertSpy.mockRestore(); + performWipeSpy.mockRestore(); + }); + + test('should remove blur screen when alert dismissed', async () => { + const showAlertSpy = jest.spyOn(alerts, 'showAuthenticationRequiredAlert').mockImplementation(async (_reason, _locale, onDismiss) => { + if (onDismiss) { + onDismiss(); + } + }); + + const event: IntuneAuthRequiredEvent = { + oid: 'object-id', + serverUrls: [serverUrl1], + }; + + await SecurityManager.onAuthRequired(event); + + expect(Emm.applyBlurEffect).toHaveBeenCalled(); + await TestHelper.wait(10); + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + + showAlertSpy.mockRestore(); + }); + }); + + describe('onConditionalLaunchBlocked', () => { + test('should trigger wipe when launch blocked', async () => { + const showAlertSpy = jest.spyOn(alerts, 'showConditionalAccessAlert').mockImplementation(async (_locale, callback) => { + if (callback) { + callback(); + } + }); + const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined); + + const event: IntuneConditionalLaunchBlockedEvent = { + oid: 'object-id', + reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED, + serverUrls: [serverUrl1], + }; + + await SecurityManager.onConditionalLaunchBlocked(event); + + expect(Emm.applyBlurEffect).toHaveBeenCalled(); + expect(showAlertSpy).toHaveBeenCalled(); + await TestHelper.wait(10); + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true); + + showAlertSpy.mockRestore(); + performWipeSpy.mockRestore(); + }); + + test('should remove blur screen when launch blocked alert dismissed', async () => { + const showAlertSpy = jest.spyOn(alerts, 'showConditionalAccessAlert').mockImplementation(async (_locale, callback) => { + if (callback) { + callback(); + } + }); + + const event: IntuneConditionalLaunchBlockedEvent = { + oid: 'object-id', + reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED, + serverUrls: [serverUrl1], + }; + + await SecurityManager.onConditionalLaunchBlocked(event); + + await TestHelper.wait(10); + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + + showAlertSpy.mockRestore(); + }); + + test('should retry authentication when launch canceled', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + + const showAlertSpy = jest.spyOn(alerts, 'showBiometricFailureAlertForOrganization').mockImplementation(async (_server, _locale, retryCallback) => { + if (retryCallback) { + await retryCallback(); + } + }); + const setIdentitySpy = jest.spyOn(IntuneManager, 'setCurrentIdentity').mockResolvedValue(undefined); + + const event: IntuneConditionalLaunchBlockedEvent = { + oid: 'object-id', + reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED, + serverUrls: [serverUrl1], + }; + + await SecurityManager.onConditionalLaunchBlocked(event); + + expect(showAlertSpy).toHaveBeenCalled(); + expect(setIdentitySpy).toHaveBeenCalledWith(serverUrl1); + + showAlertSpy.mockRestore(); + setIdentitySpy.mockRestore(); + }); + + test('should remove blur screen after retry', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + + const showAlertSpy = jest.spyOn(alerts, 'showBiometricFailureAlertForOrganization').mockImplementation(async (_server, _locale, retryCallback) => { + if (retryCallback) { + await retryCallback(); + } + }); + jest.spyOn(IntuneManager, 'setCurrentIdentity').mockResolvedValue(undefined); + + const event: IntuneConditionalLaunchBlockedEvent = { + oid: 'object-id', + reason: IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED, + serverUrls: [serverUrl1], + }; + + await SecurityManager.onConditionalLaunchBlocked(event); + + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + + showAlertSpy.mockRestore(); + }); + }); + + describe('onIdentitySwitchRequired', () => { + test('should trigger wipe and show alert', async () => { + const showAlertSpy = jest.spyOn(alerts, 'showIdentitySwitchRequiredAlert').mockResolvedValue(undefined); + const performWipeSpy = jest.spyOn(SecurityManager as any, 'performSelectiveWipe').mockResolvedValue(undefined); + + const event: IntuneIdentitySwitchRequiredEvent = { + oid: 'object-id', + serverUrls: [serverUrl1], + reason: 'new_identity_required', + }; + + await SecurityManager.onIdentitySwitchRequired(event); + + expect(showAlertSpy).toHaveBeenCalled(); + await TestHelper.wait(10); + expect(performWipeSpy).toHaveBeenCalledWith(serverUrl1, true); + + showAlertSpy.mockRestore(); + performWipeSpy.mockRestore(); + }); + }); + + describe('handleEnrollmentSuccess', () => { + beforeEach(() => { + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl1); + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy); + }); + + test('should fetch and update policy for all affected servers', async () => { + await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1, serverUrl2]); + + const config1 = SecurityManager.getServerConfig(serverUrl1); + const config2 = SecurityManager.getServerConfig(serverUrl2); + expect(config1?.intunePolicy).toEqual(mockPolicy); + expect(config2?.intunePolicy).toEqual(mockPolicy); + }); + + test('should set current identity if active server is affected', async () => { + await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1, serverUrl2]); + + expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl1); + }); + + test('should re-apply screen capture policy for active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + + await (SecurityManager as any).handleEnrollmentSuccess([serverUrl1]); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not set current identity if active server not affected', async () => { + jest.mocked(IntuneManager.setCurrentIdentity).mockClear(); + + await (SecurityManager as any).handleEnrollmentSuccess([serverUrl2]); + + expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled(); + }); + }); + + describe('handleUnenrollment', () => { + beforeEach(async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + await SecurityManager.addServer(serverUrl2, {} as SecurityClientConfig, false, mockPolicy); + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl1); + }); + + test('should remove intunePolicy from all affected servers', async () => { + await (SecurityManager as any).handleUnenrollment([serverUrl1, serverUrl2], 'admin_unenrolled'); + + const config1 = SecurityManager.getServerConfig(serverUrl1); + const config2 = SecurityManager.getServerConfig(serverUrl2); + expect(config1?.intunePolicy).toBeNull(); + expect(config2?.intunePolicy).toBeNull(); + }); + + test('should re-apply screen capture policy if active server affected', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + + await (SecurityManager as any).handleUnenrollment([serverUrl1], 'policy_removed'); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not re-apply if active server not affected', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + await (SecurityManager as any).handleUnenrollment([serverUrl2]); + + expect(setScreenCaptureSpy).not.toHaveBeenCalled(); + setScreenCaptureSpy.mockRestore(); + }); + }); + + describe('onConfigChanged', () => { + const mockConfig = { + SiteName: 'Updated Server', + MobileEnableBiometrics: 'true', + MobileJailbreakProtection: 'true', + MobilePreventScreenCapture: 'true', + } as SecurityClientConfig; + + beforeEach(() => { + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy); + }); + + test('should update server config when config changes', async () => { + await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig}); + + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.siteName).toBe('Updated Server'); + expect(config?.Biometrics).toBe(true); + expect(config?.JailbreakProtection).toBe(true); + expect(config?.PreventScreenCapture).toBe(true); + }); + + test('should fetch and store Intune policy when config changes', async () => { + await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig}); + + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.intunePolicy).toEqual(mockPolicy); + expect(IntuneManager.getPolicy).toHaveBeenCalledWith(serverUrl1); + }); + + test('should preserve existing config fields when updating', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, true); + SecurityManager.serverConfig[serverUrl1].lastAccessed = 12345; + + await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig}); + + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.lastAccessed).toBe(12345); + expect(config?.authenticated).toBe(true); + }); + + test('should trigger MAM enrollment check for active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + + await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig}); + + expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1); + ensureMAMSpy.mockRestore(); + }); + + test('should apply screen capture policy for active server after enrollment check', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + + await SecurityManager.onConfigChanged({serverUrl: serverUrl1, config: mockConfig}); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not trigger enrollment for non-active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer'); + + await SecurityManager.onConfigChanged({serverUrl: serverUrl2, config: mockConfig}); + + expect(ensureMAMSpy).not.toHaveBeenCalled(); + ensureMAMSpy.mockRestore(); + }); + }); + + describe('onLicenseChanged', () => { + const mockLicense = { + IsLicensed: 'true', + SkuShortName: 'enterprise', + } as ClientLicense; + + beforeEach(() => { + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockPolicy); + }); + + test('should create server config if not exists', async () => { + await SecurityManager.onLicenseChanged({serverUrl: 'https://new-server.com', license: mockLicense}); + + const config = SecurityManager.getServerConfig('https://new-server.com'); + expect(config).toBeDefined(); + expect(config?.intunePolicy).toEqual(mockPolicy); + }); + + test('should fetch and update Intune policy when license changes', async () => { + await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense}); + + const config = SecurityManager.getServerConfig(serverUrl1); + expect(config?.intunePolicy).toEqual(mockPolicy); + expect(IntuneManager.getPolicy).toHaveBeenCalledWith(serverUrl1); + }); + + test('should trigger MAM enrollment check for active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + + await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense}); + + expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1); + ensureMAMSpy.mockRestore(); + }); + + test('should set current identity for active server after successful enrollment', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + + await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense}); + + expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl1); + }); + + test('should not set identity if enrollment fails', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(false); + jest.mocked(IntuneManager.setCurrentIdentity).mockClear(); + + await SecurityManager.onLicenseChanged({serverUrl: serverUrl1, license: mockLicense}); + + expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled(); + }); + + test('should not trigger enrollment for non-active server', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer'); + + await SecurityManager.onLicenseChanged({serverUrl: serverUrl2, license: mockLicense}); + + expect(ensureMAMSpy).not.toHaveBeenCalled(); + ensureMAMSpy.mockRestore(); + }); + }); + + describe('canSaveToLocation', () => { + test('should return true when no Intune policy exists', () => { + const result = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary'); + expect(result).toBe(true); + }); + + test('should return policy value when Intune policy exists', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + const canSaveToOneDrive = SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness'); + const canSaveToPhotoLibrary = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary'); + + expect(canSaveToOneDrive).toBe(true); + expect(canSaveToPhotoLibrary).toBe(false); + }); + + test('should return false for restricted locations', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + expect(SecurityManager.canSaveToLocation(serverUrl1, 'LocalDrive')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'CameraRoll')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'FilesApp')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'iCloudDrive')).toBe(false); + }); + + test('should return true for allowed locations', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + expect(SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness')).toBe(true); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'SharePoint')).toBe(true); + }); + }); + + describe('setActiveServer options', () => { + beforeEach(async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); + jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); + jest.mocked(Emm.authenticate).mockResolvedValue(true); + }); + + test('should skip MAM enrollment check when skipMAMEnrollmentCheck is true', async () => { + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true}}); + + expect(ensureMAMSpy).not.toHaveBeenCalled(); + ensureMAMSpy.mockRestore(); + }); + + test('should perform MAM enrollment check when skipMAMEnrollmentCheck is false', async () => { + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: false}}); + + expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1); + ensureMAMSpy.mockRestore(); + }); + + test('should skip jailbreak check when skipJailbreakCheck is true', async () => { + const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true}}); + + expect(jailbreakSpy).not.toHaveBeenCalled(); + jailbreakSpy.mockRestore(); + }); + + test('should perform jailbreak check when skipJailbreakCheck is false', async () => { + const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken').mockResolvedValue(false); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: false}}); + + expect(jailbreakSpy).toHaveBeenCalledWith(serverUrl1); + jailbreakSpy.mockRestore(); + }); + + test('should skip biometric auth when skipBiometricCheck is true', async () => { + const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + + expect(biometricSpy).not.toHaveBeenCalled(); + biometricSpy.mockRestore(); + }); + + test('should perform biometric auth when skipBiometricCheck is false', async () => { + const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: false}}); + + expect(biometricSpy).toHaveBeenCalledWith(serverUrl1); + biometricSpy.mockRestore(); + }); + + test('should force switch to same server when forceSwitch is true', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: true}}); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not switch to same server when forceSwitch is false', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: false}}); + + expect(setScreenCaptureSpy).not.toHaveBeenCalled(); + setScreenCaptureSpy.mockRestore(); + }); + }); + }); +}); diff --git a/app/managers/security_manager/index.test.ts b/app/managers/security_manager/index.test.ts index f8b8527ad..c7bc599c8 100644 --- a/app/managers/security_manager/index.test.ts +++ b/app/managers/security_manager/index.test.ts @@ -1,22 +1,28 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import Emm from '@mattermost/react-native-emm'; import {isRootedExperimentalAsync} from 'expo-device'; -import {Alert, type AppStateStatus} from 'react-native'; -import {switchToServer} from '@actions/app/server'; -import {logout} from '@actions/remote/session'; import {Screens} from '@constants'; -import {DEFAULT_LOCALE, getTranslations} from '@i18n'; -import {getServerCredentials} from '@init/credentials'; -import TestHelper from '@test/test_helper'; +import DatabaseManager from '@database/manager'; +import IntuneManager from '@managers/intune_manager'; +import { + type IntunePolicy, +} from '@managers/intune_manager/types'; +import {queryAllActiveServers} from '@queries/app/servers'; +import {getSecurityConfig} from '@queries/servers/system'; +import * as alerts from '@utils/alerts'; import {toMilliseconds} from '@utils/datetime'; import {logError} from '@utils/log'; -import SecurityManager, {exportsForTesting} from '.'; +import SecurityManager from '.'; -const messages = exportsForTesting.messages; +import type {Query} from '@nozbe/watermelondb'; +import type {ServerDatabase} from '@typings/database/database'; +import type ServersModel from '@typings/database/models/app/servers'; jest.mock('@mattermost/react-native-emm', () => ({ isDeviceSecured: jest.fn(), @@ -24,21 +30,46 @@ jest.mock('@mattermost/react-native-emm', () => ({ openSecuritySettings: jest.fn(), exitApp: jest.fn(), enableBlurScreen: jest.fn(), + applyBlurEffect: jest.fn(), + removeBlurEffect: jest.fn(), })); jest.mock('expo-device', () => ({ isRootedExperimentalAsync: jest.fn(), })); jest.mock('@actions/app/server', () => ({switchToServer: jest.fn()})); -jest.mock('@actions/remote/session', () => ({logout: jest.fn()})); +jest.mock('@actions/local/session', () => ({terminateSession: jest.fn()})); +jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))})); +jest.mock('@actions/remote/session', () => ({ + logout: jest.fn(), +})); jest.mock('@utils/datetime', () => ({toMilliseconds: jest.fn(() => 25000)})); jest.mock('@utils/helpers', () => ({ isMainActivity: jest.fn(() => true), isTablet: jest.fn(() => false), })); -jest.mock('@utils/log', () => ({logError: jest.fn()})); +jest.mock('@utils/log', () => ({ + logError: jest.fn(), + logDebug: jest.fn(), +})); jest.mock('@init/managed_app', () => ({enabled: true, inAppPinCode: false})); jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})})); +jest.mock('@database/manager', () => ({ + getActiveServerUrl: jest.fn(), + getServerDatabaseAndOperator: jest.fn(), +})); +jest.mock('@queries/servers/system', () => ({ + getSecurityConfig: jest.fn(), + getConfig: jest.fn(), + getConfigValue: jest.fn(), +})); +jest.mock('@queries/app/servers', () => ({ + queryAllActiveServers: jest.fn(), +})); +jest.mock('@queries/servers/user', () => ({ + getCurrentUser: jest.fn(), + getCurrentUserLocale: jest.fn(() => Promise.resolve('en')), +})); describe('SecurityManager', () => { beforeEach(() => { @@ -49,178 +80,254 @@ describe('SecurityManager', () => { }); describe('init', () => { - test('should initialize with servers', () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - }; - SecurityManager.init(servers, 'server-1'); - expect(SecurityManager.serverConfig['server-1']).toBeDefined(); - expect(SecurityManager.activeServer).toBe('server-1'); - }); + test('should initialize and load configs from all active servers', async () => { + const mockServers = [ + {url: 'server-1'}, + {url: 'server-2'}, + ]; + const mockFetch = jest.fn().mockResolvedValue(mockServers); + jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query); - test('should initialize with servers and set active server', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - 'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'}, - }; - await SecurityManager.init(servers, 'server-1'); + const mockConfig1 = {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as SecurityClientConfig; + const mockConfig2 = {SiteName: 'Server Two', MobilePreventScreenCapture: 'true'} as SecurityClientConfig; + + jest.mocked(getSecurityConfig).mockImplementation(async (database: any) => { + if (database === 'db-1') { + return mockConfig1; + } + return mockConfig2; + }); + + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((url: string) => { + if (url === 'server-1') { + return {database: 'db-1'} as unknown as ServerDatabase; + } + return {database: 'db-2'} as unknown as ServerDatabase; + }); + + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(null); + + await SecurityManager.init(); + + expect(queryAllActiveServers).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + expect(getSecurityConfig).toHaveBeenCalledTimes(2); + expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-1'); + expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-2'); + expect(SecurityManager.initialized).toBe(true); expect(SecurityManager.serverConfig['server-1']).toBeDefined(); expect(SecurityManager.serverConfig['server-2']).toBeDefined(); - expect(SecurityManager.activeServer).toBe('server-1'); }); test('should not reinitialize if already initialized', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - }; SecurityManager.initialized = true; - await SecurityManager.init(servers, 'server-1'); + await SecurityManager.init(); + expect(queryAllActiveServers).not.toHaveBeenCalled(); + }); + + test('should handle empty server list', async () => { + const mockFetch = jest.fn().mockResolvedValue([]); + jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query); + + await SecurityManager.init(); + + expect(queryAllActiveServers).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + expect(SecurityManager.initialized).toBe(true); + expect(Object.keys(SecurityManager.serverConfig)).toHaveLength(0); + }); + + test('should handle undefined queryAllActiveServers result', async () => { + jest.mocked(queryAllActiveServers).mockReturnValue(undefined); + + await SecurityManager.init(); + + expect(queryAllActiveServers).toHaveBeenCalled(); + expect(SecurityManager.initialized).toBe(true); + }); + + test('should load Intune policies along with server configs', async () => { + const mockServers = [{url: 'server-1'}]; + const mockFetch = jest.fn().mockResolvedValue(mockServers); + jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query); + + const mockConfig = {SiteName: 'Server One', MobileEnableBiometrics: 'false'} as unknown as SecurityClientConfig; + const mockIntunePolicy: IntunePolicy = { + isPINRequired: false, + isContactSyncAllowed: true, + isWidgetContentSyncAllowed: true, + isSpotlightIndexingAllowed: true, + areSiriIntentsAllowed: true, + areAppIntentsAllowed: true, + isAppSharingAllowed: true, + shouldFileProviderEncryptFiles: false, + isManagedBrowserRequired: false, + isFileEncryptionRequired: false, + isScreenCaptureAllowed: false, + notificationPolicy: 0, + allowedSaveLocations: { + Other: false, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: false, + PhotoLibrary: false, + CameraRoll: false, + FilesApp: false, + iCloudDrive: false, + }, + allowedOpenLocations: 0, + }; + + jest.mocked(getSecurityConfig).mockResolvedValue(mockConfig); + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: 'db-1'} as unknown as ServerDatabase); + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(mockIntunePolicy); + + await SecurityManager.init(); + + expect(IntuneManager.getPolicy).toHaveBeenCalledWith('server-1'); + expect(SecurityManager.serverConfig['server-1'].intunePolicy).toEqual(mockIntunePolicy); + }); + + test('should handle errors when loading server config', async () => { + const mockServers = [{url: 'server-1'}, {url: 'server-2'}]; + const mockFetch = jest.fn().mockResolvedValue(mockServers); + jest.mocked(queryAllActiveServers).mockReturnValue({fetch: mockFetch} as unknown as Query); + + // First server throws error, second succeeds + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation((url: string) => { + if (url === 'server-1') { + throw new Error('Database error'); + } + return {database: 'db-2'} as unknown as ServerDatabase; + }); + + const mockConfig2 = {SiteName: 'Server Two'} as unknown as SecurityClientConfig; + jest.mocked(getSecurityConfig).mockResolvedValue(mockConfig2); + jest.mocked(IntuneManager.getPolicy).mockResolvedValue(null); + + await SecurityManager.init(); + + expect(SecurityManager.initialized).toBe(true); expect(SecurityManager.serverConfig['server-1']).toBeUndefined(); - }); - - test('should set active server and authenticate if not jailbroken', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - }; - jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); - jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false); - - const isDeviceJailbrokenSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken'); - const authenticateWithBiometricsIfNeededSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded'); - - await SecurityManager.init(servers, 'server-1'); - expect(SecurityManager.activeServer).toBe('server-1'); - expect(isDeviceJailbrokenSpy).toHaveBeenCalledWith('server-1'); - expect(authenticateWithBiometricsIfNeededSpy).toHaveBeenCalledWith('server-1'); - }); - - test('should not authenticate if device is jailbroken', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true'}, - }; - jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); - await SecurityManager.init(servers, 'server-1'); - expect(SecurityManager.activeServer).toBe('server-1'); - expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('server-1'); - expect(SecurityManager.authenticateWithBiometricsIfNeeded).not.toHaveBeenCalled(); - }); - - test('should not set active server if not provided', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - }; - await SecurityManager.init(servers); - expect(SecurityManager.activeServer).toBeUndefined(); + expect(SecurityManager.serverConfig['server-2']).toBeDefined(); + expect(logError).toHaveBeenCalled(); }); }); describe('addServer', () => { - test('should add server config with biometrics enabled', () => { - SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as ClientConfig); + test('should add server config with biometrics enabled', async () => { + await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as unknown as SecurityClientConfig); expect(SecurityManager.serverConfig['server-1']).toEqual({ siteName: 'Server One', Biometrics: true, JailbreakProtection: false, PreventScreenCapture: false, authenticated: false, + intunePolicy: null, }); }); - test('should add server config with jailbreak protection enabled', () => { - SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileJailbreakProtection: 'true'} as ClientConfig); + test('should add server config with jailbreak protection enabled', async () => { + await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileJailbreakProtection: 'true'} as unknown as SecurityClientConfig); expect(SecurityManager.serverConfig['server-2']).toEqual({ siteName: 'Server Two', Biometrics: false, JailbreakProtection: true, PreventScreenCapture: false, authenticated: false, + intunePolicy: null, }); }); - test('should add server config with screen capture prevention enabled', () => { - SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig); + test('should add server config with screen capture prevention enabled', async () => { + await SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as unknown as SecurityClientConfig); expect(SecurityManager.serverConfig['server-3']).toEqual({ siteName: 'Server Three', Biometrics: false, JailbreakProtection: false, PreventScreenCapture: true, authenticated: false, + intunePolicy: null, }); }); - test('should add server config with all features enabled', () => { - SecurityManager.addServer('server-4', { + test('should add server config with all features enabled', async () => { + await SecurityManager.addServer('server-4', { SiteName: 'Server Four', MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true', MobilePreventScreenCapture: 'true', - } as ClientConfig); + } as unknown as SecurityClientConfig); expect(SecurityManager.serverConfig['server-4']).toEqual({ siteName: 'Server Four', Biometrics: true, JailbreakProtection: true, PreventScreenCapture: true, authenticated: false, + intunePolicy: null, }); }); - test('should add server config with authenticated set to true', () => { - SecurityManager.addServer('server-5', {SiteName: 'Server Five'} as ClientConfig, true); + test('should add server config with authenticated set to true', async () => { + await SecurityManager.addServer('server-5', {SiteName: 'Server Five'} as unknown as SecurityClientConfig, true); expect(SecurityManager.serverConfig['server-5']).toEqual({ siteName: 'Server Five', Biometrics: false, JailbreakProtection: false, PreventScreenCapture: false, authenticated: true, + intunePolicy: null, }); }); - test('should add server config without config', () => { - SecurityManager.addServer('server-6'); + test('should add server config without config', async () => { + await SecurityManager.addServer('server-6'); expect(SecurityManager.serverConfig['server-6']).toEqual({ siteName: undefined, Biometrics: false, JailbreakProtection: false, PreventScreenCapture: false, authenticated: false, + intunePolicy: null, }); }); - test('should update a server config previously added', () => { - SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as ClientConfig, true); + test('should update a server config previously added', async () => { + await SecurityManager.addServer('server-3', {SiteName: 'Server Three', MobilePreventScreenCapture: 'true'} as unknown as SecurityClientConfig, true); expect(SecurityManager.serverConfig['server-3']).toEqual({ siteName: 'Server Three', Biometrics: false, JailbreakProtection: false, PreventScreenCapture: true, authenticated: true, + intunePolicy: null, }); }); }); describe('removeServer', () => { test('should remove server config and active server', async () => { - const servers: Record = { - 'server-1': {SiteName: 'Server One', MobileEnableBiometrics: 'true'}, - 'server-2': {SiteName: 'Server Two', MobileEnableBiometrics: 'false'}, - }; - await SecurityManager.init(servers, 'server-1'); + await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobileEnableBiometrics: 'true'} as SecurityClientConfig); + await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobileEnableBiometrics: 'false'} as SecurityClientConfig); + await SecurityManager.setActiveServer({serverUrl: 'server-1'}); + SecurityManager.initialized = true; + SecurityManager.removeServer('server-1'); expect(SecurityManager.serverConfig['server-1']).toBeUndefined(); expect(SecurityManager.activeServer).toBeUndefined(); expect(SecurityManager.initialized).toBe(false); }); - test('should remove server config', () => { - SecurityManager.addServer('server-3'); + test('should remove server config', async () => { + await SecurityManager.addServer('server-3'); SecurityManager.removeServer('server-3'); expect(SecurityManager.serverConfig['server-3']).toBeUndefined(); }); }); describe('getServerConfig', () => { - test('should return server config', () => { - SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig); + test('should return server config', async () => { + await SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as SecurityClientConfig); expect(SecurityManager.getServerConfig('server-4')?.siteName).toBe('Server Four'); }); @@ -230,27 +337,27 @@ describe('SecurityManager', () => { }); describe('setActiveServer', () => { - test('should set active server and update lastAccessed', () => { - SecurityManager.addServer('server-5'); + test('should set active server and update lastAccessed', async () => { + await SecurityManager.addServer('server-5'); const before = Date.now(); - SecurityManager.setActiveServer('server-5'); + await SecurityManager.setActiveServer({serverUrl: 'server-5'}); const after = Date.now(); expect(SecurityManager.activeServer).toBe('server-5'); expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeGreaterThanOrEqual(before); expect(SecurityManager.serverConfig['server-5'].lastAccessed).toBeLessThanOrEqual(after); }); - test('should not set active server if server does not exist', () => { - SecurityManager.setActiveServer('server-6'); + test('should not set active server if server does not exist', async () => { + await SecurityManager.setActiveServer({serverUrl: 'server-6'}); expect(SecurityManager.activeServer).toBeUndefined(); }); - test('should update active server and lastAccessed if a different server is set', () => { - SecurityManager.addServer('server-7'); - SecurityManager.addServer('server-8'); - SecurityManager.setActiveServer('server-7'); + test('should update active server and lastAccessed if a different server is set', async () => { + await SecurityManager.addServer('server-7'); + await SecurityManager.addServer('server-8'); + await SecurityManager.setActiveServer({serverUrl: 'server-7'}); const before = Date.now(); - SecurityManager.setActiveServer('server-8'); + await SecurityManager.setActiveServer({serverUrl: 'server-8'}); const after = Date.now(); expect(SecurityManager.activeServer).toBe('server-8'); expect(SecurityManager.serverConfig['server-8'].lastAccessed).toBeGreaterThanOrEqual(before); @@ -258,23 +365,115 @@ describe('SecurityManager', () => { }); test('should not change active server or lastAccessed if the same server is set', async () => { - SecurityManager.addServer('server-9'); - SecurityManager.setActiveServer('server-9'); + await SecurityManager.addServer('server-9'); + await SecurityManager.setActiveServer({serverUrl: 'server-9'}); const lastAccessed = SecurityManager.serverConfig['server-9'].lastAccessed; - SecurityManager.setActiveServer('server-9'); + await SecurityManager.setActiveServer({serverUrl: 'server-9'}); expect(SecurityManager.activeServer).toBe('server-9'); expect(SecurityManager.serverConfig['server-9'].lastAccessed).toBe(lastAccessed); }); }); + describe('setActiveServer options', () => { + const serverUrl1 = 'https://test1.server.com'; + const serverUrl2 = 'https://test2.server.com'; + + beforeEach(async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); + jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); + jest.mocked(Emm.authenticate).mockResolvedValue(true); + await SecurityManager.init(); + await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig); + await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig); + }); + + test('should skip MAM enrollment check when skipMAMEnrollmentCheck is true', async () => { + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true}}); + + expect(ensureMAMSpy).not.toHaveBeenCalled(); + ensureMAMSpy.mockRestore(); + }); + + test('should perform MAM enrollment check when skipMAMEnrollmentCheck is false', async () => { + const ensureMAMSpy = jest.spyOn(SecurityManager, 'ensureMAMEnrollmentForActiveServer').mockResolvedValue(true); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: false}}); + + expect(ensureMAMSpy).toHaveBeenCalledWith(serverUrl1); + ensureMAMSpy.mockRestore(); + }); + + test('should skip jailbreak check when skipJailbreakCheck is true', async () => { + const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true}}); + + expect(jailbreakSpy).not.toHaveBeenCalled(); + jailbreakSpy.mockRestore(); + }); + + test('should perform jailbreak check when skipJailbreakCheck is false', async () => { + const jailbreakSpy = jest.spyOn(SecurityManager, 'isDeviceJailbroken').mockResolvedValue(false); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: false}}); + + expect(jailbreakSpy).toHaveBeenCalledWith(serverUrl1); + jailbreakSpy.mockRestore(); + }); + + test('should skip biometric auth when skipBiometricCheck is true', async () => { + const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + + expect(biometricSpy).not.toHaveBeenCalled(); + biometricSpy.mockRestore(); + }); + + test('should perform biometric auth when skipBiometricCheck is false', async () => { + const biometricSpy = jest.spyOn(SecurityManager, 'authenticateWithBiometricsIfNeeded'); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: false}}); + + expect(biometricSpy).toHaveBeenCalledWith(serverUrl1); + biometricSpy.mockRestore(); + }); + + test('should force switch to same server when forceSwitch is true', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: true}}); + + expect(setScreenCaptureSpy).toHaveBeenCalledWith(serverUrl1); + setScreenCaptureSpy.mockRestore(); + }); + + test('should not switch to same server when forceSwitch is false', async () => { + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true}}); + const setScreenCaptureSpy = jest.spyOn(SecurityManager, 'setScreenCapturePolicy'); + setScreenCaptureSpy.mockClear(); + + await SecurityManager.setActiveServer({serverUrl: serverUrl1, options: {skipMAMEnrollmentCheck: true, skipJailbreakCheck: true, skipBiometricCheck: true, forceSwitch: false}}); + + expect(setScreenCaptureSpy).not.toHaveBeenCalled(); + setScreenCaptureSpy.mockRestore(); + }); + }); + describe('isScreenCapturePrevented', () => { - test('should return true if screen capture prevention is enabled', () => { - SecurityManager.addServer('server-1', {SiteName: 'Server One', MobilePreventScreenCapture: 'true'} as ClientConfig); + test('should return true if screen capture prevention is enabled', async () => { + await SecurityManager.addServer('server-1', {SiteName: 'Server One', MobilePreventScreenCapture: 'true'} as SecurityClientConfig); expect(SecurityManager.isScreenCapturePrevented('server-1')).toBe(true); }); - test('should return false if screen capture prevention is disabled', () => { - SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobilePreventScreenCapture: 'false'} as ClientConfig); + test('should return false if screen capture prevention is disabled', async () => { + await SecurityManager.addServer('server-2', {SiteName: 'Server Two', MobilePreventScreenCapture: 'false'} as SecurityClientConfig); expect(SecurityManager.isScreenCapturePrevented('server-2')).toBe(false); }); @@ -282,8 +481,8 @@ describe('SecurityManager', () => { expect(SecurityManager.isScreenCapturePrevented('server-3')).toBe(false); }); - test('should return false if PreventScreenCapture property is not set', () => { - SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as ClientConfig); + test('should return false if PreventScreenCapture property is not set', async () => { + await SecurityManager.addServer('server-4', {SiteName: 'Server Four'} as SecurityClientConfig); expect(SecurityManager.isScreenCapturePrevented('server-4')).toBe(false); }); }); @@ -292,7 +491,7 @@ describe('SecurityManager', () => { test('should handle biometric authentication if biometrics enabled and device secured', async () => { jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); jest.mocked(Emm.authenticate).mockResolvedValue(true); - SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig); + await SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(true); expect(Emm.isDeviceSecured).toHaveBeenCalled(); expect(Emm.authenticate).toHaveBeenCalled(); @@ -300,8 +499,8 @@ describe('SecurityManager', () => { test('should not prompt for biometric authentication if biometrics enabled but device is not secured', async () => { jest.mocked(Emm.isDeviceSecured).mockResolvedValue(false); - const showNotSecuredAlertSpy = jest.spyOn(SecurityManager, 'showNotSecuredAlert'); - SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as ClientConfig); + const showNotSecuredAlertSpy = jest.spyOn(alerts, 'showNotSecuredAlert'); + await SecurityManager.addServer('server-6', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-6')).resolves.toBe(false); expect(showNotSecuredAlertSpy).toHaveBeenCalled(); expect(Emm.isDeviceSecured).toHaveBeenCalled(); @@ -309,7 +508,7 @@ describe('SecurityManager', () => { }); test('should not attempt biometric authentication if biometrics not enabled', async () => { - SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'false'} as ClientConfig); + await SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'false'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-8')).resolves.toBe(true); expect(Emm.isDeviceSecured).not.toHaveBeenCalled(); expect(Emm.authenticate).not.toHaveBeenCalled(); @@ -318,7 +517,7 @@ describe('SecurityManager', () => { test('should resolve with true if biometric authentication succeeds', async () => { jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); jest.mocked(Emm.authenticate).mockResolvedValue(true); - SecurityManager.addServer('server-9', {MobileEnableBiometrics: 'true'} as ClientConfig); + await SecurityManager.addServer('server-9', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-9')).resolves.toBe(true); expect(Emm.isDeviceSecured).toHaveBeenCalled(); expect(Emm.authenticate).toHaveBeenCalled(); @@ -327,7 +526,7 @@ describe('SecurityManager', () => { test('should log error and resolve with false if biometric authentication fails', async () => { jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); jest.mocked(Emm.authenticate).mockResolvedValue(false); - SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as ClientConfig); + await SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-10')).resolves.toBe(false); expect(Emm.isDeviceSecured).toHaveBeenCalled(); expect(Emm.authenticate).toHaveBeenCalled(); @@ -338,7 +537,7 @@ describe('SecurityManager', () => { test('should log error and resolve with false if biometric authentication throws an error', async () => { jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); jest.mocked(Emm.authenticate).mockRejectedValue(new Error('Authorization cancelled')); - SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as ClientConfig); + await SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-11')).resolves.toBe(false); expect(Emm.isDeviceSecured).toHaveBeenCalled(); expect(Emm.authenticate).toHaveBeenCalled(); @@ -359,7 +558,7 @@ describe('SecurityManager', () => { Date.now = jest.fn(() => fixedTime); try { - SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as ClientConfig, true); + await SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as SecurityClientConfig, true); SecurityManager.serverConfig['server-12'].lastAccessed = oneMinuteAgo; await expect(SecurityManager.authenticateWithBiometricsIfNeeded('server-12')).resolves.toBe(true); expect(Emm.isDeviceSecured).not.toHaveBeenCalled(); @@ -372,7 +571,7 @@ describe('SecurityManager', () => { }); test('should not attempt biometric authentication if server was previously failed authentication even though lastAccess is less than 5 mins', async () => { - SecurityManager.addServer('server-13', {MobileEnableBiometrics: 'true'} as ClientConfig); + await SecurityManager.addServer('server-13', {MobileEnableBiometrics: 'true'} as SecurityClientConfig); SecurityManager.serverConfig['server-13'].authenticated = false; SecurityManager.serverConfig['server-13'].lastAccessed = Date.now() - toMilliseconds({minutes: 1}); await SecurityManager.authenticateWithBiometricsIfNeeded('server-13'); @@ -381,167 +580,66 @@ describe('SecurityManager', () => { }); }); - describe('onAppStateChange', () => { - test('should handle app state changes', async () => { - SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as ClientConfig); - SecurityManager.setActiveServer('server-8'); - await SecurityManager.onAppStateChange('background' as AppStateStatus); - expect(SecurityManager.backgroundSince).toBeGreaterThan(0); - await SecurityManager.onAppStateChange('active' as AppStateStatus); - expect(SecurityManager.backgroundSince).toBe(0); - }); - - test('should call biometric authentication app state changes', async () => { - const authenticateWithBiometrics = jest.spyOn(SecurityManager, 'authenticateWithBiometrics'); - jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false); - SecurityManager.addServer('server-8', {MobileEnableBiometrics: 'true'} as ClientConfig); - SecurityManager.setActiveServer('server-8'); - SecurityManager.onAppStateChange('background' as AppStateStatus); - SecurityManager.backgroundSince = Date.now() - toMilliseconds({minutes: 5, seconds: 1}); - SecurityManager.onAppStateChange('active' as AppStateStatus); - await TestHelper.wait(300); - expect(authenticateWithBiometrics).toHaveBeenCalledWith('server-8'); - }); - }); - - describe('showNotSecuredAlert', () => { - test('should show not secured alert', async () => { - SecurityManager.addServer('server-9'); - SecurityManager.showNotSecuredAlert('server-9', 'Test Site', getTranslations(DEFAULT_LOCALE)); - await TestHelper.wait(300); - expect(Alert.alert).toHaveBeenCalled(); - }); - }); - - describe('goToPreviousServer', () => { - afterAll(() => { - jest.clearAllMocks(); - }); - - test('should switch to previous server', async () => { - jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); - jest.mocked(Emm.authenticate).mockResolvedValue(true); - jest.mocked(switchToServer).mockImplementation((serverUrl: string) => { - SecurityManager.setActiveServer(serverUrl); - return Promise.resolve(); - }); - SecurityManager.activeServer = undefined; - SecurityManager.addServer('server-10', {MobileEnableBiometrics: 'true'} as ClientConfig); - SecurityManager.setActiveServer('server-10'); - SecurityManager.addServer('server-11', {MobileEnableBiometrics: 'true'} as ClientConfig); - SecurityManager.setActiveServer('server-11'); - await SecurityManager.goToPreviousServer(['server-10', 'server-11']); - expect(switchToServer).toHaveBeenCalledWith('server-10', expect.anything(), expect.anything()); - expect(SecurityManager.activeServer).toBe('server-10'); - }); - }); - - describe('buildAlertOptions', () => { - test('should build alert options with logout', async () => { - jest.mocked(getServerCredentials).mockResolvedValue({token: 'some-token', serverUrl: 'server-12', userId: 'me'}); - SecurityManager.addServer('server-12', {MobileEnableBiometrics: 'true'} as ClientConfig); - SecurityManager.setActiveServer('server-12'); - const translations = getTranslations(DEFAULT_LOCALE); - const buttons = await SecurityManager.buildAlertOptions('server-12', translations); - expect(buttons.length).toBeGreaterThan(0); - const logoutButton = buttons.find((button) => button.text === translations[messages.logout.id]); - expect(logoutButton).toBeDefined(); - logoutButton?.onPress?.(); - expect(logout).toHaveBeenCalledWith('server-12', undefined); - }); - }); - - describe('showDeviceNotTrustedAlert', () => { - test('should show device not trusted alert', async () => { - const server = 'server-13'; - const siteName = 'Site Name'; - const translations = getTranslations(DEFAULT_LOCALE); - - await SecurityManager.showDeviceNotTrustedAlert(server, siteName, translations); - - expect(Alert.alert).toHaveBeenCalledWith( - translations[messages.blocked_by.id].replace('{vendor}', siteName), - translations[messages.jailbreak.id].replace('{vendor}', siteName), - expect.any(Array), - {cancelable: false}, - ); - }); - }); - - describe('showBiometricFailureAlert', () => { - test('should show biometric failure alert', async () => { - const server = 'server-14'; - const siteName = 'Site Name'; - const translations = getTranslations(DEFAULT_LOCALE); - - await SecurityManager.showBiometricFailureAlert(server, false, siteName, translations); - - expect(Alert.alert).toHaveBeenCalledWith( - translations[messages.blocked_by.id].replace('{vendor}', siteName), - translations[messages.biometric_failed.id], - expect.any(Array), - {cancelable: false}, - ); - }); - }); - describe('isDeviceJailbroken', () => { - test('should check if device is jailbroken', async () => { + test('should check if device is jailbroken and return true', async () => { const server = 'server-15'; const siteName = 'Site Name'; - SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig); + await SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as SecurityClientConfig); jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); - const translations = getTranslations(DEFAULT_LOCALE); const result = await SecurityManager.isDeviceJailbroken(server, siteName); expect(result).toBe(true); - await TestHelper.wait(300); - expect(Alert.alert).toHaveBeenCalledWith( - translations[messages.blocked_by.id].replace('{vendor}', siteName), - translations[messages.jailbreak.id].replace('{vendor}', siteName), - expect.any(Array), - {cancelable: false}, - ); + expect(isRootedExperimentalAsync).toHaveBeenCalled(); }); test('should return false if device is not jailbroken', async () => { const server = 'server-16'; const siteName = 'Site Name'; - SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as ClientConfig); + await SecurityManager.addServer(server, {MobileJailbreakProtection: 'true'} as SecurityClientConfig); jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false); const result = await SecurityManager.isDeviceJailbroken(server, siteName); expect(result).toBe(false); - expect(Alert.alert).not.toHaveBeenCalled(); + }); + + test('should return false if jailbreak protection is not enabled', async () => { + const server = 'server-17'; + await SecurityManager.addServer(server, {MobileJailbreakProtection: 'false'} as SecurityClientConfig); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); + + const result = await SecurityManager.isDeviceJailbroken(server); + + expect(result).toBe(false); + expect(isRootedExperimentalAsync).not.toHaveBeenCalled(); }); }); describe('getShieldScreenId', () => { test('should return the name of the screen shielded if prevent screen capture is enabled', () => { - SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig); + SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig); SecurityManager.activeServer = 'server-2'; expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen.shielded`); }); test('should return the name of the screen without shielded if active server is different', () => { - SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig); + SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig); SecurityManager.activeServer = 'server-1'; expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`); }); test('should return the name of the screen shielded if prevent screen capture is disabled', () => { - SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as ClientConfig); + SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as SecurityClientConfig); expect(SecurityManager.getShieldScreenId(Screens.CHANNEL)).toBe(`${Screens.CHANNEL}.screen`); }); test('should return the name of the screen shielded if prevent screen capture is disabled but forced', () => { - SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as ClientConfig); + SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'false'} as SecurityClientConfig); expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, true)).toBe(`${Screens.CHANNEL}.screen.shielded`); }); test('should return the name of the screen as shielded but skip', () => { - SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as ClientConfig); + SecurityManager.addServer('server-2', {MobilePreventScreenCapture: 'true'} as SecurityClientConfig); SecurityManager.activeServer = 'server-2'; expect(SecurityManager.getShieldScreenId(Screens.CHANNEL, false, true)).toBe(`${Screens.CHANNEL}.screen.skip.shielded`); }); diff --git a/app/managers/security_manager/index.ts b/app/managers/security_manager/index.ts index 601d71e66..657bb99d7 100644 --- a/app/managers/security_manager/index.ts +++ b/app/managers/security_manager/index.ts @@ -1,20 +1,49 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import Emm from '@mattermost/react-native-emm'; import {isRootedExperimentalAsync} from 'expo-device'; -import {createIntl, defineMessages} from 'react-intl'; -import {Alert, type AlertButton, AppState, type AppStateStatus, Platform} from 'react-native'; +import {AppState, DeviceEventEmitter, type AppStateStatus, type EventSubscription} from 'react-native'; -import {switchToServer} from '@actions/app/server'; +import {terminateSession} from '@actions/local/session'; +import {getCurrentUserLocale} from '@actions/local/user'; import {logout} from '@actions/remote/session'; -import {Preferences} from '@constants'; +import {Events} from '@constants'; +import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getTranslations} from '@i18n'; -import {getServerCredentials} from '@init/credentials'; import ManagedApp from '@init/managed_app'; +import IntuneManager from '@managers/intune_manager'; +import { + IntuneConditionalLaunchBlockedReasons, + type IntuneAuthRequiredEvent, + type IntuneConditionalLaunchBlockedEvent, + type IntuneEnrollmentChangedEvent, + type IntuneIdentitySwitchRequiredEvent, + type IntunePolicy, + type IntunePolicyChangedEvent, + type IntuneWipeRequestedEvent, +} from '@managers/intune_manager/types'; +import {queryAllActiveServers} from '@queries/app/servers'; +import {getConfig, getConfigValue, getSecurityConfig} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; +import { + messages, + showAuthenticationRequiredAlert, + showBiometricFailureAlert, + showBiometricFailureAlertForOrganization, + showConditionalAccessAlert, + showDeviceNotTrustedAlert, + showIdentitySwitchRequiredAlert, + showMAMDeclinedAlert, + showMAMEnrollmentFailedAlert, + showMAMEnrollmentRequiredAlert, + showNotSecuredAlert, +} from '@utils/alerts'; import {toMilliseconds} from '@utils/datetime'; import {isMainActivity} from '@utils/helpers'; -import {logError} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -25,102 +54,128 @@ type SecurityManagerServerConfig = { authenticated?: boolean; lastAccessed?: number; siteName?: string; + intunePolicy?: IntunePolicy | null; }; type SecurityManagerServersCollection = Record; -const messages = defineMessages({ - not_secured_vendor_ios: { - id: 'mobile.managed.not_secured.ios.vendor', - defaultMessage: 'This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.', - }, - not_secured_vendor_android: { - id: 'mobile.managed.not_secured.android.vendor', - defaultMessage: 'This device must be secured with a screen lock to use {vendor}.', - }, - not_secured_ios: { - id: 'mobile.managed.not_secured.ios', - defaultMessage: 'This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.', - }, - not_secured_android: { - id: 'mobile.managed.not_secured.android', - defaultMessage: 'This device must be secured with a screen lock to use Mattermost.', - }, - blocked_by: { - id: 'mobile.managed.blocked_by', - defaultMessage: 'Blocked by {vendor}', - }, - androidSettings: { - id: 'mobile.managed.settings', - defaultMessage: 'Go to settings', - }, - securedBy: { - id: 'mobile.managed.secured_by', - defaultMessage: 'Secured by {vendor}', - }, - logout: { - id: 'mobile.managed.logout', - defaultMessage: 'Logout', - }, - ok: { - id: 'mobile.managed.OK', - defaultMessage: 'OK', - }, - switchServer: { - id: 'mobile.managed.switch_server', - defaultMessage: 'Switch server', - }, - exit: { - id: 'mobile.managed.exit', - defaultMessage: 'Exit', - }, - jailbreak: { - id: 'mobile.managed.jailbreak', - defaultMessage: 'Jailbroken or rooted devices are not trusted by {vendor}.', - }, - biometric_failed: { - id: 'mobile.managed.biometric_failed', - defaultMessage: 'Biometric or Passcode authentication failed.', - }, -}); - class SecurityManagerSingleton { activeServer?: string; serverConfig: SecurityManagerServersCollection = {}; backgroundSince = 0; previousAppState?: AppStateStatus; initialized = false; + started = false; + isEnrolling = false; + isCheckingBiometrics = false; + needsEnrollmentCheck = false; + intunePolicySubscription?: EventSubscription; + intuneEnrollmentSubscription?: EventSubscription; + intuneWipeSubscription?: EventSubscription; + intuneAuthSubscription?: EventSubscription; + intuneBlockedSubscription?: EventSubscription; + intuneIdentitySwitchSubscription?: EventSubscription; constructor() { AppState.addEventListener('change', this.onAppStateChange); + DeviceEventEmitter.addListener(Events.ACTIVE_SERVER_CHANGED, this.setActiveServer); + DeviceEventEmitter.addListener(Events.LICENSE_CHANGED, this.onLicenseChanged); + DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onConfigChanged); + + // Setup Intune event listeners + this.intunePolicySubscription = IntuneManager.subscribeToPolicyChanges(this.onIntunePolicyChanged); + this.intuneEnrollmentSubscription = IntuneManager.subscribeToEnrollmentChanges(this.onEnrollmentChanged); + this.intuneWipeSubscription = IntuneManager.subscribeToWipeRequests(this.onWipeRequested); + this.intuneAuthSubscription = IntuneManager.subscribeToAuthRequired(this.onAuthRequired); + this.intuneBlockedSubscription = IntuneManager.subscribeToConditionalLaunchBlocked(this.onConditionalLaunchBlocked); + this.intuneIdentitySwitchSubscription = IntuneManager.subscribeToIdentitySwitchRequired(this.onIdentitySwitchRequired); } /** - * Initializes the class with existing servers on app launch. - * Should be called when the app starts. + * Initializes SecurityManager by loading server configs and Intune policies + * Should be called during app startup in app/init/app.ts */ - async init(servers: Record, activeServer?: string) { + async init() { if (this.initialized) { return; } - this.initialized = true; - const added = new Set(); - for (const [server, config] of Object.entries(servers)) { - if (!this.serverConfig[server]) { - this.addServer(server, config); - added.add(server); + logDebug('SecurityManager: Initializing'); + + const loadServerConfig = async (serverUrl: string) => { + try { + // Get security config from server database + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const config = await getSecurityConfig(database); + + // Get Intune policy if available + const intunePolicy = await IntuneManager.getPolicy(serverUrl); + + // Add server to config map + this.addServer(serverUrl, config, false, intunePolicy); + } catch (error) { + logError('SecurityManager: Failed to load config for server', {error}); } + }; + + try { + // Query all active servers from app database + const servers = await queryAllActiveServers()?.fetch(); + if (!servers || servers.length === 0) { + logDebug('SecurityManager: No active servers found'); + this.initialized = true; + return; + } + + // Fetch configs and policies for all servers in parallel + await Promise.all(servers.map((server) => loadServerConfig(server.url))); + + // Retry any pending wipes that failed previously + await this.retryPendingWipes(); + } catch (error) { + logError('SecurityManager: Failed to initialize', error); } - if (activeServer && (!added.has(activeServer) || !this.activeServer)) { - this.activeServer = activeServer; - this.setScreenCapturePolicy(activeServer); - const isJailbroken = await this.isDeviceJailbroken(activeServer); - if (!isJailbroken) { - this.authenticateWithBiometricsIfNeeded(activeServer); - } + this.initialized = true; + } + + /** + * Start method applies security policies to the active server + * Should be called from Home screen after rendering + */ + async start() { + if (this.started) { + return; } + + this.started = true; + const serverUrl = await DatabaseManager.getActiveServerUrl(); + if (!serverUrl) { + logDebug('SecurityManager: No active server to start'); + return; + } + + // Delegate all logic to setActiveServer + this.setActiveServer({serverUrl}); + } + + /** + * Cleanup event listeners (for testing or shutdown) + */ + cleanup() { + logDebug('SecurityManager: Cleaning up'); + + this.intunePolicySubscription?.remove(); + this.intuneEnrollmentSubscription?.remove(); + this.intuneWipeSubscription?.remove(); + this.intuneAuthSubscription?.remove(); + this.intuneBlockedSubscription?.remove(); + this.intuneIdentitySwitchSubscription?.remove(); + + this.initialized = false; + this.serverConfig = {}; + this.activeServer = undefined; + this.started = false; } /** @@ -135,7 +190,7 @@ class SecurityManagerSingleton { const isBackground = appState === 'background'; if (isActive && this.previousAppState === 'background') { - if (this.activeServer) { + if (this.activeServer && !this.serverConfig[this.activeServer].intunePolicy?.isPINRequired) { const config = this.getServerConfig(this.activeServer); if (config && config.Biometrics && isMainActivity()) { const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= toMilliseconds({minutes: 5}); @@ -155,6 +210,223 @@ class SecurityManagerSingleton { this.previousAppState = appState; }; + /** + * Handles config changes from app + */ + onConfigChanged = async (event: {serverUrl: string; config: SecurityClientConfig}) => { + const {serverUrl, config} = event; + logDebug('SecurityManager: Config changed'); + + // Update server config + const existingConfig = this.serverConfig[serverUrl] || {}; + const intunePolicy = await IntuneManager.getPolicy(serverUrl); + + this.serverConfig[serverUrl] = { + ...existingConfig, + siteName: config.SiteName, + Biometrics: config.MobileEnableBiometrics === 'true', + JailbreakProtection: config.MobileJailbreakProtection === 'true', + PreventScreenCapture: config.MobilePreventScreenCapture === 'true', + intunePolicy, + }; + + if (serverUrl === this.activeServer) { + // If biometric auth is in progress, defer enrollment check + if (this.isCheckingBiometrics) { + logDebug('SecurityManager: Biometric auth in progress, deferring enrollment check'); + this.needsEnrollmentCheck = true; + return; + } + + // Check if MAM enrollment needed (method handles all checks internally) + const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl); + + // Only set screen capture policy if enrollment wasn't needed/succeeded + if (enrollmentOk && !this.isEnrolling) { + await IntuneManager.setCurrentIdentity(serverUrl); + this.setScreenCapturePolicy(serverUrl); + } + } + }; + + onLicenseChanged = async (event: {serverUrl: string; license: ClientLicense}) => { + const {serverUrl, license} = event; + + logDebug('SecurityManager: License changed', {isLicensed: license.IsLicensed, sku: license.SkuShortName}); + + this.serverConfig[serverUrl] = this.serverConfig[serverUrl] || {}; + + const intunePolicy = await IntuneManager.getPolicy(serverUrl); + this.serverConfig[serverUrl].intunePolicy = intunePolicy; + + if (serverUrl === this.activeServer) { + // Check if MAM enrollment needed (method handles all checks internally) + const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl); + + if (enrollmentOk && !this.isEnrolling) { + await IntuneManager.setCurrentIdentity(serverUrl); + this.setScreenCapturePolicy(serverUrl); + } + } + }; + + /** + * Handles Intune policy changes from IntuneManager + */ + onIntunePolicyChanged = (event: IntunePolicyChangedEvent) => { + if (!this.initialized) { + return; + } + + const {changed, removed, policy, serverUrls} = event; + + // Update cached Intune policies for affected servers + for (const serverUrl of serverUrls) { + if (!this.serverConfig[serverUrl]) { + this.serverConfig[serverUrl] = {}; + } + + if (removed) { + this.serverConfig[serverUrl].intunePolicy = null; + } else if (changed && policy) { + this.serverConfig[serverUrl].intunePolicy = policy; + } + } + + // Only re-apply policies if the active server is affected + if (this.activeServer && serverUrls.includes(this.activeServer)) { + this.setScreenCapturePolicy(this.activeServer); + } + }; + + /** + * Handles enrollment status changes from Intune SDK + */ + onEnrollmentChanged = (event: IntuneEnrollmentChangedEvent) => { + const {enrolled, reason, serverUrls} = event; + + logDebug('SecurityManager: Enrollment changed', {enrolled, reason}); + + if (enrolled) { + // Successful enrollment + this.handleEnrollmentSuccess(serverUrls); + } else { + // Unenrollment - remove intunePolicy from affected servers + this.handleUnenrollment(serverUrls, reason); + } + }; + + /** + * Handles selective wipe requests from Intune SDK + */ + onWipeRequested = async (event: IntuneWipeRequestedEvent) => { + const {oid, serverUrls} = event; + + logDebug('SecurityManager: Wipe requested', {serverCount: serverUrls.length}); + + let success = true; + + // Wipe non-active servers + for await (const serverUrl of serverUrls) { + if (serverUrl === this.activeServer) { + // We do this one last to avoid issues with active server changes + continue; + } + + const wipeSuccess = await this.performSelectiveWipe(serverUrl, true); + if (!wipeSuccess) { + success = false; + } + this.removeServer(serverUrl); + } + + // Finally wipe active server if needed + if (this.activeServer && serverUrls.includes(this.activeServer)) { + const wipeSuccess = await this.performSelectiveWipe(this.activeServer, false); + if (!wipeSuccess) { + success = false; + } + this.removeServer(this.activeServer); + } + + // Cleanup storage and MSAL account after all wipes complete + await IntuneManager.cleanupAfterWipe(oid); + + // Report completion status to native (clears pending state if successful) + await IntuneManager.reportWipeComplete(oid, success); + }; + + /** + * Handles authentication required events from Intune SDK + */ + onAuthRequired = async (event: IntuneAuthRequiredEvent) => { + const {oid, serverUrls, reason} = event; + + logDebug('SecurityManager: Auth required', {serverCount: serverUrls.length, reason}); + + Emm.enableBlurScreen(true); + Emm.applyBlurEffect(20); + this.onWipeRequested({oid, serverUrls}); + + const locale = await getCurrentUserLocale(serverUrls[0]); + showAuthenticationRequiredAlert(reason, locale, () => { + Emm.removeBlurEffect(); + Emm.enableBlurScreen(false); + }); + }; + + /** + * Handles conditional launch blocked events from Intune SDK + */ + onConditionalLaunchBlocked = async (event: IntuneConditionalLaunchBlockedEvent) => { + const {oid, reason, serverUrls} = event; + + logDebug('SecurityManager: Conditional launch blocked', {reason, serverCount: serverUrls.length}); + + if (reason === IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED) { + // Conditional launch policy blocked (OS version, jailbreak, threat level) + // Trigger selective wipe of managed data + Emm.enableBlurScreen(true); + Emm.applyBlurEffect(20); + + this.onWipeRequested({oid, serverUrls}); + const locale = await getCurrentUserLocale(serverUrls[0]); + showConditionalAccessAlert(locale, () => { + Emm.removeBlurEffect(); + Emm.enableBlurScreen(false); + }); + } else if (reason === IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED) { + // User canceled conditional launch (dismissed PIN/auth prompt) + // Allow retry with biometric prompt + await new Promise((resolve) => setTimeout(resolve, 250)); + Emm.enableBlurScreen(true); + Emm.applyBlurEffect(20); + + const locale = await getCurrentUserLocale(serverUrls[0]); + + await showBiometricFailureAlertForOrganization(serverUrls[0], locale, async () => { + Emm.removeBlurEffect(); + + // Retry by setting current identity again + await IntuneManager.setCurrentIdentity(serverUrls[0]); + }); + Emm.enableBlurScreen(false); + } + }; + + /** + * Handles identity switch required events from Intune SDK + */ + onIdentitySwitchRequired = async (event: IntuneIdentitySwitchRequiredEvent) => { + const {oid, reason, serverUrls} = event; + + logDebug('SecurityManager: Identity switch required', {reason, serverCount: serverUrls.length}); + const locale = await getCurrentUserLocale(serverUrls[0]); + this.onWipeRequested({oid, serverUrls}); + + showIdentitySwitchRequiredAlert(locale); + }; + /** * Checks if EMM is already enabled and setup * to handle biometric / passcode authentication. @@ -181,6 +453,7 @@ class SecurityManagerSingleton { /** * Get the configuration of a server to prevent screenshots. + * MAM policy takes precedence over server config. */ isScreenCapturePrevented = (server: string) => { const config = this.getServerConfig(server); @@ -188,11 +461,19 @@ class SecurityManagerSingleton { return false; } + // Check Intune MAM policy first - MAM always wins + if (config.intunePolicy?.isScreenCaptureAllowed === false) { + // MAM explicitly disallows screen capture, so server policy doesn't apply + return false; + } + + // Fall back to server config if no MAM policy applies return config.PreventScreenCapture == null ? false : config.PreventScreenCapture; }; /** * Checks if the device is Jailbroken or Rooted. + * Skips check if MAM controls jailbreak detection. */ isDeviceJailbroken = async (server: string, siteName?: string) => { if (this.isJalbreakProtectionHandledByEmm()) { @@ -204,12 +485,16 @@ class SecurityManagerSingleton { return false; } - const locale = DEFAULT_LOCALE; - const translations = getTranslations(locale); + // Skip check if MAM policy is active - MAM handles jailbreak detection + if (config?.intunePolicy != null) { + return false; + } + + // Fall back to server config if MAM doesn't control jailbreak detection if (config?.JailbreakProtection || siteName) { const isRooted = await isRootedExperimentalAsync(); if (isRooted) { - this.showDeviceNotTrustedAlert(server, siteName, translations); + showDeviceNotTrustedAlert(server, siteName, DEFAULT_LOCALE); return true; } } @@ -220,14 +505,16 @@ class SecurityManagerSingleton { /** * Add the config for a server. */ - addServer = (server: string, config?: SecurityClientConfig, authenticated = false) => { + addServer = async (server: string, config?: SecurityClientConfig, authenticated = false, intunePolicy: IntunePolicy | null = null) => { const mobileConfig: SecurityManagerServerConfig = { siteName: config?.SiteName, Biometrics: config?.MobileEnableBiometrics === 'true', JailbreakProtection: config?.MobileJailbreakProtection === 'true', PreventScreenCapture: config?.MobilePreventScreenCapture === 'true', authenticated, + intunePolicy, }; + this.serverConfig[server] = mobileConfig; }; @@ -250,54 +537,174 @@ class SecurityManagerSingleton { }; /** - * Switches the active server. + * Ensures MAM enrollment for a server that requires it. + * Shows alert with blur screen and performs enrollment via direct IntuneManager calls. + * + * @param serverUrl - Server URL to check and enroll + * @returns Promise - true if enrollment OK, false if failed/declined */ - setActiveServer = (server: string) => { - if (this.activeServer === server) { + async ensureMAMEnrollmentForActiveServer(serverUrl: string): Promise { + // Check if already enrolling to prevent race conditions + if (this.isEnrolling) { + logDebug('ensureMAMEnrollment: Already enrolling, skipping'); + return true; + } + + // Check if Intune MAM is enabled + const isIntuneEnabled = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl); + if (!isIntuneEnabled) { + return true; + } + + // Check if already enrolled + const isManaged = await IntuneManager.isManagedServer(serverUrl); + if (isManaged) { + return true; + } + + // Get server config and current user + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const currentUser = await getCurrentUser(database); + const authService = await getConfigValue(database, 'IntuneAuthService'); + + // Check if current user is not using SSO entra login, skip enrollment + if (currentUser && currentUser.authService.toLocaleLowerCase() !== authService?.toLocaleLowerCase()) { + return true; + } + + const config = await getConfig(database); + const intuneScope = config.IntuneScope; + + if (!intuneScope) { + logError('ensureMAMEnrollment: IntuneScope not configured'); + return false; + } + + // Set enrolling flag and apply blur + this.isEnrolling = true; + + // Get site name and locale for alerts + const siteName = this.serverConfig[serverUrl]?.siteName; + const locale = await getCurrentUserLocale(serverUrl); + + // Show alert and handle enrollment + return new Promise(async (resolve) => { + logDebug('ensureMAMEnrollment: Enrollment required', {serverUrl}); + + Emm.enableBlurScreen(true); + Emm.applyBlurEffect(20); + + // Give time for blur effect to apply + await new Promise((resolveEffect) => setTimeout(resolveEffect, 250)); + + const beforeExit = () => { + // Always clear enrolling flag and remove blur + this.isEnrolling = false; + Emm.removeBlurEffect(); + this.setScreenCapturePolicy(serverUrl); + }; + + const handleEnrollment = async () => { + try { + // Step 1: Acquire MSAL tokens via native login + const tokens = await IntuneManager.login(serverUrl, [intuneScope]); + + // Step 2: Enroll in MAM (NO token sent to server - session exists) + // If enrollServer doesn't throw, consider it successful + // Policy and enrollment status will be updated via events + await IntuneManager.enrollServer(serverUrl, tokens.identity); + + logDebug('ensureMAMEnrollment: Enrollment successful'); + + // Always clear enrolling flag and remove blur + beforeExit(); + resolve(true); + } catch (error) { + logError('ensureMAMEnrollment: Failed', error); + + await showMAMEnrollmentFailedAlert(locale, () => { + // Always clear enrolling flag and remove blur + beforeExit(); + logout(serverUrl, undefined, {removeServer: true}); + resolve(false); + }); + } + }; + + const handleCancel = () => { + logDebug('ensureMAMEnrollment: User declined enrollment'); + + // Show declined alert with retry option + showMAMDeclinedAlert(serverUrl, siteName, locale, () => { + beforeExit(); + resolve(false); + }, handleEnrollment); + }; + + // Show enrollment required alert + showMAMEnrollmentRequiredAlert(siteName, locale, handleEnrollment, handleCancel); + }); + } + + /** + * Switches the active server and applies security policies. + * Called via ACTIVE_SERVER_CHANGED event or directly. + * + * @param serverUrl - Server URL to activate + * @param options - Optional skip flags for certain checks + */ + setActiveServer = async ({serverUrl, options}: {serverUrl: string; options?: ActiveServerOptions}) => { + const opts = options || {}; + + // Set Intune identity + await IntuneManager.setCurrentIdentity(serverUrl); + if (this.activeServer === serverUrl && !opts.forceSwitch) { // active server is not changing, so no need to do anything here return; } + // Update active server tracking if (this.activeServer && this.serverConfig[this.activeServer]) { this.serverConfig[this.activeServer].lastAccessed = Date.now(); } - if (this.serverConfig[server]) { - this.activeServer = server; - this.serverConfig[server].lastAccessed = Date.now(); - this.setScreenCapturePolicy(server); + if (!this.serverConfig[serverUrl]) { + return; } - }; - /** - * Gets the last accessed server. - */ - getLastAccessedServer = (otherServers: string[]) => { - const lastAccessed = otherServers.map((s) => this.serverConfig[s].lastAccessed).sort((a: number, b: number) => b - a)[0]; - return Object.keys(this.serverConfig).find((s) => this.serverConfig[s].lastAccessed === lastAccessed); - }; + this.activeServer = serverUrl; + this.serverConfig[serverUrl].lastAccessed = Date.now(); + this.setScreenCapturePolicy(serverUrl); - /** - * Switches to the previous server. - */ - goToPreviousServer = async (otherServers: string[]) => { - // Switch to last accessed server - const lastAccessedServer = this.getLastAccessedServer(otherServers); - if (lastAccessedServer) { - const theme = Preferences.THEMES.denim; - const locale = DEFAULT_LOCALE; + // Security checks (moved from start() method) + // Order matters: MAM enrollment first, then jailbreak, then biometrics + // This way we only check jailbreak/biometrics if user doesn't need to enroll - const intl = createIntl({ - locale, - defaultLocale: DEFAULT_LOCALE, - messages: getTranslations(locale), - }); - await switchToServer(lastAccessedServer, theme, intl); + // 1. Check MAM enrollment requirement (IMPORTANT: do not skip without explicit flag) + if (!opts.skipMAMEnrollmentCheck) { + const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl); + if (!enrollmentOk || this.isEnrolling) { + return; + } + } + + // 2. Check jailbreak protection + if (!opts.skipJailbreakCheck) { + const isJailbroken = await this.isDeviceJailbroken(serverUrl); + if (isJailbroken) { + return; + } + } + + // 3. Check biometric authentication + if (!opts.skipBiometricCheck) { + await this.authenticateWithBiometricsIfNeeded(serverUrl); } }; /** * Determines if biometric authentication should be prompted. + * MAM policy takes precedence over server config. */ authenticateWithBiometricsIfNeeded = async (server: string) => { if (this.isAuthenticationHandledByEmm()) { @@ -309,6 +716,12 @@ class SecurityManagerSingleton { return true; } + // Check Intune MAM policy first - if MAM requires PIN, skip server config + if (config.intunePolicy?.isPINRequired === true) { + return true; + } + + // Fall back to server config if MAM doesn't require PIN if (config?.Biometrics) { const lastAccessed = config?.lastAccessed ?? 0; const timeSinceLastAccessed = Date.now() - lastAccessed; @@ -324,46 +737,76 @@ class SecurityManagerSingleton { * Handles biometric authentication. */ authenticateWithBiometrics = async (server: string, siteName?: string) => { - if (this.isAuthenticationHandledByEmm()) { + // Prevent concurrent biometric checks + if (this.isCheckingBiometrics) { + logDebug('SecurityManager: Biometric check already in progress, skipping'); return true; } - const config = this.getServerConfig(server); - if (!config && !siteName) { - return true; - } + this.isCheckingBiometrics = true; - const locale = DEFAULT_LOCALE; - const translations = getTranslations(locale); - - const isSecured = await Emm.isDeviceSecured(); - if (!isSecured) { - await this.showNotSecuredAlert(server, siteName, translations); - return false; - } - const shouldBlurOnAuthenticate = server === this.activeServer && this.isScreenCapturePrevented(server); try { - const auth = await Emm.authenticate({ - reason: translations[messages.securedBy.id].replace('{vendor}', siteName || config?.siteName || 'Mattermost'), - fallback: true, - supressEnterPassword: true, - blurOnAuthenticate: shouldBlurOnAuthenticate, - }); - - if (config) { - config.authenticated = auth; + if (this.isAuthenticationHandledByEmm()) { + return true; } - if (!auth) { - throw new Error('Authorization cancelled'); + const config = this.getServerConfig(server); + if (!config && !siteName) { + return true; } - } catch (err) { - logError('Failed to authenticate with biometrics', err); - this.showBiometricFailureAlert(server, shouldBlurOnAuthenticate, siteName, translations); - return false; + + // Check Intune MAM policy first - if MAM requires PIN, skip server config + if (config?.intunePolicy?.isPINRequired === true) { + return true; + } + + const locale = DEFAULT_LOCALE; + const translations = getTranslations(locale); + + const isSecured = await Emm.isDeviceSecured(); + if (!isSecured) { + await showNotSecuredAlert(server, siteName, locale); + return false; + } + const shouldBlurOnAuthenticate = server === this.activeServer && this.isScreenCapturePrevented(server); + try { + const auth = await Emm.authenticate({ + reason: translations[messages.securedBy.id].replace('{vendor}', siteName || config?.siteName || 'Mattermost'), + fallback: true, + supressEnterPassword: true, + blurOnAuthenticate: shouldBlurOnAuthenticate, + }); + + if (config) { + config.authenticated = auth; + } + + if (!auth) { + throw new Error('Authorization cancelled'); + } + } catch (err) { + logError('Failed to authenticate with biometrics', err); + showBiometricFailureAlert(server, shouldBlurOnAuthenticate, siteName, locale); + return false; + } + + // After successful authentication, check if enrollment was deferred + if (this.needsEnrollmentCheck) { + this.needsEnrollmentCheck = false; + logDebug('SecurityManager: Triggering deferred enrollment check'); + const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(server); + + // Apply screen capture policy if enrollment succeeded + if (enrollmentOk && !this.isEnrolling) { + await IntuneManager.setCurrentIdentity(server); + this.setScreenCapturePolicy(server); + } + } + + return true; + } finally { + this.isCheckingBiometrics = false; } - - return true; }; /** @@ -390,130 +833,114 @@ class SecurityManagerSingleton { }; /** - * Builds the alert options for the alert. + * Checks if saving to a location is allowed by Intune policy. */ - buildAlertOptions = async (server: string, translations: Record, callback?: (value: boolean) => void) => { - const buttons: AlertButton[] = []; - const hasSessionToServer = await getServerCredentials(server); - if (server && hasSessionToServer) { - buttons.push({ - text: translations[messages.logout.id], - style: 'destructive', - onPress: async () => { - await logout(server, undefined); - callback?.(true); - }, - }); + canSaveToLocation = (serverUrl: string, location: keyof IntunePolicy['allowedSaveLocations']) => { + const policy = this.serverConfig[serverUrl]?.intunePolicy; + if (!policy) { + return true; } - const otherServers = Object.keys(this.serverConfig).filter((s) => s !== server); - if (otherServers.length > 0) { - if (otherServers.length === 1 && otherServers[0] === this.activeServer) { - buttons.push({ - text: translations[messages.ok.id], - style: 'cancel', - onPress: () => { - callback?.(true); - }, - }); - } else { - buttons.push({ - text: translations[messages.switchServer.id], - style: 'cancel', - onPress: () => { - this.goToPreviousServer(otherServers); - callback?.(true); - }, - }); - } - } - - if (buttons.length === 0) { - buttons.push({ - text: translations[messages.exit.id], - style: 'destructive', - onPress: () => { - Emm.exitApp(); - }, - }); - } - - return buttons; + return policy.allowedSaveLocations[location]; }; - showDeviceNotTrustedAlert = async (server: string, siteName: string | undefined, translations: Record) => { - const buttons = await this.buildAlertOptions(server, translations); - const securedBy = siteName || this.getServerConfig(server)?.siteName || 'Mattermost'; + // ============================================================================ + // Helper Methods for Intune Event Handling + // ============================================================================ - Alert.alert( - translations[messages.blocked_by.id].replace('{vendor}', securedBy), - translations[messages.jailbreak.id]. - replace('{vendor}', securedBy), - buttons, - {cancelable: false}, - ); + /** + * Handle successful enrollment + */ + private handleEnrollmentSuccess = async (serverUrls: string[]) => { + logDebug('SecurityManager: Handling enrollment success', {serverCount: serverUrls.length}); + + // Fetch policy for first server (all servers for same identity share policy) + const policy = await IntuneManager.getPolicy(serverUrls[0]); + + // Update policy for all affected servers + for (const serverUrl of serverUrls) { + if (!this.serverConfig[serverUrl]) { + this.serverConfig[serverUrl] = {}; + } + this.serverConfig[serverUrl].intunePolicy = policy; + } + + // Set current identity if any affected server is active + const currentServer = await DatabaseManager.getActiveServerUrl(); + if (currentServer && serverUrls.includes(currentServer)) { + await IntuneManager.setCurrentIdentity(currentServer); + this.setScreenCapturePolicy(currentServer); + } }; /** - * Shows an alert when the device does not have biometrics or passcode set. + * Handle unenrollment - remove intunePolicy from affected servers */ - showNotSecuredAlert = async (server: string, siteName: string | undefined, translations: Record) => { - const buttons: AlertButton[] = []; - const config = this.serverConfig[server]; - const securedBy = siteName || config?.siteName || 'Mattermost'; + private handleUnenrollment = async (serverUrls: string[], reason?: string) => { + logDebug('SecurityManager: Handling unenrollment', {serverCount: serverUrls.length, reason}); - if (Platform.OS === 'android') { - buttons.push({ - text: translations[messages.androidSettings.id], - onPress: () => { - Emm.openSecuritySettings(); - }, - }); + // Remove intunePolicy from all affected servers + for (const serverUrl of serverUrls) { + if (this.serverConfig[serverUrl]) { + this.serverConfig[serverUrl].intunePolicy = null; + } } - const alertButtons = await this.buildAlertOptions(server, translations); - buttons.push(...alertButtons); - - let message; - if (config?.siteName || siteName) { - const key = Platform.select({ios: messages.not_secured_vendor_ios.id, default: messages.not_secured_vendor_android.id}); - message = translations[key].replace('{vendor}', securedBy); - } else { - const key = Platform.select({ios: messages.not_secured_ios.id, default: messages.not_secured_android.id}); - message = translations[key]; + // Re-apply policies if the active server is affected + const currentServer = await DatabaseManager.getActiveServerUrl(); + if (currentServer && serverUrls.includes(currentServer)) { + this.setScreenCapturePolicy(currentServer); } - - Alert.alert( - translations[messages.blocked_by.id].replace('{vendor}', securedBy), - message, - buttons, - {cancelable: false}, - ); }; /** - * Shows an alert when biometric authentication fails. + * Perform selective wipe for affected servers + * @returns true if wipe succeeded, false if it failed */ - showBiometricFailureAlert = async (server: string, blurOnAuthenticate: boolean, siteName: string | undefined, translations: Record) => { - const buttons = await this.buildAlertOptions(server, translations, () => { - if (blurOnAuthenticate) { - Emm.removeBlurEffect(); + private performSelectiveWipe = async (serverUrl: string, skipEvents: boolean): Promise => { + // Try to logout from server (skip alert dialog for automated wipes) + await logout(serverUrl, undefined, {skipServerLogout: false, skipEvents, skipAlert: true}); + + // Always call terminateSession to clean up local data (even if server logout failed) + if (skipEvents) { + const result = await terminateSession(serverUrl, false); + if (result.error) { + logError('SecurityManager: terminateSession failed', {errors: result.error}); + return false; } + } + + logDebug('SecurityManager: Server wiped successfully'); + return true; + }; + + /** + * Retry any pending wipes that failed in a previous app session + */ + private retryPendingWipes = async () => { + const pendingWipes = await IntuneManager.getPendingWipes(); + if (pendingWipes.length === 0) { + return; + } + + logDebug('SecurityManager: Retrying pending wipes', {count: pendingWipes.length}); + + pendingWipes.forEach(async (wipe) => { + const {oid, serverUrls} = wipe; + + // Retry wipe for each server + const results = await Promise.all( + serverUrls.map((serverUrl) => this.performSelectiveWipe(serverUrl, true)), + ); + + // Check if all wipes succeeded + const success = results.every((result) => result === true); + + // Report completion status (clears pending state if successful) + await IntuneManager.reportWipeComplete(oid, success); }); - const securedBy = siteName || this.getServerConfig(server)?.siteName || 'Mattermost'; - - Alert.alert( - translations[messages.blocked_by.id].replace('{vendor}', securedBy), - translations[messages.biometric_failed.id], - buttons, - {cancelable: false}, - ); }; } const SecurityManager = new SecurityManagerSingleton(); export default SecurityManager; - -export const exportsForTesting = { - messages, -}; diff --git a/app/managers/security_manager/mam.test.ts b/app/managers/security_manager/mam.test.ts new file mode 100644 index 000000000..c316e513e --- /dev/null +++ b/app/managers/security_manager/mam.test.ts @@ -0,0 +1,717 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import Emm from '@mattermost/react-native-emm'; +import {isRootedExperimentalAsync} from 'expo-device'; +import {type EventSubscription} from 'react-native'; + +import {getCurrentUserLocale} from '@actions/local/user'; +import {logout} from '@actions/remote/session'; +import {Sso} from '@constants'; +import DatabaseManager from '@database/manager'; +import IntuneManager from '@managers/intune_manager'; +import {type IntunePolicy} from '@managers/intune_manager/types'; +import {getConfig, getConfigValue} from '@queries/servers/system'; +import {getCurrentUser} from '@queries/servers/user'; +import TestHelper from '@test/test_helper'; +import * as alerts from '@utils/alerts'; +import {logError} from '@utils/log'; + +import SecurityManager from '.'; + +import type {ServerDatabase} from '@typings/database/database'; +import type UserModel from '@typings/database/models/servers/user'; + +jest.mock('@mattermost/react-native-emm', () => ({ + isDeviceSecured: jest.fn(), + authenticate: jest.fn(), + openSecuritySettings: jest.fn(), + exitApp: jest.fn(), + enableBlurScreen: jest.fn(), + applyBlurEffect: jest.fn(), + removeBlurEffect: jest.fn(), + addListener: jest.fn(), + setAppGroupId: jest.fn(), + getManagedConfig: jest.fn(() => ({})), +})); + +jest.mock('expo-device', () => ({ + isRootedExperimentalAsync: jest.fn(), +})); +jest.mock('@actions/local/session', () => ({terminateSession: jest.fn(() => Promise.resolve())})); +jest.mock('@actions/local/user', () => ({getCurrentUserLocale: jest.fn(() => Promise.resolve('en'))})); +jest.mock('@actions/remote/session', () => ({ + logout: jest.fn(() => Promise.resolve()), +})); +jest.mock('@utils/log', () => ({ + logError: jest.fn(), + logDebug: jest.fn(), +})); +jest.mock('@init/credentials', () => ({getServerCredentials: jest.fn().mockResolvedValue({token: 'token'})})); +jest.mock('@database/manager', () => ({ + getActiveServerUrl: jest.fn(), + getServerDatabaseAndOperator: jest.fn(), +})); +jest.mock('@queries/servers/system', () => ({ + getConfig: jest.fn(), + getConfigValue: jest.fn(), +})); +jest.mock('@queries/servers/user', () => ({ + getCurrentUser: jest.fn(), +})); + +describe('SecurityManager - Intune MAM Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + SecurityManager.initialized = false; + SecurityManager.serverConfig = {}; + SecurityManager.activeServer = undefined; + }); + + // Default policy with strict restrictions (all policies enforced) + const mockRestrictiveIntunePolicy: IntunePolicy = { + isPINRequired: true, + isContactSyncAllowed: false, + isWidgetContentSyncAllowed: false, + isSpotlightIndexingAllowed: false, + areSiriIntentsAllowed: false, + areAppIntentsAllowed: false, + isAppSharingAllowed: false, + shouldFileProviderEncryptFiles: true, + isManagedBrowserRequired: true, + isFileEncryptionRequired: true, + isScreenCaptureAllowed: false, + notificationPolicy: 2, // Block all notifications + allowedSaveLocations: { + Other: false, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: false, + PhotoLibrary: false, + CameraRoll: false, + FilesApp: false, + iCloudDrive: false, + }, + allowedOpenLocations: 0, + }; + + const mockPermissiveIntunePolicy: IntunePolicy = { + isPINRequired: false, + isContactSyncAllowed: true, + isWidgetContentSyncAllowed: true, + isSpotlightIndexingAllowed: true, + areSiriIntentsAllowed: true, + areAppIntentsAllowed: true, + isAppSharingAllowed: true, + shouldFileProviderEncryptFiles: false, + isManagedBrowserRequired: false, + isFileEncryptionRequired: false, + isScreenCaptureAllowed: true, + notificationPolicy: 0, // Allow all notifications + allowedSaveLocations: { + Other: true, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: true, + PhotoLibrary: true, + CameraRoll: true, + FilesApp: true, + iCloudDrive: true, + }, + allowedOpenLocations: 255, // All locations + }; + + describe('start', () => { + test('should set current Intune identity and apply policies', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig); + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false); + + await SecurityManager.start(); + + expect(IntuneManager.setCurrentIdentity).toHaveBeenCalledWith(serverUrl); + expect(SecurityManager.activeServer).toBe(serverUrl); + }); + + test('should handle missing active server', async () => { + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(''); + + await SecurityManager.start(); + + expect(IntuneManager.setCurrentIdentity).not.toHaveBeenCalled(); + }); + + test('should skip biometric auth when device is jailbroken', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true', MobileJailbreakProtection: 'true'} as SecurityClientConfig); + jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl); + jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true); + + await SecurityManager.start(); + + expect(Emm.authenticate).not.toHaveBeenCalled(); + }); + }); + + describe('isScreenCapturePrevented - MAM policy precedence', () => { + test('should return false (block capture) when MAM disallows even if server allows', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false); + }); + + test('should return true (allow capture) when both MAM and server allow', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + + expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false); + }); + + test('should return true (block capture) when server prevents and MAM allows', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'true'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + + expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(true); + }); + + test('should return false (block capture) when MAM disallows regardless of server', () => { + const serverUrl = 'https://test.server.com'; + + // Server allows capture, but MAM blocks - MAM wins + SecurityManager.addServer(serverUrl, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + expect(SecurityManager.isScreenCapturePrevented(serverUrl)).toBe(false); + }); + + test('should use server config when no Intune policy', () => { + const serverUrl1 = 'https://test1.server.com'; + const serverUrl2 = 'https://test2.server.com'; + SecurityManager.addServer(serverUrl1, {MobilePreventScreenCapture: 'true'} as SecurityClientConfig, false, null); + SecurityManager.addServer(serverUrl2, {MobilePreventScreenCapture: 'false'} as SecurityClientConfig, false, null); + + expect(SecurityManager.isScreenCapturePrevented(serverUrl1)).toBe(true); + expect(SecurityManager.isScreenCapturePrevented(serverUrl2)).toBe(false); + }); + }); + + describe('authenticateWithBiometricsIfNeeded - isPINRequired enforcement', () => { + test('should skip biometric auth when MAM requires PIN', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl); + + expect(result).toBe(true); + expect(Emm.authenticate).not.toHaveBeenCalled(); + }); + + test('should use server biometric config when MAM PIN not required', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); + jest.mocked(Emm.authenticate).mockResolvedValue(true); + + const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl); + + expect(result).toBe(true); + expect(Emm.authenticate).toHaveBeenCalled(); + }); + + test('should not require auth when server disables biometrics and MAM PIN not required', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'false'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + + const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl); + + expect(result).toBe(true); + expect(Emm.authenticate).not.toHaveBeenCalled(); + }); + + test('should skip auth when MAM requires PIN even if server enables biometrics', async () => { + const serverUrl = 'https://test.server.com'; + + // Server wants biometrics, but MAM requires PIN - MAM wins + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl); + + expect(result).toBe(true); + expect(Emm.authenticate).not.toHaveBeenCalled(); + }); + + test('should use server config when no Intune policy', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, null); + jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); + jest.mocked(Emm.authenticate).mockResolvedValue(true); + + const result = await SecurityManager.authenticateWithBiometricsIfNeeded(serverUrl); + + expect(result).toBe(true); + expect(Emm.authenticate).toHaveBeenCalled(); + }); + }); + + describe('authenticateWithBiometrics - isPINRequired enforcement', () => { + test('should skip authentication when MAM handles PIN', async () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + const result = await SecurityManager.authenticateWithBiometrics(serverUrl); + + expect(result).toBe(true); + expect(Emm.isDeviceSecured).not.toHaveBeenCalled(); + expect(Emm.authenticate).not.toHaveBeenCalled(); + }); + + test('should attempt authentication when MAM does not require PIN', async () => { + const serverUrl = 'https://test.server.com'; + const siteName = 'Test Site'; + SecurityManager.addServer(serverUrl, {MobileEnableBiometrics: 'true'} as unknown as SecurityClientConfig, false, mockPermissiveIntunePolicy); + jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true); + jest.mocked(Emm.authenticate).mockResolvedValue(true); + + const result = await SecurityManager.authenticateWithBiometrics(serverUrl, siteName); + + expect(result).toBe(true); + expect(Emm.authenticate).toHaveBeenCalled(); + }); + }); + + describe('cleanup', () => { + test('should remove all Intune event subscriptions', () => { + const mockSubscription = {remove: jest.fn()} as unknown as EventSubscription; + SecurityManager.intunePolicySubscription = mockSubscription; + SecurityManager.intuneEnrollmentSubscription = mockSubscription; + SecurityManager.intuneWipeSubscription = mockSubscription; + SecurityManager.intuneAuthSubscription = mockSubscription; + SecurityManager.intuneBlockedSubscription = mockSubscription; + SecurityManager.intuneIdentitySwitchSubscription = mockSubscription; + + SecurityManager.cleanup(); + + expect(mockSubscription.remove).toHaveBeenCalledTimes(6); + }); + + test('should handle missing subscriptions gracefully', () => { + SecurityManager.intunePolicySubscription = undefined; + SecurityManager.intuneEnrollmentSubscription = undefined; + + expect(() => SecurityManager.cleanup()).not.toThrow(); + }); + }); + + describe('addServer with Intune policy', () => { + test('should store restrictive Intune policy', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + + const config = SecurityManager.getServerConfig(serverUrl); + expect(config?.intunePolicy).toEqual(mockRestrictiveIntunePolicy); + expect(config?.intunePolicy?.isPINRequired).toBe(true); + expect(config?.intunePolicy?.isScreenCaptureAllowed).toBe(false); + }); + + test('should store permissive Intune policy', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + + const config = SecurityManager.getServerConfig(serverUrl); + expect(config?.intunePolicy).toEqual(mockPermissiveIntunePolicy); + expect(config?.intunePolicy?.isPINRequired).toBe(false); + expect(config?.intunePolicy?.isScreenCaptureAllowed).toBe(true); + }); + + test('should store null when not enrolled in Intune', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null); + + const config = SecurityManager.getServerConfig(serverUrl); + expect(config?.intunePolicy).toBeNull(); + }); + + test('should update policy when server re-enrolls', () => { + const serverUrl = 'https://test.server.com'; + + // First add without policy + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null); + expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).toBeNull(); + + // Then update with restrictive policy + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + const config = SecurityManager.getServerConfig(serverUrl); + expect(config?.intunePolicy?.isPINRequired).toBe(true); + }); + + test('should update from restrictive to permissive policy', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy?.isPINRequired).toBe(true); + + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockPermissiveIntunePolicy); + expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy?.isPINRequired).toBe(false); + }); + + test('should clear policy when unenrolled', () => { + const serverUrl = 'https://test.server.com'; + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, mockRestrictiveIntunePolicy); + expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).not.toBeNull(); + + SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig, false, null); + expect(SecurityManager.getServerConfig(serverUrl)?.intunePolicy).toBeNull(); + }); + }); + + describe('ensureMAMEnrollmentForActiveServer', () => { + const serverUrl = 'https://test.server.com'; + const mockDatabase = {database: 'mock-db'}; + const mockUser = {id: 'user-id', authService: Sso.OFFICE365}; + const mockConfig = {IntuneScope: 'https://msmamservice.api.application/.default', IntuneAuthService: Sso.OFFICE365, SiteName: 'Test Server'}; + const mockTokens = { + identity: {upn: 'user@test.com', tid: 'tenant-id', oid: 'object-id'}, + idToken: 'id-token', + accessToken: 'access-token', + }; + + beforeEach(async () => { + SecurityManager.isEnrolling = false; + await SecurityManager.addServer(serverUrl, {SiteName: 'Test Server'} as SecurityClientConfig); + jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue(mockDatabase as unknown as ServerDatabase); + jest.mocked(getCurrentUser).mockResolvedValue(mockUser as unknown as UserModel); + jest.mocked(getConfig).mockResolvedValue(mockConfig as ClientConfig); + jest.mocked(getConfigValue).mockResolvedValue(Sso.OFFICE365); + jest.mocked(getCurrentUserLocale).mockResolvedValue('en'); + }); + + test('should skip enrollment if already enrolling (race condition prevention)', async () => { + SecurityManager.isEnrolling = true; + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(IntuneManager.isIntuneMAMEnabledForServer).not.toHaveBeenCalled(); + }); + + test('should return true if Intune MAM not enabled for server', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(false); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(IntuneManager.isIntuneMAMEnabledForServer).toHaveBeenCalledWith(serverUrl); + expect(IntuneManager.isManagedServer).not.toHaveBeenCalled(); + }); + + test('should return true if server is already managed', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(true); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(IntuneManager.isManagedServer).toHaveBeenCalledWith(serverUrl); + expect(getCurrentUser).not.toHaveBeenCalled(); + }); + + test('should return true if user auth service is not Office365', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(getCurrentUser).mockResolvedValue({...mockUser, authService: 'SAML'} as unknown as UserModel); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(getCurrentUser).toHaveBeenCalledWith(mockDatabase.database); + }); + + test('should return false if IntuneScope not configured', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(getConfig).mockResolvedValue({IntuneScope: ''} as ClientConfig); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(false); + expect(logError).toHaveBeenCalledWith('ensureMAMEnrollment: IntuneScope not configured'); + }); + + test('should show enrollment required alert with blur screen enabled', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + + const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async () => { + // User cancels enrollment - don't call callbacks + }); + + // Don't await - we're testing the alert is shown + SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + // Wait for blur to be applied + await TestHelper.wait(300); + + expect(Emm.enableBlurScreen).toHaveBeenCalledWith(true); + expect(Emm.applyBlurEffect).toHaveBeenCalledWith(20); + expect(showAlertSpy).toHaveBeenCalledWith( + 'Test Server', + 'en', + expect.any(Function), + expect.any(Function), + ); + + showAlertSpy.mockRestore(); + }); + + test('should successfully enroll after user accepts', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens); + jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined); + + const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + // User accepts enrollment + onAccept(); + }); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(IntuneManager.login).toHaveBeenCalledWith(serverUrl, [mockConfig.IntuneScope]); + expect(IntuneManager.enrollServer).toHaveBeenCalledWith(serverUrl, mockTokens.identity); + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + expect(SecurityManager.isEnrolling).toBe(false); + + showAlertSpy.mockRestore(); + }); + + test('should clear isEnrolling flag after successful enrollment', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens); + jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined); + + const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + onAccept(); + }); + + expect(SecurityManager.isEnrolling).toBe(false); + await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + expect(SecurityManager.isEnrolling).toBe(false); + + showAlertSpy.mockRestore(); + }); + + test('should show enrollment failed alert on MSAL login failure', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockRejectedValue(new Error('MSAL login failed')); + + const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + onAccept(); + }); + const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => { + if (onDismiss) { + onDismiss(); + } + }); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(false); + expect(failedAlertSpy).toHaveBeenCalledWith('en', expect.any(Function)); + expect(logout).toHaveBeenCalledWith(serverUrl, undefined, {removeServer: true}); + expect(logError).toHaveBeenCalled(); + + enrollmentAlertSpy.mockRestore(); + failedAlertSpy.mockRestore(); + }); + + test('should show enrollment failed alert on MAM enrollment failure', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens); + jest.mocked(IntuneManager.enrollServer).mockRejectedValue(new Error('Enrollment failed')); + + const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + onAccept(); + }); + const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => { + if (onDismiss) { + onDismiss(); + } + }); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(false); + expect(failedAlertSpy).toHaveBeenCalledWith('en', expect.any(Function)); + + enrollmentAlertSpy.mockRestore(); + failedAlertSpy.mockRestore(); + }); + + test('should clear isEnrolling flag after enrollment failure', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockRejectedValue(new Error('Failed')); + + const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + onAccept(); + }); + const failedAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentFailedAlert').mockImplementation(async (_locale, onDismiss) => { + if (onDismiss) { + onDismiss(); + } + }); + + await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + expect(SecurityManager.isEnrolling).toBe(false); + + enrollmentAlertSpy.mockRestore(); + failedAlertSpy.mockRestore(); + }); + + test('should show MAM declined alert when user cancels', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + + const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, _onAccept, onCancel) => { + onCancel(); + }); + const declinedAlertSpy = jest.spyOn(alerts, 'showMAMDeclinedAlert').mockImplementation(async (_url, _siteName, _locale, onDismiss) => { + onDismiss(false); + }); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(false); + expect(declinedAlertSpy).toHaveBeenCalledWith( + serverUrl, + 'Test Server', + 'en', + expect.any(Function), + expect.any(Function), + ); + + enrollmentAlertSpy.mockRestore(); + declinedAlertSpy.mockRestore(); + }); + + test('should allow retry after user cancels', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens); + jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined); + + const enrollmentAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, _onAccept, onCancel) => { + // User cancels first + onCancel(); + }); + const declinedAlertSpy = jest.spyOn(alerts, 'showMAMDeclinedAlert').mockImplementation(async (_url, _siteName, _locale, _onDismiss, onRetry) => { + // User chooses to retry + onRetry(); + }); + + const result = await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(result).toBe(true); + expect(IntuneManager.login).toHaveBeenCalled(); + expect(IntuneManager.enrollServer).toHaveBeenCalled(); + + enrollmentAlertSpy.mockRestore(); + declinedAlertSpy.mockRestore(); + }); + + test('should remove blur screen after enrollment completion', async () => { + jest.mocked(IntuneManager.isIntuneMAMEnabledForServer).mockResolvedValue(true); + jest.mocked(IntuneManager.isManagedServer).mockResolvedValue(false); + jest.mocked(IntuneManager.login).mockResolvedValue(mockTokens); + jest.mocked(IntuneManager.enrollServer).mockResolvedValue(undefined); + + const showAlertSpy = jest.spyOn(alerts, 'showMAMEnrollmentRequiredAlert').mockImplementation(async (_siteName, _locale, onAccept) => { + onAccept(); + }); + + await SecurityManager.ensureMAMEnrollmentForActiveServer(serverUrl); + + expect(Emm.removeBlurEffect).toHaveBeenCalled(); + expect(Emm.enableBlurScreen).toHaveBeenCalledWith(false); + + showAlertSpy.mockRestore(); + }); + }); + + describe('canSaveToLocation', () => { + const serverUrl1 = 'https://test1.server.com'; + const serverUrl2 = 'https://test2.server.com'; + const mockPolicy: IntunePolicy = { + isPINRequired: true, + isContactSyncAllowed: false, + isWidgetContentSyncAllowed: false, + isSpotlightIndexingAllowed: false, + areSiriIntentsAllowed: false, + areAppIntentsAllowed: false, + isAppSharingAllowed: false, + shouldFileProviderEncryptFiles: true, + isManagedBrowserRequired: false, + isFileEncryptionRequired: true, + isScreenCaptureAllowed: false, + notificationPolicy: 0, + allowedSaveLocations: { + Other: false, + OneDriveForBusiness: true, + SharePoint: true, + LocalDrive: false, + PhotoLibrary: false, + CameraRoll: false, + FilesApp: false, + iCloudDrive: false, + }, + allowedOpenLocations: 0, + }; + + beforeEach(async () => { + await SecurityManager.init(); + await SecurityManager.addServer(serverUrl1, {SiteName: 'Test Server 1'} as SecurityClientConfig); + await SecurityManager.addServer(serverUrl2, {SiteName: 'Test Server 2'} as SecurityClientConfig); + }); + + afterEach(() => { + SecurityManager.cleanup(); + }); + + test('should return true when no Intune policy exists', () => { + const result = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary'); + expect(result).toBe(true); + }); + + test('should return policy value when Intune policy exists', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + const canSaveToOneDrive = SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness'); + const canSaveToPhotoLibrary = SecurityManager.canSaveToLocation(serverUrl1, 'PhotoLibrary'); + + expect(canSaveToOneDrive).toBe(true); + expect(canSaveToPhotoLibrary).toBe(false); + }); + + test('should return false for restricted locations', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + expect(SecurityManager.canSaveToLocation(serverUrl1, 'LocalDrive')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'CameraRoll')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'FilesApp')).toBe(false); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'iCloudDrive')).toBe(false); + }); + + test('should return true for allowed locations', async () => { + await SecurityManager.addServer(serverUrl1, {} as SecurityClientConfig, false, mockPolicy); + + expect(SecurityManager.canSaveToLocation(serverUrl1, 'OneDriveForBusiness')).toBe(true); + expect(SecurityManager.canSaveToLocation(serverUrl1, 'SharePoint')).toBe(true); + }); + }); +}); diff --git a/app/managers/session_manager.test.ts b/app/managers/session_manager.test.ts index 8396dc587..cfad3a688 100644 --- a/app/managers/session_manager.test.ts +++ b/app/managers/session_manager.test.ts @@ -4,20 +4,28 @@ import CookieManager from '@react-native-cookies/cookies'; import {AppState, DeviceEventEmitter, Platform} from 'react-native'; -import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session'; +import {cancelAllSessionNotifications} from '@actions/local/session'; +import {logout, scheduleSessionNotification} from '@actions/remote/session'; import {Events} from '@constants'; import DatabaseManager from '@database/manager'; import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; import {relaunchApp} from '@init/launch'; import PushNotifications from '@init/push_notifications'; +import IntuneManager from '@managers/intune_manager'; import NetworkManager from '@managers/network_manager'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; +import {queryGlobalValue} from '@queries/app/global'; +import {getAllServers, getServerDisplayName} from '@queries/app/servers'; +import TestHelper from '@test/test_helper'; import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; import {isMainActivity} from '@utils/helpers'; import {SessionManagerSingleton as SessionManagerClass} from './session_manager'; +import type {Query} from '@nozbe/watermelondb'; +import type GlobalModel from '@typings/database/models/app/global'; + jest.mock('@react-native-cookies/cookies', () => ({ get: jest.fn(), clearByName: jest.fn(), @@ -34,12 +42,35 @@ jest.mock('@database/manager', () => ({ serverDatabases: {}, })); jest.mock('@i18n'); +jest.mock('@actions/local/session', () => { + const actual = jest.requireActual('@actions/local/session'); + return { + ...actual, + cancelAllSessionNotifications: jest.fn(), + }; +}); jest.mock('@init/credentials'); jest.mock('@init/launch'); jest.mock('@init/push_notifications'); +jest.mock('@managers/intune_manager', () => ({ + __esModule: true, + default: { + unenrollServer: jest.fn().mockResolvedValue(undefined), + subscribeToPolicyChanges: jest.fn().mockReturnValue({remove: jest.fn()}), + subscribeToEnrollmentChanges: jest.fn().mockReturnValue({remove: jest.fn()}), + subscribeToWipeRequests: jest.fn().mockReturnValue({remove: jest.fn()}), + subscribeToAuthRequired: jest.fn().mockReturnValue({remove: jest.fn()}), + subscribeToConditionalLaunchBlocked: jest.fn().mockReturnValue({remove: jest.fn()}), + subscribeToIdentitySwitchRequired: jest.fn().mockReturnValue({remove: jest.fn()}), + }, +})); jest.mock('@managers/network_manager'); jest.mock('@managers/security_manager'); jest.mock('@managers/websocket_manager'); +jest.mock('@queries/app/global', () => ({ + queryGlobalValue: jest.fn(), + storeGlobal: jest.fn(), +})); jest.mock('@queries/app/servers'); jest.mock('@queries/servers/user'); jest.mock('@screens/navigation'); @@ -64,6 +95,13 @@ describe('SessionManager', () => { }); jest.mocked(getAllServerCredentials).mockResolvedValue([{serverUrl: mockServerUrl, userId: 'user_id', token: 'token'}]); + jest.mocked(getAllServers).mockResolvedValue([]); + jest.mocked(getServerDisplayName).mockResolvedValue(mockServerDisplayName); + + // Mock queryGlobalValue to return a resolved promise for cache migration check + jest.mocked(queryGlobalValue).mockReturnValue({ + fetch: jest.fn().mockResolvedValue([{value: true}]), + } as unknown as Query); beforeEach(() => { jest.clearAllMocks(); @@ -71,6 +109,11 @@ describe('SessionManager', () => { AppState.currentState = 'active'; Platform.OS = 'ios'; + // Reset queryGlobalValue mock to return cache migration as done + jest.mocked(queryGlobalValue).mockReturnValue({ + fetch: jest.fn().mockResolvedValue([{value: true}]), + } as unknown as Query); + (AppState.addEventListener as jest.Mock).mockImplementation((event, callback) => { if (event === 'change' || event === 'focus' || event === 'blur') { appStateCallback = callback; @@ -81,7 +124,16 @@ describe('SessionManager', () => { SessionManager = new SessionManagerClass(); }); - describe('initialization', () => { + afterEach(() => { + // Clear all timers to prevent Jest from hanging + jest.clearAllTimers(); + + // Remove all event listeners + DeviceEventEmitter.removeAllListeners(Events.SERVER_LOGOUT); + DeviceEventEmitter.removeAllListeners(Events.SESSION_EXPIRED); + }); + + describe('constructor', () => { it('should construct with Android correctly', async () => { Platform.OS = 'android'; const manager = new SessionManagerClass(); @@ -93,8 +145,37 @@ describe('SessionManager', () => { describe('initialization', () => { it('should initialize correctly', async () => { - await SessionManager.init(); - expect(cancelSessionNotification).toHaveBeenCalled(); + SessionManager.init(); + expect(cancelAllSessionNotifications).toHaveBeenCalled(); + }); + + it('should delete legacy cache on first init', async () => { + // Mock cache migration as not done + jest.mocked(queryGlobalValue).mockReturnValueOnce({ + fetch: jest.fn().mockResolvedValue([]), + } as unknown as Query); + + SessionManager.init(); + + // Wait for the async promise chain to complete + await new Promise((resolve) => setImmediate(resolve)); + + expect(deleteFileCacheByDir).toHaveBeenCalledWith('com.hackemist.SDImageCache'); + }); + + it('should not delete legacy cache if migration already done', async () => { + // Mock cache migration as already done + jest.mocked(queryGlobalValue).mockReturnValueOnce({ + fetch: jest.fn().mockResolvedValue([{value: true}]), + } as unknown as Query); + + SessionManager.init(); + + // Wait for the async promise chain to complete + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(deleteFileCacheByDir).not.toHaveBeenCalledWith('com.hackemist.SDImageCache'); }); }); @@ -103,28 +184,31 @@ describe('SessionManager', () => { const event = {serverUrl: mockServerUrl, removeServer: true}; DeviceEventEmitter.emit(Events.SERVER_LOGOUT, event); - await new Promise((resolve) => setImmediate(resolve)); + await TestHelper.wait(50); expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl); expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(mockServerUrl); expect(NetworkManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); expect(WebsocketManager.invalidateClient).toHaveBeenCalledWith(mockServerUrl); expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl); + expect(IntuneManager.unenrollServer).toHaveBeenCalledWith(mockServerUrl, false); }); it('should handle session expiration', async () => { DeviceEventEmitter.emit(Events.SESSION_EXPIRED, mockServerUrl); - await new Promise((resolve) => setImmediate(resolve)); + await TestHelper.wait(50); expect(logout).toHaveBeenCalledWith(mockServerUrl, undefined, {skipEvents: true, skipServerLogout: true}); + expect(SecurityManager.removeServer).toHaveBeenCalledWith(mockServerUrl); + expect(IntuneManager.unenrollServer).toHaveBeenCalledWith(mockServerUrl, true); expect(relaunchApp).toHaveBeenCalled(); }); }); describe('app state changes', () => { - beforeEach(async () => { - await SessionManager.init(); + beforeEach(() => { + SessionManager.init(); }); it('should handle active state', async () => { @@ -132,8 +216,7 @@ describe('SessionManager', () => { if (appStateCallback) { jest.useFakeTimers(); appStateCallback('active'); - jest.runAllTimers(); - expect(cancelSessionNotification).toHaveBeenCalled(); + expect(cancelAllSessionNotifications).toHaveBeenCalled(); jest.useRealTimers(); } }); @@ -141,67 +224,59 @@ describe('SessionManager', () => { it('should handle inactive state', async () => { expect(appStateCallback).toBeDefined(); if (appStateCallback) { - jest.useFakeTimers(); appStateCallback('inactive'); - jest.runAllTimers(); + await TestHelper.wait(50); expect(scheduleSessionNotification).toHaveBeenCalled(); - jest.useRealTimers(); } }); }); describe('cleanup operations', () => { - beforeEach(async () => { + beforeEach(() => { const mockCookies = { cookie1: {name: 'cookie1'}, cookie2: {name: 'cookie2'}, }; (CookieManager.get as jest.Mock).mockResolvedValue(mockCookies); - await SessionManager.init(); + SessionManager.init(); }); it('should clear cookies correctly - iOS', async () => { Platform.OS = 'ios'; - jest.useFakeTimers(); DeviceEventEmitter.emit(Events.SERVER_LOGOUT, { serverUrl: mockServerUrl, removeServer: true, }); - jest.runAllTimers(); + await TestHelper.wait(450); expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie1', false); expect(CookieManager.clearByName).toHaveBeenCalledWith(mockServerUrl, 'cookie2', false); - jest.useRealTimers(); }); it('should clear cookies correctly - Android', async () => { Platform.OS = 'android'; - jest.useFakeTimers(); DeviceEventEmitter.emit(Events.SERVER_LOGOUT, { serverUrl: mockServerUrl, removeServer: true, }); - jest.runAllTimers(); + await TestHelper.wait(50); expect(CookieManager.flush).toHaveBeenCalled(); - jest.useRealTimers(); }); it('should clear file caches', async () => { - jest.useFakeTimers(); DeviceEventEmitter.emit(Events.SERVER_LOGOUT, { serverUrl: mockServerUrl, removeServer: true, }); - jest.runAllTimers(); + await TestHelper.wait(50); expect(deleteFileCache).toHaveBeenCalledWith(mockServerUrl); expect(deleteFileCacheByDir).toHaveBeenCalledWith('mmPasteInput'); expect(deleteFileCacheByDir).toHaveBeenCalledWith('thumbnails'); - jest.useRealTimers(); }); }); }); diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 49ac6a9ba..6a0106150 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -1,28 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import CookieManager, {type Cookie} from '@react-native-cookies/cookies'; -import {Image} from 'expo-image'; import {AppState, type AppStateStatus, DeviceEventEmitter, Platform} from 'react-native'; -import {removePushDisabledInServerAcknowledged, storeOnboardingViewedValue} from '@actions/app/global'; -import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session'; +import {storeGlobal, storeOnboardingViewedValue} from '@actions/app/global'; +import {cancelAllSessionNotifications, terminateSession} from '@actions/local/session'; +import {logout, scheduleSessionNotification} from '@actions/remote/session'; import {Events, Launch} from '@constants'; +import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; -import {resetMomentLocale} from '@i18n'; -import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; +import {getAllServerCredentials} from '@init/credentials'; import {relaunchApp} from '@init/launch'; -import PushNotifications from '@init/push_notifications'; -import NetworkManager from '@managers/network_manager'; +import IntuneManager from '@managers/intune_manager'; import SecurityManager from '@managers/security_manager'; -import WebsocketManager from '@managers/websocket_manager'; +import {queryGlobalValue} from '@queries/app/global'; import {getAllServers, getServerDisplayName} from '@queries/app/servers'; -import {getCurrentUser} from '@queries/servers/user'; import {getThemeFromState} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; -import {deleteFileCache, deleteFileCacheByDir} from '@utils/file'; +import {deleteFileCacheByDir} from '@utils/file'; import {isMainActivity} from '@utils/helpers'; -import {urlSafeBase64Encode} from '@utils/security'; import {addNewServer} from '@utils/server'; import type {LaunchType} from '@typings/launch'; @@ -56,37 +52,27 @@ export class SessionManagerSingleton { } init() { - this.cancelAllSessionNotifications(); + cancelAllSessionNotifications(); + + let updateToMigrationDone = false; + queryGlobalValue(GLOBAL_IDENTIFIERS.CACHE_MIGRATION)?.fetch().then((records) => { + const cacheMigrationDone = Boolean(records?.[0]?.value); + if (!cacheMigrationDone) { + if (Platform.OS === 'ios') { + deleteFileCacheByDir('com.hackemist.SDImageCache'); + } else if (Platform.OS === 'android') { + deleteFileCacheByDir('image_cache'); + deleteFileCacheByDir('image_manager_disk_cache'); + } + updateToMigrationDone = true; + } + }).finally(() => { + if (updateToMigrationDone) { + storeGlobal(GLOBAL_IDENTIFIERS.CACHE_MIGRATION, true); + } + }); } - private cancelAllSessionNotifications = async () => { - const serverCredentials = await getAllServerCredentials(); - for (const {serverUrl} of serverCredentials) { - cancelSessionNotification(serverUrl); - } - }; - - private clearCookies = async (serverUrl: string, webKit: boolean) => { - try { - const cookies = await CookieManager.get(serverUrl, webKit); - const values = Object.values(cookies); - values.forEach((cookie: Cookie) => { - CookieManager.clearByName(serverUrl, cookie.name, webKit); - }); - } catch (error) { - // Nothing to clear - } - }; - - private clearCookiesForServer = async (serverUrl: string) => { - if (Platform.OS === 'ios') { - this.clearCookies(serverUrl, false); - this.clearCookies(serverUrl, true); - } else if (Platform.OS === 'android') { - CookieManager.flush(); - } - }; - private scheduleAllSessionNotifications = async () => { if (!this.scheduling) { this.scheduling = true; @@ -101,43 +87,6 @@ export class SessionManagerSingleton { } }; - private resetLocale = async () => { - if (Object.keys(DatabaseManager.serverDatabases).length) { - const serverDatabase = await DatabaseManager.getActiveServerDatabase(); - const user = await getCurrentUser(serverDatabase!); - resetMomentLocale(user?.locale); - } else { - resetMomentLocale(); - } - }; - - private terminateSession = async (serverUrl: string, removeServer: boolean) => { - cancelSessionNotification(serverUrl); - await removeServerCredentials(serverUrl); - PushNotifications.removeServerNotifications(serverUrl); - SecurityManager.removeServer(serverUrl); - - NetworkManager.invalidateClient(serverUrl); - WebsocketManager.invalidateClient(serverUrl); - - if (removeServer) { - await removePushDisabledInServerAcknowledged(urlSafeBase64Encode(serverUrl)); - await DatabaseManager.destroyServerDatabase(serverUrl); - } else { - await DatabaseManager.deleteServerDatabase(serverUrl); - } - - this.resetLocale(); - this.clearCookiesForServer(serverUrl); - Image.clearDiskCache(); - deleteFileCache(serverUrl); - deleteFileCacheByDir('mmPasteInput'); - deleteFileCacheByDir('thumbnails'); - if (Platform.OS === 'android') { - deleteFileCacheByDir('image_cache'); - } - }; - private onAppStateChange = async (appState: AppStateStatus) => { if (appState === this.previousAppState || !isMainActivity()) { return; @@ -146,7 +95,7 @@ export class SessionManagerSingleton { this.previousAppState = appState; switch (appState) { case 'active': - setTimeout(this.cancelAllSessionNotifications, 750); + setTimeout(cancelAllSessionNotifications, 750); break; case 'inactive': this.scheduleAllSessionNotifications(); @@ -158,54 +107,66 @@ export class SessionManagerSingleton { if (this.terminatingSessionUrl.has(serverUrl)) { return; } - this.terminatingSessionUrl.add(serverUrl); + try { + this.terminatingSessionUrl.add(serverUrl); - const activeServerUrl = await DatabaseManager.getActiveServerUrl(); - const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName(); - await this.terminateSession(serverUrl, removeServer); + const activeServerUrl = await DatabaseManager.getActiveServerUrl(); + const activeServerDisplayName = await DatabaseManager.getActiveServerDisplayName(); + await terminateSession(serverUrl, removeServer); + SecurityManager.removeServer(serverUrl); - if (activeServerUrl === serverUrl) { - let displayName = ''; - let launchType: LaunchType = Launch.AddServer; - if (!Object.keys(DatabaseManager.serverDatabases).length) { - EphemeralStore.theme = undefined; - launchType = Launch.Normal; + // We do not unenroll with Wipe as we already removed all the data during terminateSession + await IntuneManager.unenrollServer(serverUrl, false); - if (activeServerDisplayName) { - displayName = activeServerDisplayName; + if (activeServerUrl === serverUrl) { + let displayName = ''; + let launchType: LaunchType = Launch.AddServer; + if (!Object.keys(DatabaseManager.serverDatabases).length) { + EphemeralStore.theme = undefined; + launchType = Launch.Normal; + + if (activeServerDisplayName) { + displayName = activeServerDisplayName; + } } - } - // set the onboardingViewed value to false so the launch will show the onboarding screen after all servers were removed - const servers = await getAllServers(); - if (!servers.length) { - await storeOnboardingViewedValue(false); - } + // set the onboardingViewed value to false so the launch will show the onboarding screen after all servers were removed + const servers = await getAllServers(); + if (!servers.length) { + await storeOnboardingViewedValue(false); + } - relaunchApp({launchType, serverUrl, displayName}); + relaunchApp({launchType, serverUrl, displayName}); + } + } finally { + this.terminatingSessionUrl.delete(serverUrl); } - this.terminatingSessionUrl.delete(serverUrl); }; private onSessionExpired = async (serverUrl: string) => { this.terminatingSessionUrl.add(serverUrl); + try { // logout is not doing anything in this scenario, but we keep it // to keep the same flow as other logout scenarios. - await logout(serverUrl, undefined, {skipServerLogout: true, skipEvents: true}); + await logout(serverUrl, undefined, {skipServerLogout: true, skipEvents: true}); - await this.terminateSession(serverUrl, false); + await terminateSession(serverUrl, false); + SecurityManager.removeServer(serverUrl); + await IntuneManager.unenrollServer(serverUrl, true); - const activeServerUrl = await DatabaseManager.getActiveServerUrl(); - const serverDisplayName = await getServerDisplayName(serverUrl); + const activeServerUrl = await DatabaseManager.getActiveServerUrl(); + const serverDisplayName = await getServerDisplayName(serverUrl); - await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}); - if (activeServerUrl) { - addNewServer(getThemeFromState(), serverUrl, serverDisplayName); - } else { - EphemeralStore.theme = undefined; + await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}); + if (activeServerUrl) { + addNewServer(getThemeFromState(), serverUrl, serverDisplayName); + } else { + EphemeralStore.theme = undefined; + } + } finally { + this.terminatingSessionUrl.delete(serverUrl); } - this.terminatingSessionUrl.delete(serverUrl); }; } diff --git a/app/products/playbooks/screens/post_update/post_update.test.tsx b/app/products/playbooks/screens/post_update/post_update.test.tsx index 9c81bb19e..cde22629f 100644 --- a/app/products/playbooks/screens/post_update/post_update.test.tsx +++ b/app/products/playbooks/screens/post_update/post_update.test.tsx @@ -250,11 +250,10 @@ describe('PostUpdate', () => { await waitFor(() => { expect(setButtons).toHaveBeenCalled(); + const lastCall = jest.mocked(setButtons).mock.calls[jest.mocked(setButtons).mock.calls.length - 1]; + const rightButton = lastCall[1]?.rightButtons?.[0]; + expect(rightButton?.enabled).toBe(true); }); - - const lastCall = jest.mocked(setButtons).mock.calls[jest.mocked(setButtons).mock.calls.length - 1]; - const rightButton = lastCall[1]?.rightButtons?.[0]; - expect(rightButton?.enabled).toBe(true); }); it('should disable save button when message is empty', async () => { diff --git a/app/queries/app/servers.test.ts b/app/queries/app/servers.test.ts index 964141a1d..6139becaf 100644 --- a/app/queries/app/servers.test.ts +++ b/app/queries/app/servers.test.ts @@ -70,7 +70,7 @@ describe('Servers Queries', () => { expect(result).toBeUndefined(); }); - test('queryAllActiveServers should return all active servers query', () => { + test('queryAllActiveServers should return all active servers query sorted by last_active_at descending', () => { queryAllActiveServers(); expect(mockDatabase.get).toHaveBeenCalledWith(SERVERS); expect(mockServerModel.query).toHaveBeenCalledWith( @@ -78,6 +78,7 @@ describe('Servers Queries', () => { Q.where('identifier', Q.notEq('')), Q.where('last_active_at', Q.gt(0)), ), + Q.sortBy('last_active_at', Q.desc), ); }); diff --git a/app/queries/app/servers.ts b/app/queries/app/servers.ts index a735b0fcf..ab4c4b5d6 100644 --- a/app/queries/app/servers.ts +++ b/app/queries/app/servers.ts @@ -31,6 +31,7 @@ export const queryAllActiveServers = () => { Q.where('identifier', Q.notEq('')), Q.where('last_active_at', Q.gt(0)), ), + Q.sortBy('last_active_at', Q.desc), ); } catch { return undefined; diff --git a/app/queries/servers/system.test.ts b/app/queries/servers/system.test.ts index 845f149f8..f034ccc80 100644 --- a/app/queries/servers/system.test.ts +++ b/app/queries/servers/system.test.ts @@ -183,11 +183,11 @@ describe('observeIsMinimumLicenseTier', () => { }).then(() => { operator.handleSystem({ systems: [ - {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'Professional'}}, + {id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'professional'}}, ], prepareRecordsOnly: false, }).then(() => { - observeIsMinimumLicenseTier(database, 'Professional').subscribe((isMinimumTier) => { + observeIsMinimumLicenseTier(database, 'professional').subscribe((isMinimumTier) => { expect(isMinimumTier).toBe(false); done(); }); diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index af766bbc3..db194657b 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -12,7 +12,7 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Preferences, License} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy'; -import {isMinimumServerVersion} from '@utils/helpers'; +import {isMinimumLicenseTier, isMinimumServerVersion, type LicenseTierSku} from '@utils/helpers'; import {logError} from '@utils/log'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -628,25 +628,19 @@ export const observeReportAProblemMetadata = (database: Database) => { ); }; -export const observeIsMinimumLicenseTier = (database: Database, shortSku: string) => { +export const observeIsMinimumLicenseTier = (database: Database, shortSku: LicenseTierSku) => { const license = observeLicense(database); const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false); return combineLatest([license, isEnterpriseReady]).pipe( switchMap(([lic, isEnt]) => { - if (!shortSku) { - return of$(false); - } - const isLicensed = lic?.IsLicensed === 'true'; - if (!isEnt || !isLicensed) { + if (!shortSku || !isEnt) { return of$(false); } - const tier = License.LicenseSkuTier[lic.SkuShortName]; - const targetTier = License.LicenseSkuTier[shortSku] || 0; - const isMinimumTier = Boolean(tier) && Boolean(targetTier) && isEnt && isLicensed && tier >= targetTier; + const meetsMinimumTier = isMinimumLicenseTier(lic, shortSku); - return of$(isMinimumTier); + return of$(meetsMinimumTier); }), distinctUntilChanged(), ); diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx index a938f6920..9c65e0067 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx +++ b/app/screens/channel/channel_post_list/intro/direct_channel/group/group.tsx @@ -1,15 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image} from 'expo-image'; import {chunk} from 'lodash'; import React from 'react'; import {View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrlFromUser} from '@actions/remote/user'; +import ExpoImage from '@components/expo_image'; import {useServerUrl} from '@context/server'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getLastPictureUpdate} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; @@ -41,8 +42,10 @@ const Group = ({theme, users}: Props) => { const groups = rows.map((c, k) => { const group = c.map((u, i) => { const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u); + const lastPictureUpdateAt = getLastPictureUpdate(u); return ( - { ); }); - return ( - <> - {groups} - - ); + return groups; }; export default Group; diff --git a/app/screens/channel_info/title/group_message/avatars/avatars.tsx b/app/screens/channel_info/title/group_message/avatars/avatars.tsx index cabd33eec..6e01dba47 100644 --- a/app/screens/channel_info/title/group_message/avatars/avatars.tsx +++ b/app/screens/channel_info/title/group_message/avatars/avatars.tsx @@ -1,15 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image} from 'expo-image'; import React from 'react'; import {View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrlFromUser} from '@actions/remote/user'; +import ExpoImage from '@components/expo_image'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getLastPictureUpdate} from '@utils/user'; import type UserModel from '@typings/database/models/servers/user'; @@ -39,8 +40,10 @@ const GroupAvatars = ({users}: Props) => { const group = users.map((u, i) => { const pictureUrl = buildProfileImageUrlFromUser(serverUrl, u); + const lastPictureUpdateAt = getLastPictureUpdate(u); return ( - { /> } {Boolean(imageUrl) && - } diff --git a/app/screens/gallery/footer/actions/index.tsx b/app/screens/gallery/footer/actions/index.tsx index 290e87ec0..c782bf153 100644 --- a/app/screens/gallery/footer/actions/index.tsx +++ b/app/screens/gallery/footer/actions/index.tsx @@ -8,6 +8,7 @@ import {StyleSheet, View} from 'react-native'; import Action from './action'; type Props = { + allowSaveToLocation: boolean; canDownloadFiles: boolean; disabled: boolean; enablePublicLinks: boolean; @@ -26,8 +27,9 @@ const styles = StyleSheet.create({ }); const Actions = ({ - canDownloadFiles, disabled, enablePublicLinks, fileId, onCopyPublicLink, - onDownload, onShare, + allowSaveToLocation, canDownloadFiles, disabled, + enablePublicLinks, fileId, + onCopyPublicLink, onDownload, onShare, }: Props) => { const managedConfig = useManagedConfig(); const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true'; @@ -42,11 +44,13 @@ const Actions = ({ />} {canDownloadFiles && <> + {allowSaveToLocation && + } diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx index e79aa3383..51dadc9e3 100644 --- a/app/screens/gallery/footer/footer.tsx +++ b/app/screens/gallery/footer/footer.tsx @@ -8,6 +8,8 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Events} from '@constants'; import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; +import {useServerUrl} from '@context/server'; +import SecurityManager from '@managers/security_manager'; import {changeOpacity} from '@utils/theme'; import {ensureString} from '@utils/types'; import {displayUsername} from '@utils/user'; @@ -18,6 +20,7 @@ import CopyPublicLink from './copy_public_link'; import Details from './details'; import DownloadWithAction from './download_with_action'; +import type {IntuneMAMSaveLocation} from '@managers/intune_manager/types'; import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery'; @@ -59,6 +62,7 @@ const Footer = ({ enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview, hideActions, isDirectChannel, item, post, style, teammateNameDisplay, }: Props) => { + const serverUrl = useServerUrl(); const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid'); const [action, setAction] = useState('none'); const {bottom} = useSafeAreaInsets(); @@ -91,6 +95,14 @@ const Footer = ({ setAction('sharing'); }, []); + const allowSaveToLocation = useMemo(() => { + let location: keyof IntuneMAMSaveLocation = 'CameraRoll'; + if (item.type === 'file') { + location = 'FilesApp'; + } + return canDownloadFiles && SecurityManager.canSaveToLocation(serverUrl, location); + }, [canDownloadFiles, item.type, serverUrl]); + useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.GALLERY_ACTIONS, (value: GalleryAction) => { setAction(value); @@ -134,6 +146,7 @@ const Footer = ({ {showActions && ; galleryIdentifier: string; @@ -58,10 +56,10 @@ const Gallery = forwardRef(({ lightboxRef.current?.closeLightbox(); }; - const onLocalIndex = (index: number) => { + const onLocalIndex = useCallback((index: number) => { setLocalIndex(index); onIndexChange?.(index); - }; + }, [onIndexChange]); useEffect(() => { runOnUI(() => { @@ -73,6 +71,9 @@ const Gallery = forwardRef(({ const th = item.height / scaleFactor; sharedValues.targetHeight.value = th; })(); + + // sharedValues do not trigger re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps }, [item, targetDimensions.width]); useEffect(() => { @@ -106,7 +107,10 @@ const Gallery = forwardRef(({ runOnJS(onLocalIndex)(nextIndex); sharedValues.activeIndex.value = nextIndex; - }, []); + + // sharedValues do not trigger re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onLocalIndex]); const renderBackdropComponent = useCallback( ({animatedStyles, translateY}: BackdropProps) => { @@ -146,13 +150,18 @@ const Gallery = forwardRef(({ sharedValues.y.value = 0; runOnJS(onHide)(); + + // sharedValues do not trigger re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onRenderItem = useCallback((info: RenderItemInfo) => { - if (item.type === 'video' && item.posterUri) { + const currentItem = items[localIndex]; + if (currentItem.type === 'video' && currentItem.posterUri) { return ( - @@ -160,7 +169,7 @@ const Gallery = forwardRef(({ } return null; - }, [item]); + }, [items, localIndex]); const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => { switch (props.item.type) { @@ -183,7 +192,7 @@ const Gallery = forwardRef(({ default: return null; } - }, []); + }, [hideHeaderAndFooter, initialIndex]); const source = useMemo(() => { if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) { diff --git a/app/screens/gallery/lightbox_swipeout/index.tsx b/app/screens/gallery/lightbox_swipeout/index.tsx index d7eee8815..2ac20ba1b 100644 --- a/app/screens/gallery/lightbox_swipeout/index.tsx +++ b/app/screens/gallery/lightbox_swipeout/index.tsx @@ -3,10 +3,11 @@ import {type ImageSource} from 'expo-image'; import React, {forwardRef, useImperativeHandle, useMemo} from 'react'; -import {type ImageSize, type ImageStyle, type ViewStyle} from 'react-native'; +import {type ImageSize, type ImageStyle, type StyleProp} from 'react-native'; import { cancelAnimation, useAnimatedReaction, useSharedValue, withTiming, + type AnimatedStyle, type SharedValue, } from 'react-native-reanimated'; @@ -22,7 +23,7 @@ export interface RenderItemInfo { source: ImageSource; width: number; height: number; - itemStyles: ViewStyle | ImageStyle; + itemStyles: StyleProp>>; } interface LightboxSwipeoutProps { diff --git a/app/screens/gallery/lightbox_swipeout/lightbox.tsx b/app/screens/gallery/lightbox_swipeout/lightbox.tsx index b6268fdb7..d0ab87304 100644 --- a/app/screens/gallery/lightbox_swipeout/lightbox.tsx +++ b/app/screens/gallery/lightbox_swipeout/lightbox.tsx @@ -1,14 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Image, type ImageSource} from 'expo-image'; +import {type ImageSource} from 'expo-image'; import React, {useEffect, useMemo, useState} from 'react'; -import {type ImageStyle, StyleSheet, View, type ViewStyle} from 'react-native'; +import {type ImageStyle, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'; import Animated, { interpolate, runOnJS, runOnUI, useAnimatedStyle, withTiming, + type AnimatedStyle, } from 'react-native-reanimated'; +import {ExpoImageAnimated} from '@components/expo_image'; import {calculateDimensions} from '@utils/images'; import {pagerTimingConfig} from '../animation_config/timing'; @@ -22,7 +24,7 @@ export interface RenderItemInfo { source: ImageSource; width: number; height: number; - itemStyles: ViewStyle | ImageStyle; + itemStyles: ViewStyle | ImageStyle | StyleProp>>; } interface LightboxProps { @@ -33,8 +35,6 @@ interface LightboxProps { source: ImageSource | string; } -const AnimatedImage = Animated.createAnimatedComponent(Image); - const styles = StyleSheet.create({ container: { flex: 1, @@ -87,8 +87,8 @@ export default function Lightbox({ } }; - // no need to add animateOnMount as this function uses references and is only needed on mount - // eslint-disable-next-line react-hooks/exhaustive-deps + // We only want to run this on mount and unmount + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const {width: tw, height: th} = useMemo(() => calculateDimensions( @@ -177,7 +177,9 @@ export default function Lightbox({ itemStyles, }) ) : ( - { const {formatMessage} = useIntl(); + const serverUrl = useServerUrl(); const file = useMemo(() => galleryItemToFileInfo(item), [item]); const [enabled, setEnabled] = useState(true); const isSupported = useMemo(() => isDocument(file), [file]); @@ -81,13 +88,16 @@ const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hide }, [canDownloadFiles, enableSecureFilePreview, file, isSupported]); const optionText = useMemo(() => { + const allowSaveToLocation = SecurityManager.canSaveToLocation(serverUrl, 'FilesApp'); if (enableSecureFilePreview && !isPdf(file)) { return formatMessage(messages.onlyPdf); - } else if (!isSupported) { + } else if (!isSupported && allowSaveToLocation) { return formatMessage(messages.unsupported); + } else if (!isSupported && !allowSaveToLocation) { + return formatMessage(messages.unsupportedAndBlockedDownload); } return formatMessage(messages.openFile); - }, [enableSecureFilePreview, file, formatMessage, isSupported]); + }, [enableSecureFilePreview, file, formatMessage, isSupported, serverUrl]); const setGalleryAction = useCallback((action: GalleryAction) => { DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action); diff --git a/app/screens/gallery/renderers/image/index.tsx b/app/screens/gallery/renderers/image/index.tsx index b51ca8e24..ec1390659 100644 --- a/app/screens/gallery/renderers/image/index.tsx +++ b/app/screens/gallery/renderers/image/index.tsx @@ -65,6 +65,7 @@ function ImageRenderer({ return ( { + cacheKey: string; enabled?: boolean; isSvg: boolean; source: ImageSource | string; @@ -41,7 +44,7 @@ interface ImageTransformerProps extends Omit { @@ -57,11 +60,14 @@ const ImageTransformer = ( const setInteractionsEnabled = useCallback((value: boolean) => { interactionsEnabled.value = value; + + // SharedValue does not trigger re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onLoadImageSuccess = useCallback(() => { setInteractionsEnabled(true); - }, []); + }, [setInteractionsEnabled]); useAnimatedReaction( () => { @@ -136,7 +142,8 @@ const ImageTransformer = ( ); } else { element = ( - void; } -const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, hideHeaderAndFooter, posterUri, setDownloading, width}: Props) => { +const VideoError = ({cacheKey, canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, hideHeaderAndFooter, posterUri, setDownloading, width}: Props) => { const [hasPoster, setHasPoster] = useState(false); const [loadPosterError, setLoadPosterError] = useState(false); const {headerAndFooterHidden} = useLightboxSharedValues(); @@ -87,7 +88,8 @@ const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height if (posterUri && !loadPosterError) { const imageDimensions = calculateDimensions(height, width, dimensions.width); poster = ( - { style={styles.container} testID='scheduled_post_empty_component' > - { return ( - { - initializeSecurityManager(); + SecurityManager.start(); }, []); const handleFindChannels = useCallback(() => { @@ -143,6 +142,9 @@ export function HomeScreen(props: HomeProps) { }); } } + + // only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/app/screens/in_app_notification/icon.tsx b/app/screens/in_app_notification/icon.tsx index 687ec850f..54a95f9d1 100644 --- a/app/screens/in_app_notification/icon.tsx +++ b/app/screens/in_app_notification/icon.tsx @@ -2,15 +2,18 @@ // See LICENSE.txt for license information. import {withObservables} from '@nozbe/watermelondb/react'; -import {Image, type ImageSource} from 'expo-image'; +import {type ImageSource} from 'expo-image'; import React from 'react'; import {StyleSheet, View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrlFromUser} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; +import ExpoImage from '@components/expo_image'; import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; +import {urlSafeBase64Encode} from '@utils/security'; +import {getLastPictureUpdate} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; import type UserModel from '@typings/database/models/servers/user'; @@ -41,7 +44,8 @@ const NotificationIcon = ({author, enablePostIconOverride, fromWebhook, override if (overrideIconUrl) { const source: ImageSource = {uri: buildAbsoluteUrl(serverUrl, overrideIconUrl)}; icon = ( - @@ -56,8 +60,10 @@ const NotificationIcon = ({author, enablePostIconOverride, fromWebhook, override } } else if (author) { const pictureUrl = buildProfileImageUrlFromUser(serverUrl, author); + const lastPictureUpdateAt = getLastPictureUpdate(author); icon = ( - diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index 28afebfd8..e08f51459 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -17,6 +17,7 @@ import {useDefaultHeaderHeight} from '@hooks/header'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import {usePreventDoubleTap} from '@hooks/utils'; +import IntuneManager from '@managers/intune_manager'; import NetworkManager from '@managers/network_manager'; import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; @@ -268,6 +269,8 @@ const LoginOptions = ({ ssoOnly={!hasLoginForm} ssoOptions={ssoOptions} theme={theme} + isIntuneEnabled={IntuneManager.isIntuneEnabledForConfigAndLicense(config, license)} + intuneAuthService={config.IntuneAuthService} /> } diff --git a/app/screens/login/sso_options.tsx b/app/screens/login/sso_options.tsx index 6039b66c7..edc69fb34 100644 --- a/app/screens/login/sso_options.tsx +++ b/app/screens/login/sso_options.tsx @@ -1,27 +1,30 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Image, type ImageSource} from 'expo-image'; import React from 'react'; import {useIntl} from 'react-intl'; -import {Image, type ImageSourcePropType, StyleSheet, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import Button from '@components/button'; import {Sso} from '@constants'; type SsoInfo = { text: string; - imageSrc?: ImageSourcePropType; + imageSrc?: ImageSource; compassIcon?: string; }; type Props = { goToSso: (ssoType: string) => void; + intuneAuthService?: string; + isIntuneEnabled?: boolean; ssoOnly: boolean; ssoOptions: SsoWithOptions; theme: Theme; } -const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => { +const SsoOptions = ({goToSso, intuneAuthService, isIntuneEnabled, ssoOnly, ssoOptions, theme}: Props) => { const {formatMessage} = useIntl(); const getSsoButtonOptions = ((ssoType: string): SsoInfo => { @@ -30,7 +33,11 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => { switch (ssoType) { case Sso.SAML: sso.text = options.text || formatMessage({id: 'mobile.login_options.saml', defaultMessage: 'SAML'}); - sso.compassIcon = 'lock'; + if (isIntuneEnabled && intuneAuthService?.toLocaleLowerCase() === ssoType.toLocaleLowerCase()) { + sso.imageSrc = require('@assets/images/Icon_EntraID.png'); + } else { + sso.compassIcon = 'lock'; + } break; case Sso.GITLAB: sso.text = formatMessage({id: 'mobile.login_options.gitlab', defaultMessage: 'GitLab'}); @@ -54,10 +61,7 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => { return sso; }); - const enabledSSOs = Object.keys(ssoOptions).filter( - (ssoType: string) => ssoOptions[ssoType].enabled, - ); - + const enabledSSOs = Object.keys(ssoOptions).filter((ssoType: string) => ssoOptions[ssoType].enabled); const styleViewContainer = enabledSSOs.length === 2 && !ssoOnly ? styles.containerAsRow : undefined; const styleButtonWrapper = enabledSSOs.length === 2 && !ssoOnly ? styles.buttonWrapper : undefined; @@ -85,6 +89,7 @@ const SsoOptions = ({goToSso, ssoOnly, ssoOptions, theme}: Props) => { key={'image' + ssoType} source={imageSrc} style={styles.logoStyle} + cachePolicy='memory' /> ) : undefined} text={text} diff --git a/app/screens/report_a_problem/app_logs.tsx b/app/screens/report_a_problem/app_logs.tsx index 288b0c39d..721767923 100644 --- a/app/screens/report_a_problem/app_logs.tsx +++ b/app/screens/report_a_problem/app_logs.tsx @@ -8,7 +8,9 @@ import {View, Text, ActivityIndicator, Platform} from 'react-native'; import Share from 'react-native-share'; import Button from '@components/button'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import SecurityManager from '@managers/security_manager'; import {deleteFile, pathWithPrefix} from '@utils/file'; import {logDebug} from '@utils/log'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -31,6 +33,7 @@ const AppLogs = () => { const theme = useTheme(); const styles = getStyleSheet(theme); const intl = useIntl(); + const serverUrl = useServerUrl(); const [logFiles, setLogFiles] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -57,7 +60,9 @@ const AppLogs = () => { } else { await Share.open({ url: pathWithPrefix('file://', zipFilePath), - saveToFiles: true, + + // If Allowing to save to Files app, save it there, otherwise show share dialog + saveToFiles: SecurityManager.canSaveToLocation(serverUrl, 'FilesApp'), }); } } catch (error) { @@ -71,7 +76,7 @@ const AppLogs = () => { logDebug('Failed to delete zip file', error); } } - }, [logFiles]); + }, [logFiles, serverUrl]); const hasLogs = logFiles.length > 0; diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 6b0515b00..9a08bef41 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -145,6 +145,9 @@ const Server = ({ // If no other servers are allowed or the local config for AutoSelectServerUrl is set, attempt to connect handleConnect(managedConfig?.serverUrl || LocalConfig.DefaultServerUrl); } + + // functions do not need memoization + // eslint-disable-next-line react-hooks/exhaustive-deps }, [managedConfig?.allowOtherServers, managedConfig?.serverUrl, managedConfig?.serverName, defaultServerUrl]); useEffect(() => { @@ -185,6 +188,9 @@ const Server = ({ PushNotifications.registerIfNeeded(); return () => backHandler.remove(); + + // only needed on mount + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useNavButtonPressed(closeButtonId || '', componentId, dismiss, []); diff --git a/app/screens/sso/components/auth_error.tsx b/app/screens/sso/components/auth_error.tsx index f4fa86d47..9acbb1d37 100644 --- a/app/screens/sso/components/auth_error.tsx +++ b/app/screens/sso/components/auth_error.tsx @@ -56,7 +56,7 @@ const AuthError = ({error, retry, theme}: AuthErrorProps) => { style={style.infoTitle} /> - {`${error}.`} + {`${error}${error.endsWith('.') ? '' : '.'}`}