Fix 2fa auth and token format migration (#13523)
Fix the 2FA setup and also make some changes so that the transition towards a new token format introduced in a previous PR happens more smoothly
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { getTokenPair } from '../getTokenPair';
|
||||
|
||||
jest.mock('~/utils/cookie-storage', () => ({
|
||||
cookieStorage: {
|
||||
getItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCookieStorage = cookieStorage as jest.Mocked<typeof cookieStorage>;
|
||||
|
||||
describe('getTokenPair', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when tokenPair cookie does not exist', () => {
|
||||
it('should return undefined when cookie is not set', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue(undefined);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should return undefined when cookie is undefined', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue(undefined);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tokenPair cookie has invalid JSON', () => {
|
||||
it('should remove cookie and return undefined for malformed JSON', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue('invalid-json');
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined for partial JSON', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue('{"incomplete":');
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tokenPair cookie has invalid structure', () => {
|
||||
it('should remove cookie and return undefined when tokenPair is null', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue('null');
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined when tokenPair is not an object', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue('"string-value"');
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is missing', () => {
|
||||
const invalidTokenPair = {
|
||||
refreshToken: { token: 'refresh-token' },
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(
|
||||
JSON.stringify(invalidTokenPair),
|
||||
);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => {
|
||||
const invalidTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: 'not-an-object',
|
||||
refreshToken: { token: 'refresh-token' },
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(
|
||||
JSON.stringify(invalidTokenPair),
|
||||
);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined when token is missing', () => {
|
||||
const invalidTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: { token: 'refresh-token' },
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(
|
||||
JSON.stringify(invalidTokenPair),
|
||||
);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should remove cookie and return undefined when token is not a string', () => {
|
||||
const invalidTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 123,
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: { token: 'refresh-token' },
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(
|
||||
JSON.stringify(invalidTokenPair),
|
||||
);
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
});
|
||||
|
||||
it('should accept empty string token as valid', () => {
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: '',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: { token: 'refresh-token' },
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tokenPair cookie has valid structure', () => {
|
||||
it('should return valid tokenPair with all required fields', () => {
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'valid-access-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'valid-refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return valid tokenPair with minimal required fields', () => {
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'minimal-access-token',
|
||||
},
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return valid tokenPair with extra fields', () => {
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'access-token-with-extras',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
extraField: 'extra-value',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
},
|
||||
additionalField: 'additional-value',
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(mockCookieStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle JSON parsing error gracefully', () => {
|
||||
mockCookieStorage.getItem.mockReturnValue('{"valid": "json"');
|
||||
// Simulate JSON.parse throwing an error
|
||||
const originalParse = JSON.parse;
|
||||
JSON.parse = jest.fn(() => {
|
||||
throw new SyntaxError('Unexpected end of JSON input');
|
||||
});
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair');
|
||||
|
||||
// Restore original JSON.parse
|
||||
JSON.parse = originalParse;
|
||||
});
|
||||
|
||||
it('should handle very long token strings', () => {
|
||||
const longToken = 'a'.repeat(10000);
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: longToken,
|
||||
},
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(result?.accessOrWorkspaceAgnosticToken.token).toHaveLength(10000);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in token', () => {
|
||||
const unicodeToken = 'token-with-unicode-🚀-characters-∑';
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: unicodeToken,
|
||||
},
|
||||
};
|
||||
mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair));
|
||||
|
||||
const result = getTokenPair();
|
||||
|
||||
expect(result).toEqual(validTokenPair);
|
||||
expect(result?.accessOrWorkspaceAgnosticToken.token).toBe(unicodeToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { getTokenPair } from '../getTokenPair';
|
||||
import { hasTokenPair } from '../hasTokenPair';
|
||||
|
||||
jest.mock('../getTokenPair');
|
||||
|
||||
const mockGetTokenPair = getTokenPair as jest.MockedFunction<
|
||||
typeof getTokenPair
|
||||
>;
|
||||
|
||||
describe('hasTokenPair', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when getTokenPair returns a valid token pair', () => {
|
||||
it('should return true for a complete valid token pair', () => {
|
||||
const validTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'valid-access-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'valid-refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(validTokenPair);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return true for a minimal valid token pair', () => {
|
||||
const minimalTokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'minimal-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(minimalTokenPair);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return true even if token pair has extra fields', () => {
|
||||
const tokenPairWithExtras = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'access-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
customField: 'custom-value',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
extraField: 'extra-value',
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(tokenPairWithExtras);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getTokenPair returns undefined', () => {
|
||||
it('should return false when no token pair exists', () => {
|
||||
mockGetTokenPair.mockReturnValue(undefined);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation behavior', () => {
|
||||
it('should rely on getTokenPair for all validation logic', () => {
|
||||
// Test that hasTokenPair doesn't do its own validation
|
||||
// and trusts getTokenPair's validation completely
|
||||
mockGetTokenPair.mockReturnValue(undefined);
|
||||
|
||||
hasTokenPair();
|
||||
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset and test with valid token
|
||||
mockGetTokenPair.mockClear();
|
||||
const validToken = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'valid-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(validToken);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not perform additional validation beyond getTokenPair', () => {
|
||||
// This test ensures hasTokenPair doesn't duplicate validation logic
|
||||
const tokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'any-token-that-getTokenPair-considers-valid',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(tokenPair);
|
||||
|
||||
const result = hasTokenPair();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should only call getTokenPair once, no additional validation
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple calls', () => {
|
||||
it('should call getTokenPair on each invocation', () => {
|
||||
const tokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'test-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(tokenPair);
|
||||
|
||||
hasTokenPair();
|
||||
hasTokenPair();
|
||||
hasTokenPair();
|
||||
|
||||
expect(mockGetTokenPair).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should return consistent results for consistent getTokenPair results', () => {
|
||||
const tokenPair = {
|
||||
accessOrWorkspaceAgnosticToken: {
|
||||
token: 'consistent-token',
|
||||
expiresAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
refreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
mockGetTokenPair.mockReturnValue(tokenPair);
|
||||
|
||||
const result1 = hasTokenPair();
|
||||
const result2 = hasTokenPair();
|
||||
const result3 = hasTokenPair();
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true);
|
||||
expect(result3).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,35 @@
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AuthTokenPair } from '~/generated/graphql';
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
|
||||
export const getTokenPair = () => {
|
||||
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
||||
return isDefined(stringTokenPair)
|
||||
? (JSON.parse(stringTokenPair) as AuthTokenPair)
|
||||
: undefined;
|
||||
const isValidAuthTokenPair = (tokenPair: any): tokenPair is AuthTokenPair => {
|
||||
return (
|
||||
tokenPair &&
|
||||
typeof tokenPair === 'object' &&
|
||||
tokenPair.accessOrWorkspaceAgnosticToken &&
|
||||
typeof tokenPair.accessOrWorkspaceAgnosticToken === 'object' &&
|
||||
typeof tokenPair.accessOrWorkspaceAgnosticToken.token === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
export const getTokenPair = (): AuthTokenPair | undefined => {
|
||||
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
||||
|
||||
if (!isDefined(stringTokenPair)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedTokenPair = JSON.parse(stringTokenPair);
|
||||
|
||||
if (!isValidAuthTokenPair(parsedTokenPair)) {
|
||||
cookieStorage.removeItem('tokenPair');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsedTokenPair;
|
||||
} catch (error) {
|
||||
cookieStorage.removeItem('tokenPair');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,8 +3,5 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const hasTokenPair = () => {
|
||||
const tokenPair = getTokenPair();
|
||||
return (
|
||||
isDefined(tokenPair) &&
|
||||
isDefined(tokenPair.accessOrWorkspaceAgnosticToken?.token)
|
||||
);
|
||||
return isDefined(tokenPair);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user