mattermost-mobile-upstream/app/managers/session_attributes_manager/index.test.ts
cursor[bot] bc03c18f88
[MM-70866] Explain Android location permission before request (#10194)
* 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>
2026-09-25 08:33:23 -04:00

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();
});
});
});