mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 02:40:15 +00:00
* MM-70866 explain Android location permission request Co-authored-by: mattermost-code <matty-code@mattermost.com> * MM-70866 build Android location prompt at request time Create the Android location pre-prompt formatter when the prompt is shown so tests that mock react-intl can import the manager, and use the current user locale for those strings. Co-authored-by: mattermost-code <matty-code@mattermost.com> * chore: retrigger CI after unrelated recent_searches flake The ci test job failed on RecentSearches database removal, which this PR does not change. gh run rerun is not permitted for this token. Co-authored-by: mattermost-code <matty-code@mattermost.com> --------- Co-authored-by: mattermost-code <matty-code@mattermost.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
391 lines
17 KiB
TypeScript
391 lines
17 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {getAndroidId, getIosIdForVendorAsync} from 'expo-application';
|
|
import {isRootedExperimentalAsync} from 'expo-device';
|
|
import {Alert, Platform} from 'react-native';
|
|
import {PERMISSIONS, RESULTS, check, request} from 'react-native-permissions';
|
|
|
|
import {License} from '@constants';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getConfigBooleanValue, getLicense} from '@queries/servers/system';
|
|
import {getCurrentUser} from '@queries/servers/user';
|
|
import * as GeneralUtils from '@utils/general';
|
|
|
|
const mockSetSessionAttributesEnabled = jest.fn();
|
|
const mockRemoveSessionAttributesServer = jest.fn();
|
|
const mockSetSessionAttributesManifest = jest.fn();
|
|
const mockUpsertSessionAttributesField = jest.fn();
|
|
const mockRemoveSessionAttributesField = jest.fn();
|
|
const mockSetSessionAttributesStableValues = jest.fn();
|
|
const mockFetchSessionAttributesManifest = jest.fn();
|
|
|
|
jest.mock('expo-application', () => ({
|
|
getAndroidId: jest.fn(() => 'android-device-id'),
|
|
getIosIdForVendorAsync: jest.fn().mockResolvedValue('ios-device-id'),
|
|
nativeApplicationVersion: '2.20.0',
|
|
nativeBuildVersion: '456',
|
|
}));
|
|
|
|
jest.mock('expo-device', () => ({
|
|
isRootedExperimentalAsync: jest.fn().mockResolvedValue(false),
|
|
osVersion: '18.0',
|
|
}));
|
|
|
|
jest.mock('@actions/remote/session_attributes', () => ({
|
|
fetchSessionAttributesManifest: (...args: unknown[]) => mockFetchSessionAttributesManifest(...args),
|
|
}));
|
|
jest.mock('@database/manager');
|
|
jest.mock('@mattermost/react-native-network-client', () => ({
|
|
setSessionAttributesEnabled: (...args: unknown[]) => mockSetSessionAttributesEnabled(...args),
|
|
removeSessionAttributesServer: (...args: unknown[]) => mockRemoveSessionAttributesServer(...args),
|
|
setSessionAttributesManifest: (...args: unknown[]) => mockSetSessionAttributesManifest(...args),
|
|
upsertSessionAttributesField: (...args: unknown[]) => mockUpsertSessionAttributesField(...args),
|
|
removeSessionAttributesField: (...args: unknown[]) => mockRemoveSessionAttributesField(...args),
|
|
setSessionAttributesStableValues: (...args: unknown[]) => mockSetSessionAttributesStableValues(...args),
|
|
}));
|
|
jest.mock('@queries/servers/system');
|
|
jest.mock('@queries/servers/user', () => ({
|
|
getCurrentUser: jest.fn(),
|
|
}));
|
|
jest.mock('@utils/log');
|
|
|
|
import {SessionAttributesManagerSingleton} from './index';
|
|
|
|
import type UserModel from '@typings/database/models/servers/user';
|
|
|
|
const serverUrl = 'https://chat.example.com';
|
|
const manifest: SAField[] = [
|
|
{name: 'os_platform', type: 'string', ttl_seconds: 0, grace_period_seconds: 0},
|
|
];
|
|
const ssidManifest: SAField[] = [
|
|
{name: 'ssid', type: 'string', ttl_seconds: 0, grace_period_seconds: 0},
|
|
];
|
|
|
|
const iosStableValues = {
|
|
jailbreak_detected: 'false',
|
|
os_version: '18.0',
|
|
os_platform: 'ios',
|
|
client_version: '2.20.0+456',
|
|
client_device_id: 'ios-device-id',
|
|
};
|
|
|
|
describe('SessionAttributesManager', () => {
|
|
let manager: SessionAttributesManagerSingleton;
|
|
const mockDatabase = {};
|
|
|
|
beforeEach(() => {
|
|
jest.restoreAllMocks();
|
|
jest.clearAllMocks();
|
|
Platform.OS = 'ios';
|
|
jest.spyOn(Platform, 'select').mockReturnValue(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
|
|
manager = new SessionAttributesManagerSingleton();
|
|
(DatabaseManager.serverDatabases as Record<string, unknown>)[serverUrl] = {database: mockDatabase};
|
|
jest.mocked(getConfigBooleanValue).mockResolvedValue(true);
|
|
jest.mocked(getLicense).mockResolvedValue({
|
|
IsLicensed: 'true',
|
|
SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced,
|
|
} as ClientLicense);
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest});
|
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(false);
|
|
jest.mocked(check).mockResolvedValue(RESULTS.GRANTED);
|
|
jest.mocked(request).mockResolvedValue(RESULTS.GRANTED);
|
|
jest.mocked(getCurrentUser).mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('should collect stable values from Expo on iOS', async () => {
|
|
await manager.syncStaticValues();
|
|
|
|
expect(mockSetSessionAttributesStableValues).toHaveBeenCalledWith(iosStableValues);
|
|
expect(getIosIdForVendorAsync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should collect stable values from Expo on Android', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(isRootedExperimentalAsync).mockResolvedValue(true);
|
|
|
|
await manager.syncStaticValues();
|
|
|
|
expect(mockSetSessionAttributesStableValues).toHaveBeenCalledWith({
|
|
jailbreak_detected: 'true',
|
|
os_version: '18.0',
|
|
os_platform: 'android',
|
|
client_version: '2.20.0+456',
|
|
client_device_id: 'android-device-id',
|
|
});
|
|
expect(getAndroidId).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should push stable values before pushing the manifest to native', async () => {
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
const stableValuesOrder = mockSetSessionAttributesStableValues.mock.invocationCallOrder[0];
|
|
const manifestOrder = mockSetSessionAttributesManifest.mock.invocationCallOrder[0];
|
|
expect(stableValuesOrder).toBeLessThan(manifestOrder);
|
|
});
|
|
|
|
it('should enable native collection and push the manifest when refreshManifest succeeds', async () => {
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockSetSessionAttributesEnabled).toHaveBeenCalledWith(serverUrl, true);
|
|
expect(mockSetSessionAttributesManifest).toHaveBeenCalledWith(serverUrl, manifest);
|
|
});
|
|
|
|
it('should remove the server when the feature flag is disabled', async () => {
|
|
jest.mocked(getConfigBooleanValue).mockResolvedValue(false);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockRemoveSessionAttributesServer).toHaveBeenCalledWith(serverUrl);
|
|
expect(mockSetSessionAttributesManifest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should remove the server when the license tier is too low', async () => {
|
|
jest.mocked(getLicense).mockResolvedValue({SkuShortName: License.SKU_SHORT_NAME.Professional} as ClientLicense);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockRemoveSessionAttributesServer).toHaveBeenCalledWith(serverUrl);
|
|
});
|
|
|
|
it('should clear the manifest when the server returns an empty manifest', async () => {
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: []});
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockSetSessionAttributesManifest).toHaveBeenCalledWith(serverUrl, []);
|
|
});
|
|
|
|
it('should leave native state untouched when the manifest fetch fails', async () => {
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({error: new Error('network error')});
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockSetSessionAttributesManifest).not.toHaveBeenCalled();
|
|
expect(mockRemoveSessionAttributesServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should remove the server when the database is missing', async () => {
|
|
delete (DatabaseManager.serverDatabases as Record<string, unknown>)[serverUrl];
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockRemoveSessionAttributesServer).toHaveBeenCalledWith(serverUrl);
|
|
});
|
|
|
|
it('should remove the server when refreshManifest throws', async () => {
|
|
mockFetchSessionAttributesManifest.mockRejectedValue(new Error('network error'));
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
|
|
expect(mockRemoveSessionAttributesServer).toHaveBeenCalledWith(serverUrl);
|
|
});
|
|
|
|
it('should forward removeServer to native', () => {
|
|
manager.removeServer(serverUrl);
|
|
expect(mockRemoveSessionAttributesServer).toHaveBeenCalledWith(serverUrl);
|
|
});
|
|
|
|
it('should forward upsertManifestField to native', () => {
|
|
const field: SAField = {name: 'os_version', type: 'string', ttl_seconds: 60, grace_period_seconds: 0};
|
|
manager.upsertManifestField(serverUrl, field);
|
|
expect(mockUpsertSessionAttributesField).toHaveBeenCalledWith(serverUrl, field);
|
|
});
|
|
|
|
it('should forward removeManifestField to native', () => {
|
|
manager.removeManifestField(serverUrl, 'os_version');
|
|
expect(mockRemoveSessionAttributesField).toHaveBeenCalledWith(serverUrl, 'os_version');
|
|
});
|
|
|
|
describe('location permission for the ssid attribute', () => {
|
|
it('should request iOS location directly when the manifest includes ssid', async () => {
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).toHaveBeenCalledWith(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
|
|
expect(Alert.alert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should explain the Android location request before opening the native permission prompt', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(Platform.select).mockReturnValue(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(Alert.alert).toHaveBeenCalledWith(
|
|
'Allow location access?',
|
|
"Your location can be used to report the Wi-Fi network name to your administrator when required by your organization's security policy.",
|
|
[
|
|
expect.objectContaining({text: 'Not now', style: 'cancel'}),
|
|
expect.objectContaining({text: 'Continue'}),
|
|
],
|
|
expect.objectContaining({cancelable: true}),
|
|
);
|
|
expect(request).not.toHaveBeenCalled();
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
|
|
const continueButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[1];
|
|
const onContinue = continueButton?.onPress;
|
|
expect(onContinue).toEqual(expect.any(Function));
|
|
onContinue?.();
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).toHaveBeenCalledWith(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
|
|
});
|
|
|
|
it('should not request native Android permission when the pre-prompt is declined', async () => {
|
|
Platform.OS = 'android';
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
|
|
const cancelButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[0];
|
|
const onCancel = cancelButton?.onPress;
|
|
expect(onCancel).toEqual(expect.any(Function));
|
|
onCancel?.();
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not request native Android permission when the pre-prompt is dismissed', async () => {
|
|
Platform.OS = 'android';
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
|
|
const options = jest.mocked(Alert.alert).mock.calls[0][3];
|
|
expect(options?.onDismiss).toEqual(expect.any(Function));
|
|
options?.onDismiss?.();
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show only one Android pre-prompt for concurrent requests and allow a later retry', async () => {
|
|
Platform.OS = 'android';
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
manager.upsertManifestField(serverUrl, ssidManifest[0]);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(Alert.alert).toHaveBeenCalledTimes(1);
|
|
|
|
const cancelButton = jest.mocked(Alert.alert).mock.calls[0][2]?.[0];
|
|
cancelButton?.onPress?.();
|
|
await new Promise(process.nextTick);
|
|
|
|
manager.upsertManifestField(serverUrl, ssidManifest[0]);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(Alert.alert).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should not request location when the manifest omits ssid', async () => {
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(check).not.toHaveBeenCalled();
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not request location when it has already been granted', async () => {
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not request location when the permission is blocked', async () => {
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.BLOCKED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should format the Android pre-prompt using the current user locale', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(Platform.select).mockReturnValue(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
|
|
jest.mocked(getCurrentUser).mockResolvedValue({locale: 'es'} as UserModel);
|
|
const getIntlShapeSpy = jest.spyOn(GeneralUtils, 'getIntlShape').mockReturnValue({
|
|
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
|
|
} as ReturnType<typeof GeneralUtils.getIntlShape>);
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(getIntlShapeSpy).toHaveBeenCalledWith('es');
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
});
|
|
|
|
it('should not show the Android pre-prompt again when a later check reports blocked', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(Platform.select).mockReturnValue(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
|
|
mockFetchSessionAttributesManifest.mockResolvedValue({manifest: ssidManifest});
|
|
jest.mocked(check).mockResolvedValueOnce(RESULTS.DENIED).mockResolvedValue(RESULTS.BLOCKED);
|
|
jest.mocked(request).mockResolvedValue(RESULTS.BLOCKED);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
jest.mocked(Alert.alert).mock.calls[0][2]?.[1]?.onPress?.();
|
|
await new Promise(process.nextTick);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
|
|
await manager.refreshManifest(serverUrl);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(jest.mocked(Alert.alert).mock.calls).toHaveLength(1);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should request location when the ssid field arrives over the websocket', async () => {
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
manager.upsertManifestField(serverUrl, ssidManifest[0]);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(request).toHaveBeenCalledWith(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
|
|
});
|
|
|
|
it('should not request location for a field other than ssid', async () => {
|
|
jest.mocked(check).mockResolvedValue(RESULTS.DENIED);
|
|
|
|
manager.upsertManifestField(serverUrl, manifest[0]);
|
|
await new Promise(process.nextTick);
|
|
|
|
expect(check).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|