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 <eva.sarafianou@mattermost.com> * re-apply screen capture on enrollment Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * use userData from intunr login and prevent getMe Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * Check for Biometrics and Jailbreak as we used to --------- Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>
This commit is contained in:
parent
6fdfd57c11
commit
44eb76bed7
165 changed files with 9015 additions and 1904 deletions
51
.github/actions/prepare-ios-build/action.yaml
vendored
51
.github/actions/prepare-ios-build/action.yaml
vendored
|
|
@ -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::"
|
||||
|
|
|
|||
27
.github/actions/setup-intune-ssh-key/action.yaml
vendored
Normal file
27
.github/actions/setup-intune-ssh-key/action.yaml
vendored
Normal file
|
|
@ -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"
|
||||
60
.github/actions/setup-intune/action.yaml
vendored
Normal file
60
.github/actions/setup-intune/action.yaml
vendored
Normal file
|
|
@ -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::"
|
||||
23
.github/actions/setup-ssh-key/action.yaml
vendored
Normal file
23
.github/actions/setup-ssh-key/action.yaml
vendored
Normal file
|
|
@ -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"
|
||||
23
.github/workflows/build-ios-beta.yml
vendored
23
.github/workflows/build-ios-beta.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
25
.github/workflows/build-ios-release.yml
vendored
25
.github/workflows/build-ios-release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
20
.github/workflows/build-pr.yml
vendored
20
.github/workflows/build-pr.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
8
.github/workflows/e2e-detox-pr.yml
vendored
8
.github/workflows/e2e-detox-pr.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
8
.github/workflows/e2e-detox-release.yml
vendored
8
.github/workflows/e2e-detox-release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
8
.github/workflows/e2e-detox-scheduled.yml
vendored
8
.github/workflows/e2e-detox-scheduled.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/e2e-ios-template.yml
vendored
2
.github/workflows/e2e-ios-template.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
20
.github/workflows/github-release.yml
vendored
20
.github/workflows/github-release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
145
.github/workflows/validate-intune-clean.yml
vendored
Normal file
145
.github/workflows/validate-intune-clean.yml
vendored
Normal file
|
|
@ -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)"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.gitmodules
vendored
Normal file
11
.gitmodules
vendored
Normal file
|
|
@ -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 <tag-or-commit>
|
||||
# cd ../..
|
||||
# git add libraries/@mattermost/intune
|
||||
# git commit -m "Update Intune submodule to <version>"
|
||||
|
||||
[submodule "libraries/@mattermost/intune"]
|
||||
path = libraries/@mattermost/intune
|
||||
url = git@github.com:mattermost/mattermost-mobile-intune.git
|
||||
|
|
@ -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<ServersModel>([]);
|
||||
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<ServersModel>(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<ServersModel>(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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Promise<[string, SecurityClientConfig | null]>> = [];
|
||||
const results: Record<string, SecurityClientConfig> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PostImage | undefined>) {
|
||||
export async function parseMarkdownImages(serverUrl: string, markdown: string, imageMetadata: Dictionary<PostImage | undefined>) {
|
||||
// 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<Array<Promise<PostImage & {url: string}>>>((result, match) => {
|
||||
const imageUrl = match[1];
|
||||
if (isParsableUrl(imageUrl)) {
|
||||
result.push(getImageMetadata(imageUrl));
|
||||
result.push(getImageMetadata(serverUrl, imageUrl));
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
|
|
|||
346
app/actions/local/session/index.test.ts
Normal file
346
app/actions/local/session/index.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
app/actions/local/session/index.ts
Normal file
190
app/actions/local/session/index.ts
Normal file
|
|
@ -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<unknown>, 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 {};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> => {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const user = await getCurrentUser(database);
|
||||
return user?.locale || DEFAULT_LOCALE;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<LoginActionResponse> => {
|
||||
const completeSSOLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, client: Client, userData?: UserProfile, skipChecks = false): Promise<LoginActionResponse> => {
|
||||
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<LoginActionResponse> => {
|
||||
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<SAMLChallenge, 'codeVerifier' | 'state'>, preauthSecret?: string): Promise<LoginActionResponse> => {
|
||||
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<LoginActionResponse> => {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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'};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface ClientUsersMix {
|
|||
login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise<UserProfile>;
|
||||
loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise<UserProfile>;
|
||||
loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise<UserProfile>;
|
||||
loginByIntune: (accessToken: string, deviceId?: string) => Promise<UserProfile>;
|
||||
logout: () => Promise<any>;
|
||||
getProfiles: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
|
||||
getProfilesByIds: (userIds: string[], options?: Record<string, any>, groupLabel?: RequestGroupLabel) => Promise<UserProfile[]>;
|
||||
|
|
@ -144,7 +145,7 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(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 = <TBase extends Constructor<ClientBase>>(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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
style={style.slashIcon}
|
||||
source={slashIcon}
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
);
|
||||
if (icon === COMMAND_SUGGESTION_ERROR) {
|
||||
|
|
@ -120,7 +122,8 @@ const SlashSuggestionItem = ({
|
|||
);
|
||||
} else if (icon.startsWith('http')) {
|
||||
image = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`slash-${urlSafeBase64Encode(iconAsSource.uri)}`}
|
||||
source={iconAsSource}
|
||||
style={style.uriIcon}
|
||||
/>
|
||||
|
|
@ -142,7 +145,8 @@ const SlashSuggestionItem = ({
|
|||
}
|
||||
} else {
|
||||
image = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`slash-${urlSafeBase64Encode(iconAsSource.uri)}`}
|
||||
source={iconAsSource}
|
||||
style={style.uriIcon}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Image
|
||||
<ExpoImage
|
||||
testID='bookmark-image'
|
||||
id={`bookmark-image-${urlSafeBase64Encode(imageUrl)}`}
|
||||
source={{uri: imageUrl}}
|
||||
style={imageStyle}
|
||||
onError={handleImageError}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ const ChannelBookmarkOptions = ({
|
|||
type,
|
||||
lastPictureUpdate: 0,
|
||||
uri: '',
|
||||
cacheKey: fileInfo.id!,
|
||||
};
|
||||
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const ChannelBookmarks = ({
|
|||
|
||||
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
|
||||
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(() => (<View style={styles.emptyItemSeparator}/>), []);
|
||||
const renderItemSeparator = useCallback(() => (
|
||||
<View style={styles.emptyItemSeparator}/>
|
||||
), [styles.emptyItemSeparator]);
|
||||
|
||||
const onScrolled = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setAllowEndFade(isCloseToBottom(e.nativeEvent));
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`user-${author.id}-${getLastPictureUpdate(author)}`}
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
||||
style={[styles.avatar, styles.avatarRadius]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<ExpoImage
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
/>
|
||||
),
|
||||
android: (
|
||||
<Image
|
||||
key={key}
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
resizeMode='contain'
|
||||
testID={testID}
|
||||
/>
|
||||
),
|
||||
});
|
||||
return (
|
||||
<ExpoImage
|
||||
id={`emoji-${emojiName}`}
|
||||
source={image}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
transition={0}
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Platform.select({
|
||||
ios: (
|
||||
<ExpoImage
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
cachePolicy='disk'
|
||||
placeholder={require('@assets/images/thumb.png')}
|
||||
placeholderContentFit='contain'
|
||||
/>
|
||||
),
|
||||
android: (
|
||||
<Image
|
||||
source={{uri: imageUrl, cache: 'force-cache'}}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
resizeMode='contain'
|
||||
testID={testID}
|
||||
key={key}
|
||||
defaultSource={require('@assets/images/thumb.png')}
|
||||
/>
|
||||
),
|
||||
});
|
||||
return (
|
||||
<ExpoImage
|
||||
id={`emoji-${emojiName}`}
|
||||
style={[commonStyle, imageStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
contentFit='contain'
|
||||
testID={testID}
|
||||
recyclingKey={key}
|
||||
cachePolicy='disk'
|
||||
placeholder={require('@assets/images/thumb.png')}
|
||||
placeholderContentFit='contain'
|
||||
transition={0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => {
|
||||
|
|
|
|||
130
app/components/expo_image/index.tsx
Normal file
130
app/components/expo_image/index.tsx
Normal file
|
|
@ -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<Image, ExpoImageProps>(({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 (
|
||||
<Image
|
||||
ref={ref}
|
||||
{...props}
|
||||
source={source}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<ImageBackground
|
||||
{...props}
|
||||
source={source}
|
||||
placeholder={placeholder}
|
||||
>
|
||||
{props.children}
|
||||
</ImageBackground>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpoImageAnimated = Animated.createAnimatedComponent(ExpoImage);
|
||||
|
||||
export {
|
||||
ExpoImageAnimated,
|
||||
ExpoImageBackground,
|
||||
};
|
||||
|
||||
export default ExpoImage;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
borderRadius: 4,
|
||||
...typography('Body', 200),
|
||||
lineHeight: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ManagedConfig>();
|
||||
const sourceKey = removeImageProxyForKey(source);
|
||||
|
||||
// Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
|
||||
const genericFileRef = useRef<string | null>(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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 = ({
|
|||
>
|
||||
<Animated.View style={[styles, {width, height}]}>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
id={fileId.current}
|
||||
imageUri={source}
|
||||
forwardRef={ref}
|
||||
onError={onLoadFailed}
|
||||
|
|
|
|||
|
|
@ -1,11 +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, useMemo, useState} from 'react';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {isValidUrl} from '@utils/url';
|
||||
|
||||
|
|
@ -40,7 +41,8 @@ const OptionIcon = ({icon, iconColor, destructive}: OptionIconProps) => {
|
|||
|
||||
if (isValidUrl(icon) && !failedToLoadImage) {
|
||||
return (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`option-icon-${urlSafeBase64Encode(icon)}`}
|
||||
source={iconAsSource}
|
||||
style={styles.icon}
|
||||
onError={onErrorLoadingIcon}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// 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 {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Preferences} from '@constants';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
|
@ -58,9 +58,10 @@ const ScheduledPostTooltip = ({onClose}: Props) => {
|
|||
testID='scheduled_post_tutorial_tooltip'
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
<ExpoImage
|
||||
source={longPressGestureHandLogo}
|
||||
style={styles.image}
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.close}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ function Uploads({
|
|||
}, [containerHeight, hasFiles]);
|
||||
|
||||
const openGallery = useCallback((file: FileInfo) => {
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`user-override-icon-${post.id}`}
|
||||
source={source}
|
||||
style={{
|
||||
height: pictureSize,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import useDidUpdate from '@hooks/did_update';
|
|||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {secureGetFromRecord} from '@utils/types';
|
||||
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
|
||||
|
|
@ -53,8 +53,11 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
|
|||
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
|
||||
const [error, setError] = useState(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const fileId = useRef<string | null>(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
|
|||
<Animated.View testID={`ImagePreview-${fileId}`}>
|
||||
<ProgressiveImage
|
||||
forwardRef={ref}
|
||||
id={fileId}
|
||||
id={fileId.current}
|
||||
imageUri={imageUrl}
|
||||
onError={onError}
|
||||
contentFit='contain'
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ exports[`AttachmentAuthor it matches snapshot when both name and icon are provid
|
|||
source={
|
||||
[
|
||||
{
|
||||
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
@ -91,6 +93,8 @@ exports[`AttachmentAuthor it matches snapshot when only icon is provided 1`] = `
|
|||
source={
|
||||
[
|
||||
{
|
||||
"cacheKey": "attachment-author-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ exports[`AttachmentFooter it matches snapshot when both footer and footer_icon a
|
|||
source={
|
||||
[
|
||||
{
|
||||
"cacheKey": "attachment-footer-icon-aHR0cHM6Ly9pbWFnZXMuY29tL2ltYWdlLnBuZw==",
|
||||
"cachePath": "",
|
||||
"uri": "https://images.com/image.png",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// 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 {Text, View} from 'react-native';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {useExternalLinkHandler} from '@hooks/use_external_link_handler';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -41,7 +42,8 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
|
|||
return (
|
||||
<View style={style.container}>
|
||||
{Boolean(icon) &&
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`attachment-author-icon-${urlSafeBase64Encode(icon!)}`}
|
||||
source={{uri: icon}}
|
||||
key='author_icon'
|
||||
style={style.icon}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// 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 {Text, View, Platform} from 'react-native';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -39,7 +40,8 @@ const AttachmentFooter = ({icon, text, theme}: Props) => {
|
|||
return (
|
||||
<View style={style.container}>
|
||||
{Boolean(icon) &&
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`attachment-footer-icon-${urlSafeBase64Encode(icon!)}`}
|
||||
source={{uri: icon}}
|
||||
key='footer_icon'
|
||||
style={style.icon}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import {useIsTablet} from '@hooks/device';
|
|||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
|
||||
|
||||
|
|
@ -55,7 +55,10 @@ export type Props = {
|
|||
const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => {
|
||||
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
|
||||
const [error, setError] = useState(false);
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const fileId = useRef<string | null>(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
|
|||
<Animated.View testID={`attachmentImage-${fileId}`}>
|
||||
<ProgressiveImage
|
||||
forwardRef={ref}
|
||||
id={fileId}
|
||||
id={fileId.current}
|
||||
imageStyle={style.attachmentMargin}
|
||||
imageUri={imageUrl}
|
||||
onError={onError}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// 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 {StyleSheet, View} from 'react-native';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
|
||||
type Props = {
|
||||
uri: string;
|
||||
}
|
||||
|
|
@ -24,7 +26,8 @@ const style = StyleSheet.create({
|
|||
const AttachmentThumbnail = ({uri}: Props) => {
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`attachment-thumbnail-${urlSafeBase64Encode(uri)}`}
|
||||
source={{uri}}
|
||||
contentFit='contain'
|
||||
style={style.image}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
// 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, {useMemo, useRef} from 'react';
|
||||
import {TouchableWithoutFeedback, useWindowDimensions} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {View as ViewConstants} from '@constants';
|
||||
import {GalleryInit} from '@context/gallery';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {lookupMimeType} from '@utils/file';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {calculateDimensions} from '@utils/images';
|
||||
import {type BestImage, getNearestPoint} from '@utils/opengraph';
|
||||
|
|
@ -59,7 +59,10 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt
|
|||
};
|
||||
|
||||
const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
|
||||
const fileId = useRef(generateId('uid')).current;
|
||||
const fileId = useRef<string | null>(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
|
|||
<GalleryInit galleryIdentifier={galleryIdentifier}>
|
||||
<Animated.View style={[styles, style.imageContainer, dimensionsStyle]}>
|
||||
<TouchableWithoutFeedback onPress={onGestureEvent}>
|
||||
<Animated.View testID={`OpenGraphImage-${fileId}`}>
|
||||
<Image
|
||||
<Animated.View testID={`OpenGraphImage-${fileId.current}`}>
|
||||
<ExpoImage
|
||||
id={fileId.current}
|
||||
style={[style.image, dimensionsStyle]}
|
||||
source={source}
|
||||
contentFit='contain'
|
||||
ref={ref}
|
||||
nativeID={`OpenGraphImage-${fileId}`}
|
||||
nativeID={`OpenGraphImage-${fileId.current}`}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<ImageBackground
|
||||
<ExpoImageBackground
|
||||
id={`youtube-${videoId}`}
|
||||
contentFit='cover'
|
||||
style={[styles.image, dimensions]}
|
||||
source={{uri: imgUrl}}
|
||||
>
|
||||
<YouTubeLogo style={styles.shadow}/>
|
||||
</ImageBackground>
|
||||
</ExpoImageBackground>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AnimatedImage
|
||||
key={imgSource.uri}
|
||||
<ExpoImageAnimated
|
||||
id={id}
|
||||
key={id}
|
||||
ref={forwardRef}
|
||||
style={fIStyle}
|
||||
source={{uri: imgSource.uri}}
|
||||
|
|
@ -86,8 +92,9 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
|
|||
|
||||
if (imgSource) {
|
||||
return (
|
||||
<AnimatedImage
|
||||
key={imgSource.uri}
|
||||
<ExpoImageAnimated
|
||||
id={id}
|
||||
key={id}
|
||||
ref={forwardRef}
|
||||
style={fIStyle}
|
||||
source={imgSource}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image, ImageBackground, type ImageContentFit, type ImageStyle} from 'expo-image';
|
||||
import {type ImageContentFit, type ImageStyle} from 'expo-image';
|
||||
import React, {type ReactNode, useEffect, useState} from 'react';
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import ExpoImage, {ExpoImageAnimated, ExpoImageBackground} from '@components/expo_image';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
type Props = ProgressiveImageProps & {
|
||||
children?: ReactNode | ReactNode[];
|
||||
forwardRef?: React.RefObject<any>;
|
||||
|
|
@ -65,14 +64,14 @@ const ProgressiveImage = ({
|
|||
if (isBackgroundImage && imageUri) {
|
||||
return (
|
||||
<View style={[styles.defaultImageContainer, style]}>
|
||||
<ImageBackground
|
||||
key={id}
|
||||
<ExpoImageBackground
|
||||
id={id}
|
||||
source={{uri: imageUri}}
|
||||
contentFit='cover'
|
||||
style={[StyleSheet.absoluteFill, imageStyle as StyleProp<ViewStyle>]}
|
||||
>
|
||||
{children}
|
||||
</ImageBackground>
|
||||
</ExpoImageBackground>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,7 +79,8 @@ const ProgressiveImage = ({
|
|||
if (defaultSource) {
|
||||
return (
|
||||
<View style={[styles.defaultImageContainer, style]}>
|
||||
<AnimatedImage
|
||||
<ExpoImageAnimated
|
||||
id={id}
|
||||
ref={forwardRef}
|
||||
source={defaultSource}
|
||||
style={[
|
||||
|
|
@ -101,7 +101,8 @@ const ProgressiveImage = ({
|
|||
|
||||
return (
|
||||
<Animated.View style={[styles.defaultImageContainer, style]}>
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={id}
|
||||
ref={forwardRef}
|
||||
placeholder={{uri: thumbnailUri}}
|
||||
placeholderContentFit='cover'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image, type ImageSource, type ImageStyle} from 'expo-image';
|
||||
import {type ImageSource, type ImageStyle} from 'expo-image';
|
||||
import React from 'react';
|
||||
import {type StyleProp, Text, type TextStyle, TouchableHighlight, View, type ViewStyle} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {isValidUrl} from '@utils/url';
|
||||
|
|
@ -122,7 +124,8 @@ const useImageAndStyle = (icon: string | ImageSource | undefined, imageStyles: S
|
|||
const imageStyle: StyleProp<ImageStyle> = [imageStyles];
|
||||
imageStyle.push({width: 24, height: 24});
|
||||
image = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`slide-up-panel-item-${urlSafeBase64Encode(icon.uri)}`}
|
||||
source={icon}
|
||||
style={imageStyle}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`team-icon-${id}-${lastIconUpdate}`}
|
||||
style={styles.image}
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, buildTeamIconUrl(serverUrl, id, lastIconUpdate))}}
|
||||
onError={handleImageError}
|
||||
|
|
|
|||
|
|
@ -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, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {TouchableWithoutFeedback, View, Text, type ViewStyle} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import ImageFile from '@components/files/image_file';
|
||||
import UploadRetry from '@components/post_draft/uploads/upload_item/upload_retry';
|
||||
|
|
@ -210,10 +210,11 @@ export default function UploadItemShared({
|
|||
if (isShareExtension) {
|
||||
return (
|
||||
<View style={style.imageOnlyThumbnail}>
|
||||
<Image
|
||||
<ExpoImage
|
||||
source={{uri: file.uri}}
|
||||
style={style.imageOnlyImage}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -237,7 +238,7 @@ export default function UploadItemShared({
|
|||
/>
|
||||
</View>
|
||||
);
|
||||
}, [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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<typeof UserList> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise<void> => {
|
||||
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});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>}
|
||||
*/
|
||||
public setActiveServerDatabase = async (serverUrl: string): Promise<void> => {
|
||||
public setActiveServerDatabase = async (serverUrl: string, options?: ActiveServerOptions): Promise<void> => {
|
||||
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});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
387
app/managers/intune_manager/index.test.ts
Normal file
387
app/managers/intune_manager/index.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
357
app/managers/intune_manager/index.ts
Normal file
357
app/managers/intune_manager/index.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<MSALTokens> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<IntunePolicy | null> {
|
||||
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<void> {
|
||||
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<Array<{oid: string; serverUrls: string[]; timestamp: number}>> {
|
||||
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;
|
||||
148
app/managers/intune_manager/types.ts
Normal file
148
app/managers/intune_manager/types.ts
Normal file
|
|
@ -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<string, string> | null = null;
|
||||
let IntuneConditionalLaunchBlockedReasonsExport: Record<string, string> | 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<MSALTokens>;
|
||||
enrollInMAM(serverUrl: string, identity: MSALIdentity): Promise<void>;
|
||||
isManagedServer(serverUrl: string): Promise<boolean>;
|
||||
deregisterAndUnenroll(serverUrl: string, doWipe: boolean): Promise<void>;
|
||||
cleanupAfterWipe(oid: string): Promise<void>;
|
||||
reportWipeComplete(oid: string, success: boolean): Promise<void>;
|
||||
getPendingWipes(): Promise<PendingWipe[]>;
|
||||
setCurrentIdentity(serverUrl: string | null): Promise<void>;
|
||||
getPolicy(serverUrl: string): Promise<IntunePolicy | null>;
|
||||
|
||||
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;
|
||||
}
|
||||
835
app/managers/security_manager/event_handlers.test.ts
Normal file
835
app/managers/security_manager/event_handlers.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, any> = {
|
||||
'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<ServersModel>);
|
||||
|
||||
test('should initialize with servers and set active server', async () => {
|
||||
const servers: Record<string, any> = {
|
||||
'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<string, any> = {
|
||||
'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<ServersModel>);
|
||||
|
||||
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<ServersModel>);
|
||||
|
||||
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<ServersModel>);
|
||||
|
||||
// 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<string, any> = {
|
||||
'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<string, any> = {
|
||||
'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<string, any> = {
|
||||
'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<string, any> = {
|
||||
'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`);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
717
app/managers/security_manager/mam.test.ts
Normal file
717
app/managers/security_manager/mam.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<GlobalModel>);
|
||||
|
||||
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<GlobalModel>);
|
||||
|
||||
(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<GlobalModel>);
|
||||
|
||||
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<GlobalModel>);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`user-${u.id}-${lastPictureUpdateAt}`}
|
||||
key={pictureUrl + i.toString()}
|
||||
style={[styles.profile, {transform: [{translateX: -(i * 24)}]}]}
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
||||
|
|
@ -60,11 +63,7 @@ const Group = ({theme, users}: Props) => {
|
|||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups}
|
||||
</>
|
||||
);
|
||||
return groups;
|
||||
};
|
||||
|
||||
export default Group;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={`user-${u.id}-${lastPictureUpdateAt}`}
|
||||
key={pictureUrl + i.toString()}
|
||||
style={[styles.profile, {transform: [{translateX: -(i * 12)}]}]}
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, pictureUrl)}}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React, {memo, useCallback} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {EMOJI_ROW_MARGIN, EMOJI_SIZE} from '@constants/emoji';
|
||||
|
|
@ -50,8 +50,9 @@ const ImageEmoji = ({file, imageUrl, onEmojiPress, path}: ImageEmojiProps) => {
|
|||
/>
|
||||
}
|
||||
{Boolean(imageUrl) &&
|
||||
<Image
|
||||
source={{uri: path}}
|
||||
<ExpoImage
|
||||
id={`emoji-${path}`}
|
||||
source={{uri: imageUrl}}
|
||||
style={styles.imageEmoji}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ManagedConfig>();
|
||||
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
|
||||
|
|
@ -42,11 +44,13 @@ const Actions = ({
|
|||
/>}
|
||||
{canDownloadFiles &&
|
||||
<>
|
||||
{allowSaveToLocation &&
|
||||
<Action
|
||||
disabled={disabled}
|
||||
iconName='download-outline'
|
||||
onPress={onDownload}
|
||||
/>
|
||||
}
|
||||
<Action
|
||||
disabled={disabled}
|
||||
iconName='export-variant'
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
// 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 {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 {urlSafeBase64Encode} from '@utils/security';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {getLastPictureUpdate} from '@utils/user';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
|
|
@ -41,14 +43,19 @@ const Avatar = ({
|
|||
const serverUrl = useServerUrl();
|
||||
|
||||
let uri = overrideIconUrl;
|
||||
if (!uri && author) {
|
||||
let id = 'avatar-override';
|
||||
if (uri) {
|
||||
id = `avatar-override-${urlSafeBase64Encode(uri)}`;
|
||||
} else if (author) {
|
||||
uri = buildProfileImageUrlFromUser(serverUrl, author);
|
||||
id = `user-${author.id}-${getLastPictureUpdate(author)}`;
|
||||
}
|
||||
|
||||
let picture;
|
||||
if (uri) {
|
||||
picture = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={id}
|
||||
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
|
||||
style={[styles.avatar, styles.avatarRadius]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<GalleryAction>('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 = ({
|
|||
</View>
|
||||
{showActions &&
|
||||
<Actions
|
||||
allowSaveToLocation={allowSaveToLocation}
|
||||
disabled={action !== 'none'}
|
||||
canDownloadFiles={!enableSecureFilePreview && canDownloadFiles}
|
||||
enablePublicLinks={!enableSecureFilePreview && enablePublicLink && item.type !== 'avatar'}
|
||||
|
|
|
|||
|
|
@ -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, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
||||
import {BackHandler} from 'react-native';
|
||||
import Animated, {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
||||
import {runOnJS, runOnUI, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {buildFilePreviewUrl} from '@actions/remote/file';
|
||||
import {ExpoImageAnimated} from '@components/expo_image';
|
||||
import {useGallery} from '@context/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {isGif} from '@utils/file';
|
||||
|
|
@ -20,8 +20,6 @@ import GalleryViewer from './viewer';
|
|||
|
||||
import type {GalleryItemType, GalleryPagerItem} from '@typings/screens/gallery';
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||
|
||||
interface GalleryProps {
|
||||
headerAndFooterHidden: SharedValue<boolean>;
|
||||
galleryIdentifier: string;
|
||||
|
|
@ -58,10 +56,10 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
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<GalleryRef, GalleryProps>(({
|
|||
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<GalleryRef, GalleryProps>(({
|
|||
|
||||
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<GalleryRef, GalleryProps>(({
|
|||
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 (
|
||||
<AnimatedImage
|
||||
placeholder={{uri: item.posterUri}}
|
||||
<ExpoImageAnimated
|
||||
id={currentItem.cacheKey}
|
||||
source={{uri: currentItem.posterUri}}
|
||||
style={info.itemStyles}
|
||||
placeholderContentFit='cover'
|
||||
/>
|
||||
|
|
@ -160,7 +169,7 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
}
|
||||
|
||||
return null;
|
||||
}, [item]);
|
||||
}, [items, localIndex]);
|
||||
|
||||
const onRenderPage = useCallback((props: GalleryPagerItem, idx: number) => {
|
||||
switch (props.item.type) {
|
||||
|
|
@ -183,7 +192,7 @@ const Gallery = forwardRef<GalleryRef, GalleryProps>(({
|
|||
default:
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
}, [hideHeaderAndFooter, initialIndex]);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (isGif(fileInfo) && fileInfo.id && !fileInfo.id.startsWith('uid')) {
|
||||
|
|
|
|||
|
|
@ -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<AnimatedStyle<StyleProp<ImageStyle>>>;
|
||||
}
|
||||
|
||||
interface LightboxSwipeoutProps {
|
||||
|
|
|
|||
|
|
@ -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<AnimatedStyle<StyleProp<ImageStyle>>>;
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
) : (
|
||||
<AnimatedImage
|
||||
<ExpoImageAnimated
|
||||
id={target.cacheKey}
|
||||
source={imageSource}
|
||||
placeholder={imageSource}
|
||||
placeholderContentFit='cover'
|
||||
style={itemStyles}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import Animated from 'react-native-reanimated';
|
|||
|
||||
import FileIcon from '@components/files/file_icon';
|
||||
import {Events, Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import SecurityManager from '@managers/security_manager';
|
||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {isDocument, isPdf} from '@utils/file';
|
||||
|
|
@ -54,6 +56,10 @@ const messages = defineMessages({
|
|||
id: 'gallery.unsupported',
|
||||
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
|
||||
},
|
||||
unsupportedAndBlockedDownload: {
|
||||
id: 'gallery.unsupported_and_blocked_download',
|
||||
defaultMessage: "Preview isn't supported for this file type, and downloads are disabled by your administrator.",
|
||||
},
|
||||
openFile: {
|
||||
id: 'gallery.open_file',
|
||||
defaultMessage: 'Open file',
|
||||
|
|
@ -66,6 +72,7 @@ const messages = defineMessages({
|
|||
|
||||
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, hideHeaderAndFooter}: Props) => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function ImageRenderer({
|
|||
return (
|
||||
<TransfrormerProvider sharedValues={sharedValues}>
|
||||
<ImageTransformer
|
||||
cacheKey={item.cacheKey}
|
||||
isPageActive={isPageActive}
|
||||
targetDimensions={targetDimensions}
|
||||
height={targetHeight}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
// 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, {useCallback} from 'react';
|
||||
import {StyleSheet} from 'react-native';
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||
import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated';
|
||||
import {SvgUri} from 'react-native-svg';
|
||||
|
||||
import ExpoImage from '@components/expo_image';
|
||||
|
||||
import {useTransformerSharedValues} from './context';
|
||||
import useTransformerDoubleTap from './gestures/useTransformerDoubleTap';
|
||||
import useTransformerPanGesture from './gestures/useTransformerPanGesture';
|
||||
|
|
@ -33,6 +35,7 @@ const styles = StyleSheet.create({
|
|||
});
|
||||
|
||||
interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item' | 'isPagerInProgress'> {
|
||||
cacheKey: string;
|
||||
enabled?: boolean;
|
||||
isSvg: boolean;
|
||||
source: ImageSource | string;
|
||||
|
|
@ -41,7 +44,7 @@ interface ImageTransformerProps extends Omit<GalleryPagerItem, 'index' | 'item'
|
|||
|
||||
const ImageTransformer = (
|
||||
{
|
||||
enabled = true, height, isPageActive,
|
||||
cacheKey, enabled = true, height, isPageActive,
|
||||
onPageStateChange, source, isSvg,
|
||||
targetDimensions, width, pagerPanGesture, pagerTapGesture, lightboxPanGesture,
|
||||
}: ImageTransformerProps) => {
|
||||
|
|
@ -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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={cacheKey}
|
||||
onLoad={onLoadImageSuccess}
|
||||
source={imageSource}
|
||||
style={{width, height}}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Image} from 'expo-image';
|
||||
import React, {type Dispatch, type SetStateAction, useCallback, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleSheet, Text, TouchableWithoutFeedback, useWindowDimensions, View} from 'react-native';
|
||||
|
|
@ -9,6 +8,7 @@ import Animated from 'react-native-reanimated';
|
|||
|
||||
import Button from '@components/button';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Preferences} from '@constants';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
|
|
@ -45,6 +45,7 @@ const styles = StyleSheet.create({
|
|||
});
|
||||
|
||||
type Props = {
|
||||
cacheKey: string;
|
||||
canDownloadFiles: boolean;
|
||||
enableSecureFilePreview: boolean;
|
||||
filename: string;
|
||||
|
|
@ -57,7 +58,7 @@ type Props = {
|
|||
hideHeaderAndFooter: (hide?: boolean) => 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 = (
|
||||
<Image
|
||||
<ExpoImage
|
||||
id={cacheKey}
|
||||
source={{uri: posterUri}}
|
||||
style={hasPoster && imageDimensions}
|
||||
onLoad={handlePosterSet}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {getTranscriptionUri, hasCaptions} from '@calls/utils';
|
|||
import {Events} from '@constants';
|
||||
import {ANDROID_VIDEO_INSET, GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import SecurityManager from '@managers/security_manager';
|
||||
import {transformerTimingConfig} from '@screens/gallery/animation_config/timing';
|
||||
import DownloadWithAction from '@screens/gallery/footer/download_with_action';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
|
|
@ -298,7 +299,8 @@ const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index
|
|||
}
|
||||
{hasError &&
|
||||
<VideoError
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
cacheKey={item.cacheKey}
|
||||
canDownloadFiles={canDownloadFiles && SecurityManager.canSaveToLocation(serverUrl, 'CameraRoll')}
|
||||
enableSecureFilePreview={enableSecureFilePreview}
|
||||
filename={item.name}
|
||||
isDownloading={downloading}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// 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 ExpoImage from '@components/expo_image';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
|
@ -45,9 +45,10 @@ const ScheduledPostEmptyComponent = () => {
|
|||
style={styles.container}
|
||||
testID='scheduled_post_empty_component'
|
||||
>
|
||||
<Image
|
||||
<ExpoImage
|
||||
source={scheduled_message_image}
|
||||
style={styles.image}
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
<FormattedText
|
||||
id='scheduled_post.empty.title'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// 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 {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpoImage from '@components/expo_image';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Preferences} from '@constants';
|
||||
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
|
||||
|
|
@ -55,9 +55,10 @@ const DraftScheduledPostTooltip = ({draftType, onClose}: Props) => {
|
|||
return (
|
||||
<View>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
<ExpoImage
|
||||
source={longPressGestureHandLogo}
|
||||
style={styles.image}
|
||||
cachePolicy='memory'
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.close}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {useIntl} from 'react-intl';
|
|||
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
|
||||
import {enableFreeze, enableScreens} from 'react-native-screens';
|
||||
|
||||
import {initializeSecurityManager} from '@actions/app/server';
|
||||
import {autoUpdateTimezone} from '@actions/remote/user';
|
||||
import ServerVersion from '@components/server_version';
|
||||
import {Events, Launch, Screens} from '@constants';
|
||||
|
|
@ -69,7 +68,7 @@ export function HomeScreen(props: HomeProps) {
|
|||
const appState = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue