mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 19:00:14 +00:00
* [MM-70605][MM-70607] Scope pre-auth secret storage per server
The pre-auth secret was written with react-native-keychain's generic-password
API using the `server` option, which those calls do not read. Every server
therefore resolved to one shared entry, and a falsy secret argument deleted it,
so ordinary operations such as building a client, refreshing the token header or
removing another server discarded the stored value.
Key the entry per server instead. On Android the alias namespace is shared with
internet credentials, so the service is prefixed to keep it away from the
session token; iOS uses the bare server URL, which is what the native reader in
Gekidou already queries. The account value is renamed to `preauth_secret` on
both sides. Writing the token no longer touches the secret: removal is explicit,
via removePreauthSecret.
A one-time migration adopts the previously shared entry when a single server
makes ownership unambiguous, and discards it otherwise. It only deletes the old
entry once the new one is confirmed stored, and defers when no server is active.
Also forward the secret through the login screen to SSO, and fix loginOptions to
count only enabled providers so the single-provider redirect works. Relands the
`.enabled` filter from #9585 by @pltnm239.
Edit Server now updates the live client's headers rather than invalidating the
session, and validates with the candidate secret so a value the server rejects
cannot be saved.
* [MM-70605] Harden preauth secret flows and fix Edit Server Detox on iOS
Address review feedback around migration markers, credential errors, and
keeping re-login secrets out of nav params. Focus the empty Authentication
secret field via Display Name return so iOS Detox can type into it.
* [MM-70605] Fix lines-around-comment in Edit Server Detox helper
* [MM-70605] Roll back preauth keychain when Edit Server client sync fails
After the secret is stored, header apply or createClient failures left the
new value in secure storage while UI state still held the old baseline.
Restore the previous secret (and live header) before surfacing the error.
* [MM-70605] Preserve exact preauth secret on Edit Server rollback
Avoid trimming during keychain restore so boundary whitespace from the
previously stored value is not altered.
* [MM-70605] Harden preauth migration ownership and secret handoff
Adopt the legacy secret only when a single server exists in the app DB,
keep add-server candidates in EphemeralStore instead of nav params, and
resync the live client to stored credentials when keychain rollback fails.
* [MM-70605] Discard the legacy preauth secret when no server can own it
* [MM-70605] Shorten the preauth migration docblock
* [MM-70605] Skip the Authorization header when there is no bearer token
* [MM-70605] Surface preauth secret rejections with a specific error message
* [MM-70605] Mock clearPendingPreauthSecret in session action tests
* [MM-70605] Remove the unreachable catch from restorePreviousPreauthSecret
setPreauthSecret and removePreauthSecret never reject, and the returned promise was not awaited, so the catch could not run.
* [MM-70605] Use network-client fix for the iOS addHeaders crash
Temporary pin to mattermost/react-native-network-client#175 to check CI. Replace with the released version before merge.
* [MM-70605] Bump react-native-network-client to 1.11.5
1.11.5 gives each recreated iOS session its own delegate in addHeaders, which fixes the SIGTRAP on pre-auth secret changes.
---------
(cherry picked from commit 9ccfa0ebe9)
Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
669 lines
26 KiB
TypeScript
669 lines
26 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
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 {
|
|
addPushProxyVerificationStateFromLogin,
|
|
forceLogoutIfNecessary,
|
|
fetchSessions,
|
|
login,
|
|
logout,
|
|
nativeEntraLogin,
|
|
scheduleSessionNotification,
|
|
sendPasswordResetEmail,
|
|
ssoLogin,
|
|
} from './session';
|
|
|
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
|
import type {LoginArgs} from '@typings/database/database';
|
|
|
|
const intl = createIntl({
|
|
locale: 'en',
|
|
messages: {},
|
|
});
|
|
|
|
const serverUrl = 'baseHandler.test.com';
|
|
let operator: ServerDataOperator;
|
|
|
|
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;
|
|
|
|
const throwFunc = () => {
|
|
throw Error('error');
|
|
};
|
|
|
|
const mockClient = {
|
|
login: jest.fn(() => user1),
|
|
loginByIntune: jest.fn().mockResolvedValue(user1),
|
|
setCSRFToken: jest.fn(),
|
|
setClientCredentials: jest.fn(),
|
|
getClientConfigOld: jest.fn(() => ({})),
|
|
getClientLicenseOld: jest.fn(() => ({})),
|
|
getSessions: jest.fn(() => [session1]),
|
|
sendPasswordResetEmail: jest.fn(() => ({status: 200})),
|
|
getMe: jest.fn(() => user1),
|
|
logout: jest.fn(),
|
|
};
|
|
|
|
const mockWebsocketClient = {
|
|
close: jest.fn(),
|
|
};
|
|
|
|
let mockGetPushProxyVerificationState: jest.Mock;
|
|
jest.mock('@store/ephemeral_store', () => {
|
|
const original = jest.requireActual('@store/ephemeral_store');
|
|
mockGetPushProxyVerificationState = jest.fn(() => 'verified');
|
|
return {
|
|
...original,
|
|
getPushProxyVerificationState: mockGetPushProxyVerificationState,
|
|
clearPendingPreauthSecret: jest.fn(),
|
|
};
|
|
});
|
|
|
|
jest.mock('@queries/app/global', () => {
|
|
const original = jest.requireActual('@queries/app/global');
|
|
return {
|
|
...original,
|
|
getDeviceToken: jest.fn().mockResolvedValue('apple_rn-v2:standard-token'),
|
|
getVoIPDeviceToken: jest.fn().mockResolvedValue('apple_rn-v2:voip-token'),
|
|
};
|
|
});
|
|
|
|
let mockFetch: jest.Mock;
|
|
jest.mock('@react-native-community/netinfo', () => {
|
|
const original = jest.requireActual('@react-native-community/netinfo');
|
|
mockFetch = jest.fn(() => ({isInternetReachable: true}));
|
|
return {
|
|
...original,
|
|
fetch: mockFetch,
|
|
};
|
|
});
|
|
|
|
let mockCancelLocalNotification: jest.Mock;
|
|
jest.mock('react-native-notifications', () => {
|
|
const original = jest.requireActual('react-native-notifications');
|
|
mockCancelLocalNotification = jest.fn();
|
|
return {
|
|
...original,
|
|
Notifications: {
|
|
...original.Notifications,
|
|
cancelLocalNotification: mockCancelLocalNotification,
|
|
},
|
|
};
|
|
});
|
|
|
|
let mockGetCSRFFromCookie: jest.Mock;
|
|
jest.mock('@utils/security', () => {
|
|
const original = jest.requireActual('@utils/security');
|
|
mockGetCSRFFromCookie = jest.fn(() => 'csrfid');
|
|
return {
|
|
...original,
|
|
getCSRFFromCookie: mockGetCSRFFromCookie,
|
|
};
|
|
});
|
|
|
|
jest.mock('@utils/log', () => {
|
|
const original = jest.requireActual('@utils/log');
|
|
return {
|
|
...original,
|
|
logWarning: jest.fn(),
|
|
};
|
|
});
|
|
|
|
describe('Session Actions', () => {
|
|
beforeAll(() => {
|
|
// @ts-ignore
|
|
NetworkManager.getClient = () => mockClient;
|
|
|
|
// @ts-ignore
|
|
WebsocketManager.getClient = () => mockWebsocketClient;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await DatabaseManager.init([serverUrl]);
|
|
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await DatabaseManager.destroyServerDatabase(serverUrl);
|
|
});
|
|
|
|
describe('sessions', () => {
|
|
it('addPushProxyVerificationStateFromLogin - handle not found database', async () => {
|
|
const result = await addPushProxyVerificationStateFromLogin('foo');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('addPushProxyVerificationStateFromLogin - no verification', async () => {
|
|
mockGetPushProxyVerificationState.mockImplementationOnce(() => '');
|
|
const result = await addPushProxyVerificationStateFromLogin(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('addPushProxyVerificationStateFromLogin - base case', async () => {
|
|
const result = await addPushProxyVerificationStateFromLogin(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('forceLogoutIfNecessary - handle not found database', async () => {
|
|
const result = await forceLogoutIfNecessary('foo', {});
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeTruthy();
|
|
expect(result.logout).toBe(false);
|
|
});
|
|
|
|
it('forceLogoutIfNecessary - logout expected from 401', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await forceLogoutIfNecessary(serverUrl, {status_code: 401, url: '/api/v4/users/me'});
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeNull();
|
|
expect(result.logout).toBe(true);
|
|
});
|
|
|
|
it('forceLogoutIfNecessary - logout not expected', async () => {
|
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
|
|
|
|
const result = await forceLogoutIfNecessary(serverUrl, {status_code: 500, url: '/api/v4/users/me'});
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeNull();
|
|
expect(result.logout).toBe(false);
|
|
});
|
|
|
|
it('fetchSessions - handle error', async () => {
|
|
mockClient.getSessions.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await fetchSessions('foo', '');
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('fetchSessions - handle client error', async () => {
|
|
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await fetchSessions(serverUrl, user1.id);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('fetchSessions - base case', async () => {
|
|
const result = await fetchSessions(serverUrl, user1.id);
|
|
expect(result).toBeDefined();
|
|
expect(result?.length).toBe(1);
|
|
});
|
|
|
|
it('login - base case', async () => {
|
|
const result = await login(serverUrl, {config: {DiagnosticId: 'diagnosticid'}} as LoginArgs);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.failed).toBe(false);
|
|
});
|
|
|
|
it('login - handle throw on login request', async () => {
|
|
mockClient.login.mockImplementationOnce(jest.fn(throwFunc));
|
|
|
|
const result = await login(serverUrl, {config: {DiagnosticId: 'diagnosticid'}} as LoginArgs);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
expect(result.failed).toBe(true);
|
|
});
|
|
|
|
it('login - handle throw after login request', async () => {
|
|
jest.spyOn(DatabaseManager, 'setActiveServerDatabase').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await login(serverUrl, {config: {DiagnosticId: 'diagnosticid'}} as LoginArgs);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
expect(result.failed).toBe(false);
|
|
});
|
|
|
|
it('cancelSessionNotification - handle not found database', async () => {
|
|
const result = await cancelSessionNotification('foo');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('cancelSessionNotification - base case', async () => {
|
|
await operator.handleSystem({
|
|
systems: [{
|
|
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
|
|
value: {
|
|
id: 'sessionid1',
|
|
notificationId: 'notificationid',
|
|
expiresAt: 123,
|
|
},
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await cancelSessionNotification(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('cancelSessionNotification - no expired session', async () => {
|
|
const result = await cancelSessionNotification(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('scheduleSessionNotification - handle not found database', async () => {
|
|
const result = await scheduleSessionNotification('foo');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('scheduleSessionNotification - base case', async () => {
|
|
await operator.handleSystem({
|
|
systems: [{
|
|
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
|
|
value: {
|
|
id: 'sessionid1',
|
|
notificationId: 'notificationid',
|
|
expiresAt: 123,
|
|
},
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const result = await scheduleSessionNotification(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('scheduleSessionNotification - no session', async () => {
|
|
mockClient.getSessions.mockImplementationOnce(() => []);
|
|
const result = await scheduleSessionNotification(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('scheduleSessionNotification - null sessions', async () => {
|
|
mockClient.getSessions.mockImplementationOnce(() => null as any);
|
|
const result = await scheduleSessionNotification(serverUrl);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('sendPasswordResetEmail - handle error', async () => {
|
|
mockClient.sendPasswordResetEmail.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await sendPasswordResetEmail('foo', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
|
|
it('sendPasswordResetEmail - base case', async () => {
|
|
const result = await sendPasswordResetEmail(serverUrl, user1.email);
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(200);
|
|
});
|
|
|
|
it('ssoLogin - handle error', async () => {
|
|
mockClient.getMe.mockImplementationOnce(jest.fn(throwFunc));
|
|
const result = await ssoLogin('foo', '', '', '', '');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
expect(result.failed).toBe(true);
|
|
});
|
|
|
|
it('ssoLogin - base case', async () => {
|
|
const result = await ssoLogin(serverUrl, 'servername', 'diagnosticid', 'authtoken', 'csrftoken');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.failed).toBe(false);
|
|
});
|
|
|
|
it('ssoLogin - handle throw after login request', async () => {
|
|
jest.spyOn(DatabaseManager, 'setActiveServerDatabase').mockImplementationOnce(throwFunc);
|
|
|
|
const result = await ssoLogin(serverUrl, 'servername', 'diagnosticid', 'authtoken', 'csrftoken');
|
|
expect(result).toBeDefined();
|
|
expect(result.error).toBeDefined();
|
|
expect(result.failed).toBe(false);
|
|
});
|
|
|
|
it('findSession - handle not found database', async () => {
|
|
const result = await findSession('foo', []);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('findSession - by id', async () => {
|
|
await operator.handleSystem({
|
|
systems: [{
|
|
id: SYSTEM_IDENTIFIERS.SESSION_EXPIRATION,
|
|
value: {
|
|
id: 'sessionid1',
|
|
notificationId: 'notificationid',
|
|
expiresAt: 123,
|
|
},
|
|
}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const session = await findSession(serverUrl, [session1]);
|
|
expect(session).toBeDefined();
|
|
});
|
|
|
|
it('findSession - by device', async () => {
|
|
await DatabaseManager.appDatabase?.operator.handleGlobal({
|
|
globals: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: 'deviceid'}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const session = await findSession(serverUrl, [session1]);
|
|
expect(session).toBeDefined();
|
|
});
|
|
|
|
it('findSession - non-match device token', async () => {
|
|
await DatabaseManager.appDatabase?.operator.handleGlobal({
|
|
globals: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: 'diffdeviceid'}],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
|
|
const session = await findSession(serverUrl, [session1]);
|
|
expect(session).toBeDefined();
|
|
});
|
|
|
|
it('findSession - by csrf', async () => {
|
|
const session = await findSession(serverUrl, [session1]);
|
|
expect(session).toBeDefined();
|
|
});
|
|
|
|
it('findSession - no csrf token', async () => {
|
|
mockGetCSRFFromCookie.mockResolvedValueOnce('');
|
|
const session = await findSession(serverUrl, [session1]);
|
|
expect(session).toBeUndefined();
|
|
});
|
|
|
|
it('findSession - by os', async () => {
|
|
const session = await findSession(serverUrl, [{...session1, props: {os: Platform.OS, csrf: 'diffcsrfid'}}]);
|
|
expect(session).toBeDefined();
|
|
});
|
|
|
|
it('findSession - handle error', async () => {
|
|
jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(throwFunc);
|
|
const result = await findSession(serverUrl, []);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
const mockEmit = jest.spyOn(DeviceEventEmitter, 'emit').mockImplementation(() => true);
|
|
const mockAlert = jest.spyOn(Alert, 'alert').mockImplementation(() => true);
|
|
|
|
type TestCase = {
|
|
options: {
|
|
skipServerLogout: boolean;
|
|
logoutOnAlert: boolean;
|
|
removeServer: boolean;
|
|
skipEvents: boolean;
|
|
};
|
|
withIntl: boolean;
|
|
clientReturnError: boolean;
|
|
}
|
|
|
|
const testCases: TestCase[] = [];
|
|
for (const skipServerLogout of [false, true]) {
|
|
for (const logoutOnAlert of [false, true]) {
|
|
for (const removeServer of [false, true]) {
|
|
for (const skipEvents of [false, true]) {
|
|
for (const withIntl of [false, true]) {
|
|
for (const clientReturnError of [false, true]) {
|
|
testCases.push({options: {
|
|
skipServerLogout,
|
|
logoutOnAlert,
|
|
removeServer,
|
|
skipEvents},
|
|
withIntl,
|
|
clientReturnError});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
test.each(testCases)('%j', async ({clientReturnError, options, withIntl}) => {
|
|
if (clientReturnError) {
|
|
mockClient.logout.mockImplementationOnce(() => {
|
|
throw new Error('logout error');
|
|
});
|
|
} else {
|
|
mockClient.logout = jest.fn(() => ({status: 'OK'}));
|
|
}
|
|
|
|
const mockFormatMessage = jest.spyOn(intl, 'formatMessage');
|
|
|
|
const shouldCallClient = !options.skipServerLogout;
|
|
const shouldLogWarning = shouldCallClient && clientReturnError;
|
|
const shouldShowAlert = shouldCallClient && clientReturnError;
|
|
const shouldEmit = !options.skipEvents;
|
|
const shouldEmitBeforeAlert = shouldEmit && (!shouldShowAlert || options.logoutOnAlert);
|
|
const shouldEmitAfterAlert = shouldEmit && !shouldEmitBeforeAlert;
|
|
const expectedResult = !(shouldShowAlert && !options.logoutOnAlert);
|
|
const shouldCloseWebsocket = options.skipServerLogout || !clientReturnError || options.logoutOnAlert;
|
|
|
|
const clientCalls = shouldCallClient ? 1 : 0;
|
|
const alertButtons = options.logoutOnAlert ? 1 : 2;
|
|
|
|
const result = await logout(serverUrl, withIntl ? intl : undefined, {...options});
|
|
|
|
expect(result.data).toBe(expectedResult);
|
|
expect(mockClient.logout).toHaveBeenCalledTimes(clientCalls);
|
|
if (shouldLogWarning) {
|
|
expect(logWarning).toHaveBeenCalled();
|
|
}
|
|
|
|
if (shouldEmitBeforeAlert) {
|
|
expect(mockEmit).toHaveBeenCalledWith(Events.SERVER_LOGOUT, {serverUrl, removeServer: options.removeServer});
|
|
} else {
|
|
expect(mockEmit).not.toHaveBeenCalled();
|
|
}
|
|
|
|
if (shouldCloseWebsocket) {
|
|
expect(mockWebsocketClient.close).toHaveBeenCalledWith(true);
|
|
} else {
|
|
expect(mockWebsocketClient.close).not.toHaveBeenCalled();
|
|
}
|
|
|
|
if (shouldShowAlert) {
|
|
if (withIntl) {
|
|
expect(mockFormatMessage).toHaveBeenCalled();
|
|
}
|
|
expect(mockAlert).toHaveBeenCalled();
|
|
expect(mockAlert.mock.calls[0][2]).toHaveLength(alertButtons);
|
|
if (alertButtons === 2) {
|
|
mockAlert.mock.calls[0][2]?.[0].onPress?.();
|
|
expect(mockClient.logout).toHaveBeenCalledTimes(clientCalls);
|
|
expect(mockEmit).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
mockAlert.mock.calls[0][2]?.[alertButtons - 1].onPress?.(); // Last button should be confirm
|
|
expect(mockClient.logout).toHaveBeenCalledTimes(clientCalls);
|
|
if (shouldEmitAfterAlert) {
|
|
expect(mockEmit).toHaveBeenCalledWith(Events.SERVER_LOGOUT, {serverUrl, removeServer: options.removeServer});
|
|
} else {
|
|
expect(mockEmit).toHaveBeenCalledTimes(shouldEmitBeforeAlert ? 1 : 0);
|
|
}
|
|
} else {
|
|
expect(mockFormatMessage).not.toHaveBeenCalled();
|
|
expect(mockAlert).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
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.mockReturnValue(undefined);
|
|
IntuneManager.isManagedServer.mockReturnValue(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.mockReturnValue(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,
|
|
'apple_rn-v2:standard-token',
|
|
'apple_rn-v2:voip-token',
|
|
);
|
|
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,
|
|
'apple_rn-v2:standard-token',
|
|
'apple_rn-v2:voip-token',
|
|
);
|
|
});
|
|
|
|
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.mockReturnValue(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.mockReturnValue(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 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);
|
|
});
|
|
});
|
|
});
|