mattermost-mobile/app/init/credentials.test.ts
Felipe Martin fbaf13c7aa
fix: prevent removal of pre-auth secret on logout (#9411)
* fix: prevent removal of pre-auth secret on logout

* fix: use pre-auth secret when reconnecting to server after logout

* test: verify pre-auth secret usage in switchToServerAndLogin
2026-01-27 15:52:37 +01:00

390 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import * as KeyChain from 'react-native-keychain';
import {
getAllServerCredentials,
getServerCredentials,
setServerCredentials,
removeServerCredentials,
removePreauthSecret,
} from './credentials';
jest.mock('react-native-keychain', () => ({
SECURITY_LEVEL: {
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
},
STORAGE_TYPE: {
FB: 'FB',
AES: 'AES',
RSA: 'RSA',
},
setInternetCredentials: jest.fn(),
getInternetCredentials: jest.fn(),
resetInternetCredentials: jest.fn(),
setGenericPassword: jest.fn(),
getGenericPassword: jest.fn(),
resetGenericPassword: jest.fn(),
getAllInternetPasswordServers: jest.fn(),
getAllGenericPasswordServices: jest.fn(),
}));
jest.mock('@utils/log');
jest.mock('@utils/mattermost_managed', () => ({
getIOSAppGroupDetails: jest.fn().mockReturnValue({
appGroupIdentifier: 'group.com.mattermost.test',
}),
}));
jest.mock('@database/manager', () => ({
getActiveServerUrl: jest.fn().mockResolvedValue('https://example.com'),
serverDatabases: {},
}));
describe('credentials', () => {
const mockServerUrl = 'https://example.com';
const mockToken = 'test-token-123';
const mockUserId = 'user-id-123';
const mockPreauthSecret = 'preauth-secret-123';
beforeEach(() => {
jest.clearAllMocks();
});
describe('setServerCredentials', () => {
it('should store credentials with pre-auth secret', () => {
setServerCredentials(mockServerUrl, mockToken, mockPreauthSecret);
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
mockServerUrl,
mockToken,
mockToken,
expect.objectContaining({
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
}),
);
expect(KeyChain.setGenericPassword).toHaveBeenCalledWith(
'preshared_secret',
mockPreauthSecret,
expect.objectContaining({
server: mockServerUrl,
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
}),
);
});
it('should store credentials without pre-auth secret', () => {
setServerCredentials(mockServerUrl, mockToken);
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
mockServerUrl,
mockToken,
mockToken,
expect.any(Object),
);
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith(
expect.objectContaining({
server: mockServerUrl,
}),
);
});
it('should not store credentials when serverUrl is missing', () => {
setServerCredentials('', mockToken, mockPreauthSecret);
expect(KeyChain.setInternetCredentials).not.toHaveBeenCalled();
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
});
it('should not store credentials when token is missing', () => {
setServerCredentials(mockServerUrl, '', mockPreauthSecret);
expect(KeyChain.setInternetCredentials).not.toHaveBeenCalled();
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
});
it('should use iOS app group on iOS platform', () => {
Platform.OS = 'ios';
setServerCredentials(mockServerUrl, mockToken, mockPreauthSecret);
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
mockServerUrl,
mockToken,
mockToken,
expect.objectContaining({
accessGroup: 'group.com.mattermost.test',
}),
);
});
it('should not use app group on Android platform', () => {
Platform.OS = 'android';
setServerCredentials(mockServerUrl, mockToken, mockPreauthSecret);
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
mockServerUrl,
mockToken,
mockToken,
expect.objectContaining({
accessGroup: undefined,
}),
);
});
});
describe('getServerCredentials', () => {
it('should retrieve credentials with pre-auth secret', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: mockUserId,
password: mockToken,
service: mockServerUrl,
storage: 'keychain' as any,
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue({
username: 'preshared_secret',
password: mockPreauthSecret,
service: mockServerUrl,
storage: 'keychain' as any,
});
const result = await getServerCredentials(mockServerUrl);
expect(result).toEqual({
serverUrl: mockServerUrl,
userId: mockUserId,
token: mockToken,
preauthSecret: mockPreauthSecret,
});
});
it('should retrieve credentials without pre-auth secret', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: mockUserId,
password: mockToken,
service: mockServerUrl,
storage: 'keychain' as any,
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
const result = await getServerCredentials(mockServerUrl);
expect(result).toEqual({
serverUrl: mockServerUrl,
userId: mockUserId,
token: mockToken,
preauthSecret: undefined,
});
});
it('should handle legacy token format with device token', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: 'device-token-123,user-id-456',
password: mockToken,
service: mockServerUrl,
storage: 'keychain' as any,
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
const result = await getServerCredentials(mockServerUrl);
expect(result).toEqual({
serverUrl: mockServerUrl,
userId: 'user-id-456',
token: mockToken,
preauthSecret: undefined,
});
});
it('should return null when credentials do not exist', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue(false);
const result = await getServerCredentials(mockServerUrl);
expect(result).toBeNull();
});
it('should return null when token is undefined', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: mockUserId,
password: 'undefined',
service: mockServerUrl,
storage: 'keychain' as any,
});
const result = await getServerCredentials(mockServerUrl);
expect(result).toBeNull();
});
it('should gracefully handle errors when retrieving pre-auth secret', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: mockUserId,
password: mockToken,
service: mockServerUrl,
storage: 'keychain' as any,
});
jest.mocked(KeyChain.getGenericPassword).mockRejectedValue(new Error('Keychain error'));
const result = await getServerCredentials(mockServerUrl);
expect(result).toEqual({
serverUrl: mockServerUrl,
userId: mockUserId,
token: mockToken,
preauthSecret: undefined,
});
});
it('should return null on error retrieving main credentials', async () => {
jest.mocked(KeyChain.getInternetCredentials).mockRejectedValue(new Error('Keychain error'));
const result = await getServerCredentials(mockServerUrl);
expect(result).toBeNull();
});
});
describe('removeServerCredentials', () => {
it('should remove internet credentials only', async () => {
await removeServerCredentials(mockServerUrl);
expect(KeyChain.resetInternetCredentials).toHaveBeenCalledWith({
server: mockServerUrl,
});
// Should NOT remove pre-auth secret
expect(KeyChain.resetGenericPassword).not.toHaveBeenCalled();
});
});
describe('removePreauthSecret', () => {
it('should remove pre-auth secret', async () => {
await removePreauthSecret(mockServerUrl);
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith({
server: mockServerUrl,
});
});
it('should gracefully handle errors when pre-auth secret does not exist', async () => {
jest.mocked(KeyChain.resetGenericPassword).mockRejectedValue(new Error('Not found'));
await expect(removePreauthSecret(mockServerUrl)).resolves.not.toThrow();
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith({
server: mockServerUrl,
});
});
});
describe('getAllServerCredentials', () => {
beforeEach(() => {
Platform.OS = 'ios';
});
it('should retrieve all server credentials on iOS', async () => {
const serverUrls = [
'https://server1.com',
'https://server2.com',
];
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockResolvedValue(serverUrls);
jest.mocked(KeyChain.getInternetCredentials).mockImplementation(async (url) => {
if (url === 'https://server1.com') {
return {
username: 'user1',
password: 'token1',
service: url,
storage: 'keychain' as any,
};
} else if (url === 'https://server2.com') {
return {
username: 'user2',
password: 'token2',
service: url,
storage: 'keychain' as any,
};
}
return false;
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
const result = await getAllServerCredentials();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
serverUrl: 'https://server1.com',
userId: 'user1',
token: 'token1',
preauthSecret: undefined,
});
expect(result[1]).toEqual({
serverUrl: 'https://server2.com',
userId: 'user2',
token: 'token2',
preauthSecret: undefined,
});
});
it('should retrieve all server credentials on Android', async () => {
Platform.OS = 'android';
const serverUrls = ['https://server1.com'];
jest.mocked(KeyChain.getAllGenericPasswordServices).mockResolvedValue(serverUrls);
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
username: 'user1',
password: 'token1',
service: 'https://server1.com',
storage: 'keychain' as any,
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
const result = await getAllServerCredentials();
expect(result).toHaveLength(1);
expect(KeyChain.getAllGenericPasswordServices).toHaveBeenCalled();
expect((KeyChain as any).getAllInternetPasswordServers).not.toHaveBeenCalled();
});
it('should filter out null credentials', async () => {
const serverUrls = [
'https://server1.com',
'https://server2.com',
];
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockResolvedValue(serverUrls);
jest.mocked(KeyChain.getInternetCredentials).mockImplementation(async (url) => {
if (url === 'https://server1.com') {
return {
username: 'user1',
password: 'token1',
service: url,
storage: 'keychain' as any,
};
}
return false;
});
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
const result = await getAllServerCredentials();
expect(result).toHaveLength(1);
expect(result[0].serverUrl).toBe('https://server1.com');
});
});
});