mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 18:38:54 +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>
758 lines
29 KiB
TypeScript
758 lines
29 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 {
|
|
LEGACY_PREAUTH_SECRET_ACCOUNT,
|
|
getAllServerCredentials,
|
|
getLegacyPreauthSecret,
|
|
getPreauthSecret,
|
|
getServerCredentials,
|
|
setPreauthSecret,
|
|
setServerCredentials,
|
|
removeLegacyPreauthSecret,
|
|
removeServerCredentials,
|
|
removePreauthSecret,
|
|
clearCachedServerCredentials,
|
|
} 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';
|
|
const originalPlatform = Platform.OS;
|
|
const iosPreauthOptions = {service: mockServerUrl, accessGroup: 'group.com.mattermost.test'};
|
|
const androidPreauthOptions = {service: `preauth-secret::${mockServerUrl}`, accessGroup: undefined};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
clearCachedServerCredentials();
|
|
Platform.OS = 'ios';
|
|
jest.mocked(KeyChain.setInternetCredentials).mockResolvedValue({
|
|
service: mockServerUrl,
|
|
storage: 'keychain',
|
|
} as any);
|
|
jest.mocked(KeyChain.setGenericPassword).mockResolvedValue({
|
|
service: mockServerUrl,
|
|
storage: 'keychain',
|
|
} as any);
|
|
jest.mocked(KeyChain.resetGenericPassword).mockResolvedValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
Platform.OS = originalPlatform;
|
|
});
|
|
|
|
describe('setServerCredentials', () => {
|
|
it('should store the session token', async () => {
|
|
await setServerCredentials(mockServerUrl, mockToken);
|
|
|
|
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
mockToken,
|
|
mockToken,
|
|
expect.objectContaining({
|
|
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should never touch the pre-auth secret entry', async () => {
|
|
await setServerCredentials(mockServerUrl, mockToken);
|
|
|
|
expect(KeyChain.resetGenericPassword).not.toHaveBeenCalled();
|
|
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not store credentials when serverUrl is missing', async () => {
|
|
await setServerCredentials('', mockToken);
|
|
|
|
expect(KeyChain.setInternetCredentials).not.toHaveBeenCalled();
|
|
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not store credentials when token is missing', async () => {
|
|
await setServerCredentials(mockServerUrl, '');
|
|
|
|
expect(KeyChain.setInternetCredentials).not.toHaveBeenCalled();
|
|
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use iOS app group on iOS platform', async () => {
|
|
Platform.OS = 'ios';
|
|
|
|
await setServerCredentials(mockServerUrl, mockToken);
|
|
|
|
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
mockToken,
|
|
mockToken,
|
|
expect.objectContaining({
|
|
accessGroup: 'group.com.mattermost.test',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not use app group on Android platform', async () => {
|
|
Platform.OS = 'android';
|
|
|
|
await setServerCredentials(mockServerUrl, mockToken);
|
|
|
|
expect(KeyChain.setInternetCredentials).toHaveBeenCalledWith(
|
|
mockServerUrl,
|
|
mockToken,
|
|
mockToken,
|
|
expect.objectContaining({
|
|
accessGroup: undefined,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('setPreauthSecret', () => {
|
|
it('should store the secret keyed by server URL on iOS', async () => {
|
|
Platform.OS = 'ios';
|
|
|
|
await setPreauthSecret(mockServerUrl, mockPreauthSecret);
|
|
|
|
expect(KeyChain.setGenericPassword).toHaveBeenCalledWith(
|
|
'preauth_secret',
|
|
mockPreauthSecret,
|
|
{...iosPreauthOptions, securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE},
|
|
);
|
|
});
|
|
|
|
it('should prefix the service on Android so it cannot collide with the token alias', async () => {
|
|
Platform.OS = 'android';
|
|
|
|
await setPreauthSecret(mockServerUrl, mockPreauthSecret);
|
|
|
|
expect(KeyChain.setGenericPassword).toHaveBeenCalledWith(
|
|
'preauth_secret',
|
|
mockPreauthSecret,
|
|
{...androidPreauthOptions, securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE},
|
|
);
|
|
});
|
|
|
|
it('should keep each server secret separate', async () => {
|
|
await setPreauthSecret('https://a.com', 'secret-a');
|
|
await setPreauthSecret('https://b.com', 'secret-b');
|
|
|
|
expect(KeyChain.setGenericPassword).toHaveBeenNthCalledWith(
|
|
1, 'preauth_secret', 'secret-a', expect.objectContaining({service: 'https://a.com'}),
|
|
);
|
|
expect(KeyChain.setGenericPassword).toHaveBeenNthCalledWith(
|
|
2, 'preauth_secret', 'secret-b', expect.objectContaining({service: 'https://b.com'}),
|
|
);
|
|
});
|
|
|
|
it('should report true when the secret was stored', async () => {
|
|
await expect(setPreauthSecret(mockServerUrl, mockPreauthSecret)).resolves.toBe(true);
|
|
});
|
|
|
|
it('should report false when Keychain refuses the write without throwing', async () => {
|
|
jest.mocked(KeyChain.setGenericPassword).mockResolvedValue(false);
|
|
|
|
await expect(setPreauthSecret(mockServerUrl, mockPreauthSecret)).resolves.toBe(false);
|
|
});
|
|
|
|
it('should report false when the write throws', async () => {
|
|
jest.mocked(KeyChain.setGenericPassword).mockRejectedValue(new Error('Keystore error'));
|
|
|
|
await expect(setPreauthSecret(mockServerUrl, mockPreauthSecret)).resolves.toBe(false);
|
|
});
|
|
|
|
it('should not store an empty secret', async () => {
|
|
await expect(setPreauthSecret(mockServerUrl, '')).resolves.toBe(false);
|
|
|
|
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not store without a server URL', async () => {
|
|
await setPreauthSecret('', mockPreauthSecret);
|
|
|
|
expect(KeyChain.setGenericPassword).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getLegacyPreauthSecret', () => {
|
|
it('should return the password only when the account is the legacy preauth account', async () => {
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue({
|
|
username: 'preauth_secret',
|
|
password: 'should-not-adopt',
|
|
service: 'com.mattermost.rn',
|
|
storage: 'keychain' as any,
|
|
});
|
|
|
|
await expect(getLegacyPreauthSecret()).resolves.toBeUndefined();
|
|
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue({
|
|
username: LEGACY_PREAUTH_SECRET_ACCOUNT,
|
|
password: 'legacy-secret',
|
|
service: 'com.mattermost.rn',
|
|
storage: 'keychain' as any,
|
|
});
|
|
|
|
await expect(getLegacyPreauthSecret()).resolves.toBe('legacy-secret');
|
|
expect(KeyChain.getGenericPassword).toHaveBeenCalledWith({
|
|
accessGroup: 'group.com.mattermost.test',
|
|
});
|
|
});
|
|
|
|
it('should omit the access group on Android', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
|
|
|
|
await getLegacyPreauthSecret();
|
|
|
|
expect(KeyChain.getGenericPassword).toHaveBeenCalledWith({accessGroup: undefined});
|
|
});
|
|
|
|
it('should throw when the keychain read fails', async () => {
|
|
jest.mocked(KeyChain.getGenericPassword).mockRejectedValue(new Error('Keystore error'));
|
|
|
|
await expect(getLegacyPreauthSecret()).rejects.toThrow('Keystore error');
|
|
});
|
|
});
|
|
|
|
describe('removeLegacyPreauthSecret', () => {
|
|
it('should reset the legacy shared entry without a service', async () => {
|
|
await removeLegacyPreauthSecret();
|
|
|
|
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith({
|
|
accessGroup: 'group.com.mattermost.test',
|
|
});
|
|
});
|
|
|
|
it('should omit the access group on Android', async () => {
|
|
Platform.OS = 'android';
|
|
|
|
await removeLegacyPreauthSecret();
|
|
|
|
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith({accessGroup: undefined});
|
|
});
|
|
});
|
|
|
|
describe('getPreauthSecret', () => {
|
|
it('should read the secret keyed by server URL', async () => {
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue({
|
|
username: 'preauth_secret',
|
|
password: mockPreauthSecret,
|
|
service: mockServerUrl,
|
|
storage: 'keychain' as any,
|
|
});
|
|
|
|
await expect(getPreauthSecret(mockServerUrl)).resolves.toBe(mockPreauthSecret);
|
|
expect(KeyChain.getGenericPassword).toHaveBeenCalledWith(iosPreauthOptions);
|
|
});
|
|
|
|
it('should use the prefixed service on Android', async () => {
|
|
Platform.OS = 'android';
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
|
|
|
|
await getPreauthSecret(mockServerUrl);
|
|
|
|
expect(KeyChain.getGenericPassword).toHaveBeenCalledWith(androidPreauthOptions);
|
|
});
|
|
|
|
it('should return undefined when there is no secret', async () => {
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
|
|
|
|
await expect(getPreauthSecret(mockServerUrl)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined when the read fails', async () => {
|
|
jest.mocked(KeyChain.getGenericPassword).mockRejectedValue(new Error('Keystore error'));
|
|
|
|
await expect(getPreauthSecret(mockServerUrl)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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: 'preauth_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 the pre-auth secret for that server only', async () => {
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(true);
|
|
|
|
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith(iosPreauthOptions);
|
|
});
|
|
|
|
it('should use the prefixed service on Android', async () => {
|
|
Platform.OS = 'android';
|
|
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(true);
|
|
|
|
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith(androidPreauthOptions);
|
|
});
|
|
|
|
it('should report false when Keychain refuses the reset without throwing', async () => {
|
|
jest.mocked(KeyChain.resetGenericPassword).mockResolvedValue(false);
|
|
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(false);
|
|
});
|
|
|
|
it('should report false when the reset throws', async () => {
|
|
jest.mocked(KeyChain.resetGenericPassword).mockRejectedValue(new Error('Not found'));
|
|
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(false);
|
|
|
|
expect(KeyChain.resetGenericPassword).toHaveBeenCalledWith(iosPreauthOptions);
|
|
});
|
|
});
|
|
|
|
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 ignore pre-auth and legacy aliases when listing Android services', async () => {
|
|
Platform.OS = 'android';
|
|
|
|
jest.mocked(KeyChain.getAllGenericPasswordServices).mockResolvedValue([
|
|
'https://server1.com',
|
|
'preauth-secret::https://server1.com',
|
|
'',
|
|
]);
|
|
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(result[0].serverUrl).toBe('https://server1.com');
|
|
expect(KeyChain.getInternetCredentials).toHaveBeenCalledTimes(1);
|
|
expect(KeyChain.getInternetCredentials).toHaveBeenCalledWith('https://server1.com');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('should skip Android service listing when known URLs are provided', async () => {
|
|
Platform.OS = 'android';
|
|
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(['https://server1.com']);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].serverUrl).toBe('https://server1.com');
|
|
expect(KeyChain.getAllGenericPasswordServices).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fall back to listing when known URLs is empty', async () => {
|
|
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockResolvedValue([mockServerUrl]);
|
|
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
|
|
username: mockUserId,
|
|
password: mockToken,
|
|
service: mockServerUrl,
|
|
storage: 'keychain' as any,
|
|
});
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
|
|
|
|
const result = await getAllServerCredentials([]);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect((KeyChain as any).getAllInternetPasswordServers).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return the cache on a second call without listing or decrypting', async () => {
|
|
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockResolvedValue([mockServerUrl]);
|
|
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
|
|
username: mockUserId,
|
|
password: mockToken,
|
|
service: mockServerUrl,
|
|
storage: 'keychain' as any,
|
|
});
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(false);
|
|
|
|
const first = await getAllServerCredentials();
|
|
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockClear();
|
|
jest.mocked(KeyChain.getInternetCredentials).mockClear();
|
|
|
|
const second = await getAllServerCredentials();
|
|
const one = await getServerCredentials(mockServerUrl);
|
|
|
|
expect(second).toEqual(first);
|
|
expect(one).toEqual(first[0]);
|
|
expect((KeyChain as any).getAllInternetPasswordServers).not.toHaveBeenCalled();
|
|
expect(KeyChain.getInternetCredentials).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('credential cache', () => {
|
|
const loadCache = async (preauthSecret?: string) => {
|
|
jest.mocked((KeyChain as any).getAllInternetPasswordServers).mockResolvedValue([mockServerUrl]);
|
|
jest.mocked(KeyChain.getInternetCredentials).mockResolvedValue({
|
|
username: mockUserId,
|
|
password: mockToken,
|
|
service: mockServerUrl,
|
|
storage: 'keychain' as any,
|
|
});
|
|
jest.mocked(KeyChain.getGenericPassword).mockResolvedValue(preauthSecret ? {
|
|
username: 'preauth_secret',
|
|
password: preauthSecret,
|
|
service: mockServerUrl,
|
|
storage: 'keychain' as any,
|
|
} : false);
|
|
await getAllServerCredentials();
|
|
};
|
|
|
|
it('should upsert the cached credential after a successful write', async () => {
|
|
await loadCache();
|
|
await setServerCredentials(mockServerUrl, 'new-token');
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toEqual({
|
|
serverUrl: mockServerUrl,
|
|
userId: 'new-token',
|
|
token: 'new-token',
|
|
preauthSecret: undefined,
|
|
});
|
|
});
|
|
|
|
it('should preserve the cached pre-auth secret across a token write', async () => {
|
|
await loadCache(mockPreauthSecret);
|
|
await setServerCredentials(mockServerUrl, 'new-token');
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toEqual({
|
|
serverUrl: mockServerUrl,
|
|
userId: 'new-token',
|
|
token: 'new-token',
|
|
preauthSecret: mockPreauthSecret,
|
|
});
|
|
});
|
|
|
|
it('should update the cached secret after a successful secret write', async () => {
|
|
await loadCache();
|
|
await setPreauthSecret(mockServerUrl, mockPreauthSecret);
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toMatchObject({
|
|
token: mockToken,
|
|
preauthSecret: mockPreauthSecret,
|
|
});
|
|
});
|
|
|
|
it('should leave the cache unchanged when a Keychain write fails', async () => {
|
|
await loadCache();
|
|
jest.mocked(KeyChain.setInternetCredentials).mockResolvedValue(false);
|
|
|
|
await expect(setServerCredentials(mockServerUrl, 'new-token')).rejects.toThrow('failed to store credentials');
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toEqual({
|
|
serverUrl: mockServerUrl,
|
|
userId: mockUserId,
|
|
token: mockToken,
|
|
preauthSecret: undefined,
|
|
});
|
|
});
|
|
|
|
it('should leave the cached secret unchanged when the write is not stored', async () => {
|
|
await loadCache();
|
|
jest.mocked(KeyChain.setGenericPassword).mockResolvedValue(false);
|
|
|
|
await setPreauthSecret(mockServerUrl, mockPreauthSecret);
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toMatchObject({
|
|
preauthSecret: undefined,
|
|
});
|
|
});
|
|
|
|
it('should drop the cached credential on remove', async () => {
|
|
await loadCache();
|
|
jest.mocked(KeyChain.getInternetCredentials).mockClear();
|
|
|
|
await removeServerCredentials(mockServerUrl);
|
|
|
|
await expect(getAllServerCredentials()).resolves.toEqual([]);
|
|
expect(KeyChain.getInternetCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should clear the cached preauthSecret after a successful reset', async () => {
|
|
await loadCache(mockPreauthSecret);
|
|
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(true);
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toMatchObject({preauthSecret: undefined});
|
|
});
|
|
|
|
it('should keep the cached preauthSecret when the reset fails', async () => {
|
|
await loadCache(mockPreauthSecret);
|
|
jest.mocked(KeyChain.resetGenericPassword).mockRejectedValue(new Error('Keystore error'));
|
|
|
|
await expect(removePreauthSecret(mockServerUrl)).resolves.toBe(false);
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toMatchObject({preauthSecret: mockPreauthSecret});
|
|
});
|
|
|
|
it('should not let callers mutate cached credentials', async () => {
|
|
await loadCache();
|
|
const all = await getAllServerCredentials();
|
|
all[0].preauthSecret = 'mutated';
|
|
|
|
await expect(getServerCredentials(mockServerUrl)).resolves.toMatchObject({preauthSecret: undefined});
|
|
});
|
|
});
|
|
});
|