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. Co-authored-by: Cursor <cursoragent@cursor.com> * [MM-70605] Fix lines-around-comment in Edit Server Detox helper Co-authored-by: Cursor <cursoragent@cursor.com> * [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. Co-authored-by: Cursor <cursoragent@cursor.com> * [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. Co-authored-by: Cursor <cursoragent@cursor.com> * [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. Co-authored-by: Cursor <cursoragent@cursor.com> * [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. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Alert} from 'react-native';
|
|
|
|
import {Preferences, Screens, Sso} from '@constants';
|
|
import {navigateToScreen} from '@screens/navigation';
|
|
import {getIntlShape} from '@utils/general';
|
|
import {isMinimumServerVersion} from '@utils/helpers';
|
|
|
|
import {
|
|
addNewServer,
|
|
alertServerAlreadyConnected,
|
|
alertServerError,
|
|
alertServerLogout,
|
|
alertServerRemove,
|
|
editServer,
|
|
isSupportedServer,
|
|
loginOptions,
|
|
loginToServer,
|
|
semverFromServerVersion,
|
|
sortServersByDisplayName,
|
|
unsupportedServer,
|
|
} from './index';
|
|
|
|
import type {ServersModel} from '@database/models/app';
|
|
import type {DeepLinkWithData} from '@typings/launch';
|
|
|
|
jest.mock('@utils/helpers', () => ({
|
|
isMinimumServerVersion: jest.fn(),
|
|
isTablet: jest.fn(() => true),
|
|
}));
|
|
|
|
jest.mock('@screens/navigation', () => ({
|
|
navigateToScreen: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@components/compass_icon', () => ({
|
|
getImageSourceSync: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@utils/url', () => ({
|
|
tryOpenURL: jest.fn(),
|
|
}));
|
|
|
|
describe('Server Utils', () => {
|
|
afterAll(() => {
|
|
jest.resetAllMocks();
|
|
});
|
|
describe('unsupportedServer', () => {
|
|
const intl = getIntlShape();
|
|
|
|
it('should show the alert for sysadmin', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
unsupportedServer('Default Server', true, intl);
|
|
expect(alert?.mock?.calls?.[0]?.[2]?.length).toBe(2);
|
|
});
|
|
|
|
it('should not show the alert for team admin / user', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
unsupportedServer('Default Server', false, intl);
|
|
expect(alert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('isSupportedServer', () => {
|
|
it('should return true for supported server version', () => {
|
|
(isMinimumServerVersion as jest.Mock).mockReturnValue(true);
|
|
const result = isSupportedServer('5.0.0');
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false for unsupported server version', () => {
|
|
(isMinimumServerVersion as jest.Mock).mockReturnValue(false);
|
|
const result = isSupportedServer('4.0.0');
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('semverFromServerVersion', () => {
|
|
it('should return semver string for valid version', () => {
|
|
const result = semverFromServerVersion('5.0.0');
|
|
expect(result).toBe('5.0.0');
|
|
});
|
|
|
|
it('should return undefined for invalid version', () => {
|
|
const result = semverFromServerVersion('');
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('addNewServer', () => {
|
|
it('should call dismissBottomSheet and showModal', async () => {
|
|
const theme = Preferences.THEMES.denim;
|
|
const serverUrl = 'https://server.com';
|
|
const displayName = 'Server';
|
|
const deepLinkProps = {type: 'channel', url: 'https://mattermost.com'} as DeepLinkWithData;
|
|
|
|
await addNewServer(theme, serverUrl, displayName, deepLinkProps);
|
|
|
|
expect(navigateToScreen).toHaveBeenCalledWith(Screens.SERVER, expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe('loginOptions', () => {
|
|
it('should return correct login options', () => {
|
|
const config = {
|
|
EnableSaml: 'true',
|
|
EnableSignUpWithGitLab: 'true',
|
|
EnableSignUpWithGoogle: 'true',
|
|
EnableSignUpWithOffice365: 'true',
|
|
EnableSignUpWithOpenId: 'true',
|
|
EnableLdap: 'true',
|
|
EnableSignInWithEmail: 'true',
|
|
EnableSignInWithUsername: 'true',
|
|
Version: '5.0.0',
|
|
} as ClientConfig;
|
|
const license = {
|
|
IsLicensed: 'true',
|
|
SAML: 'true',
|
|
Office365OAuth: 'true',
|
|
LDAP: 'true',
|
|
} as ClientLicense;
|
|
|
|
const result = loginOptions(config, license);
|
|
|
|
expect(result).toEqual({
|
|
hasLoginForm: true,
|
|
ssoOptions: {
|
|
[Sso.SAML]: {enabled: true, text: undefined},
|
|
[Sso.GITLAB]: {enabled: true},
|
|
[Sso.GOOGLE]: {enabled: true},
|
|
[Sso.OFFICE365]: {enabled: true},
|
|
[Sso.OPENID]: {enabled: true, text: undefined},
|
|
},
|
|
enabledSSOs: [Sso.SAML, Sso.GITLAB, Sso.GOOGLE, Sso.OFFICE365, Sso.OPENID],
|
|
numberSSOs: 5,
|
|
});
|
|
});
|
|
|
|
it('should only count the enabled SSO providers', async () => {
|
|
jest.mocked(isMinimumServerVersion).mockReturnValue(true);
|
|
|
|
const config = {
|
|
EnableSaml: 'true',
|
|
EnableSignUpWithGitLab: 'false',
|
|
EnableSignUpWithGoogle: 'false',
|
|
EnableSignUpWithOffice365: 'false',
|
|
EnableSignUpWithOpenId: 'false',
|
|
EnableLdap: 'false',
|
|
EnableSignInWithEmail: 'false',
|
|
EnableSignInWithUsername: 'false',
|
|
Version: '5.0.0',
|
|
} as ClientConfig;
|
|
const license = {IsLicensed: 'true', SAML: 'true'} as ClientLicense;
|
|
|
|
const result = loginOptions(config, license);
|
|
|
|
expect(result.enabledSSOs).toEqual([Sso.SAML]);
|
|
expect(result.numberSSOs).toBe(1);
|
|
expect(result.hasLoginForm).toBe(false);
|
|
});
|
|
|
|
it('should report no SSO providers when none are enabled', async () => {
|
|
jest.mocked(isMinimumServerVersion).mockReturnValue(true);
|
|
|
|
const config = {
|
|
EnableSaml: 'false',
|
|
EnableSignUpWithGitLab: 'false',
|
|
EnableSignUpWithGoogle: 'false',
|
|
EnableSignUpWithOffice365: 'false',
|
|
EnableSignUpWithOpenId: 'false',
|
|
EnableLdap: 'false',
|
|
EnableSignInWithEmail: 'true',
|
|
EnableSignInWithUsername: 'false',
|
|
Version: '5.0.0',
|
|
} as ClientConfig;
|
|
|
|
const result = loginOptions(config, {IsLicensed: 'false'} as ClientLicense);
|
|
|
|
expect(result.enabledSSOs).toEqual([]);
|
|
expect(result.numberSSOs).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('loginToServer', () => {
|
|
const theme = Preferences.THEMES.denim;
|
|
const serverUrl = 'https://server.com';
|
|
const displayName = 'Server';
|
|
const config = {
|
|
EnableSaml: 'true',
|
|
EnableSignUpWithGitLab: 'false',
|
|
EnableSignUpWithGoogle: 'false',
|
|
EnableSignUpWithOffice365: 'false',
|
|
EnableSignUpWithOpenId: 'false',
|
|
EnableLdap: 'false',
|
|
EnableSignInWithEmail: 'true',
|
|
EnableSignInWithUsername: 'true',
|
|
Version: '5.0.0',
|
|
} as ClientConfig;
|
|
const license = {
|
|
IsLicensed: 'true',
|
|
SAML: 'true',
|
|
Office365OAuth: 'true',
|
|
LDAP: 'true',
|
|
} as ClientLicense;
|
|
|
|
it('should call dismissBottomSheet and showModal with LOGIN screen', async () => {
|
|
await loginToServer(theme, serverUrl, displayName, config, license);
|
|
|
|
expect(navigateToScreen).toHaveBeenCalledWith(Screens.LOGIN, expect.any(Object));
|
|
});
|
|
|
|
it('should navigate straight to SSO when there is no login form and a single provider', async () => {
|
|
const configWithSingleSSO = {...config, EnableSignInWithEmail: 'false', EnableSignInWithUsername: 'false'};
|
|
|
|
await loginToServer(theme, serverUrl, displayName, configWithSingleSSO, license);
|
|
|
|
expect(navigateToScreen).toHaveBeenCalledWith(
|
|
Screens.SSO,
|
|
expect.objectContaining({ssoType: Sso.SAML}),
|
|
);
|
|
});
|
|
|
|
it('should not serialize the pre-auth secret into navigation params', async () => {
|
|
await loginToServer(theme, serverUrl, displayName, config, license);
|
|
|
|
expect(navigateToScreen).toHaveBeenCalledWith(
|
|
Screens.LOGIN,
|
|
expect.not.objectContaining({serverPreauthSecret: expect.anything()}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('editServer', () => {
|
|
const theme = Preferences.THEMES.denim;
|
|
const server = {url: 'https://server.com', displayName: 'Server'} as ServersModel;
|
|
|
|
it('should call showModal with EDIT_SERVER screen', async () => {
|
|
await editServer(theme, server.id);
|
|
|
|
expect(navigateToScreen).toHaveBeenCalledWith(Screens.EDIT_SERVER, expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe('alertServerLogout', () => {
|
|
const intl = getIntlShape();
|
|
const displayName = 'Server';
|
|
const onPress = jest.fn();
|
|
|
|
it('should show an alert with the correct title and message', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
|
|
alertServerLogout(displayName, onPress, intl);
|
|
|
|
expect(alert).toHaveBeenCalledWith(
|
|
`Are you sure you want to log out of ${displayName}?`,
|
|
'All associated data will be removed',
|
|
expect.any(Array),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('alertServerRemove', () => {
|
|
const intl = getIntlShape();
|
|
const displayName = 'Server';
|
|
const onPress = jest.fn();
|
|
|
|
it('should show an alert with the correct title and message', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
alertServerRemove(displayName, onPress, intl);
|
|
|
|
expect(alert).toHaveBeenCalledWith(
|
|
`Are you sure you want to remove ${displayName}?`,
|
|
'This will remove it from your list of servers. All associated data will be removed',
|
|
expect.any(Array),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('alertServerError', () => {
|
|
const intl = getIntlShape();
|
|
const error = new Error('Test error');
|
|
|
|
it('should show an alert with the correct title and message', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
alertServerError(intl, error);
|
|
|
|
expect(alert).toHaveBeenCalledWith(
|
|
'Server is unreachable.',
|
|
'Test error',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('alertServerAlreadyConnected', () => {
|
|
const intl = getIntlShape();
|
|
|
|
it('should show an alert with the correct message', () => {
|
|
const alert = jest.spyOn(Alert, 'alert');
|
|
alertServerAlreadyConnected(intl);
|
|
|
|
expect(alert).toHaveBeenCalledWith(
|
|
'',
|
|
'You are already connected to this server.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('sortServersByDisplayName', () => {
|
|
const intl = getIntlShape();
|
|
const servers = [
|
|
{url: 'https://server1.com', displayName: 'Server 1'} as ServersModel,
|
|
{url: 'https://server2.com', displayName: 'Server 2'} as ServersModel,
|
|
{url: 'https://server3.com', displayName: 'https://server3.com'} as ServersModel,
|
|
];
|
|
|
|
it('should sort servers by display name', () => {
|
|
const sortedServers = sortServersByDisplayName(servers, intl);
|
|
|
|
expect(sortedServers[0].url).toBe('https://server3.com'); // Display name treated as 'Default Server'
|
|
expect(sortedServers[1].url).toBe('https://server1.com');
|
|
expect(sortedServers[2].url).toBe('https://server2.com');
|
|
});
|
|
});
|
|
});
|