-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' of github.com:HubSpot/hubspot-cli into jy/pin-ver…
…sions-nuke-lockfiles
- Loading branch information
Showing
11 changed files
with
297 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
import express, { Request, Response } from 'express'; | ||
import open from 'open'; | ||
import { OAuth2Manager } from '@hubspot/local-dev-lib/models/OAuth2Manager'; | ||
import { getAccountConfig } from '@hubspot/local-dev-lib/config'; | ||
import { addOauthToAccountConfig } from '@hubspot/local-dev-lib/oauth'; | ||
import { logger } from '@hubspot/local-dev-lib/logger'; | ||
import { ENVIRONMENTS } from '@hubspot/local-dev-lib/constants/environments'; | ||
import { DEFAULT_OAUTH_SCOPES } from '@hubspot/local-dev-lib/constants/auth'; | ||
import { authenticateWithOauth } from '../oauth'; | ||
|
||
jest.mock('express'); | ||
jest.mock('open'); | ||
jest.mock('@hubspot/local-dev-lib/models/OAuth2Manager'); | ||
jest.mock('@hubspot/local-dev-lib/config'); | ||
jest.mock('@hubspot/local-dev-lib/oauth'); | ||
jest.mock('@hubspot/local-dev-lib/logger'); | ||
|
||
const mockedExpress = express as unknown as jest.Mock; | ||
const mockedOAuth2Manager = OAuth2Manager as unknown as jest.Mock; | ||
const mockedGetAccountConfig = getAccountConfig as jest.Mock; | ||
|
||
describe('lib/oauth', () => { | ||
const mockExpressReq = { | ||
query: { code: 'test-auth-code' }, | ||
} as unknown as Request; | ||
const mockExpressResp = { send: jest.fn() } as unknown as Response; | ||
|
||
const mockAccountConfig = { | ||
accountId: 123, | ||
clientId: 'test-client-id', | ||
clientSecret: 'test-client-secret', | ||
scopes: ['test-scope'], | ||
env: ENVIRONMENTS.PROD, | ||
}; | ||
|
||
beforeEach(() => { | ||
mockedExpress.mockReturnValue({ | ||
get: jest.fn().mockImplementation((path, callback) => { | ||
if (path === '/oauth-callback') { | ||
callback(mockExpressReq, mockExpressResp); | ||
} | ||
}), | ||
listen: jest.fn().mockReturnValue({ close: jest.fn() }), | ||
}); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
describe('authenticateWithOauth()', () => { | ||
it('should setup OAuth and authenticate successfully', async () => { | ||
// Mock successful OAuth flow | ||
const mockOAuth2Manager = { | ||
account: mockAccountConfig, | ||
exchangeForTokens: jest.fn().mockResolvedValue({}), | ||
}; | ||
|
||
mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager); | ||
mockedGetAccountConfig.mockReturnValue({ | ||
env: ENVIRONMENTS.PROD, | ||
}); | ||
|
||
await authenticateWithOauth(mockAccountConfig); | ||
|
||
// Verify OAuth2Manager was initialized correctly | ||
expect(mockedOAuth2Manager).toHaveBeenCalledWith({ | ||
...mockAccountConfig, | ||
env: ENVIRONMENTS.PROD, | ||
}); | ||
|
||
// Verify logger was called | ||
expect(logger.log).toHaveBeenCalledWith('Authorizing'); | ||
|
||
// Verify OAuth tokens were added to config | ||
expect(addOauthToAccountConfig).toHaveBeenCalledWith(mockOAuth2Manager); | ||
}); | ||
|
||
it('should handle missing clientId', async () => { | ||
const invalidConfig = { | ||
...mockAccountConfig, | ||
clientId: undefined, | ||
}; | ||
|
||
mockedOAuth2Manager.mockImplementation(() => ({ | ||
account: invalidConfig, | ||
})); | ||
|
||
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { | ||
throw new Error('exit'); | ||
}); | ||
|
||
await expect(authenticateWithOauth(invalidConfig)).rejects.toThrow( | ||
'exit' | ||
); | ||
expect(logger.error).toHaveBeenCalled(); | ||
expect(exitSpy).toHaveBeenCalled(); | ||
exitSpy.mockRestore(); | ||
}); | ||
|
||
it('should use default scopes when none provided', async () => { | ||
const configWithoutScopes = { | ||
...mockAccountConfig, | ||
scopes: undefined, | ||
}; | ||
|
||
const mockOAuth2Manager = { | ||
account: configWithoutScopes, | ||
exchangeForTokens: jest.fn().mockResolvedValue({}), | ||
}; | ||
|
||
mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager); | ||
|
||
await authenticateWithOauth(configWithoutScopes); | ||
|
||
// Verify default scopes were used | ||
expect(open).toHaveBeenCalledWith( | ||
expect.stringContaining( | ||
encodeURIComponent(DEFAULT_OAUTH_SCOPES.join(' ')) | ||
), | ||
expect.anything() | ||
); | ||
}); | ||
|
||
it('should handle OAuth exchange failure', async () => { | ||
const mockOAuth2Manager = { | ||
account: mockAccountConfig, | ||
exchangeForTokens: jest | ||
.fn() | ||
.mockRejectedValue(new Error('Exchange failed')), | ||
}; | ||
|
||
mockedOAuth2Manager.mockImplementation(() => mockOAuth2Manager); | ||
|
||
await authenticateWithOauth(mockAccountConfig); | ||
|
||
expect(mockExpressResp.send).toHaveBeenCalledWith( | ||
expect.stringContaining('Authorization failed') | ||
); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import { poll, DEFAULT_POLLING_STATES } from '../polling'; | ||
import { DEFAULT_POLLING_DELAY } from '../constants'; | ||
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; | ||
|
||
// Mock response types | ||
type MockResponse = { | ||
status: string; | ||
}; | ||
|
||
// Helper to create a mock polling callback | ||
const createMockCallback = (responses: MockResponse[]) => { | ||
let callCount = 0; | ||
return jest.fn((): HubSpotPromise<{ status: string }> => { | ||
const response = responses[callCount]; | ||
callCount++; | ||
return Promise.resolve({ data: response }) as HubSpotPromise<{ | ||
status: string; | ||
}>; | ||
}); | ||
}; | ||
|
||
describe('lib/polling', () => { | ||
beforeEach(() => { | ||
jest.useFakeTimers(); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.useRealTimers(); | ||
}); | ||
|
||
describe('poll()', () => { | ||
it('should resolve when status is SUCCESS', async () => { | ||
const mockCallback = createMockCallback([ | ||
{ status: DEFAULT_POLLING_STATES.STARTED }, | ||
{ status: DEFAULT_POLLING_STATES.SUCCESS }, | ||
]); | ||
|
||
const pollPromise = poll(mockCallback); | ||
|
||
// Fast-forward through two polling intervals | ||
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2); | ||
|
||
const result = await pollPromise; | ||
expect(result.status).toBe(DEFAULT_POLLING_STATES.SUCCESS); | ||
expect(mockCallback).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should reject when status is ERROR', async () => { | ||
const mockCallback = createMockCallback([ | ||
{ status: DEFAULT_POLLING_STATES.STARTED }, | ||
{ status: DEFAULT_POLLING_STATES.ERROR }, | ||
]); | ||
|
||
const pollPromise = poll(mockCallback); | ||
|
||
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2); | ||
|
||
await expect(pollPromise).rejects.toEqual({ | ||
status: DEFAULT_POLLING_STATES.ERROR, | ||
}); | ||
expect(mockCallback).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should reject when status is FAILURE', async () => { | ||
const mockCallback = createMockCallback([ | ||
{ status: DEFAULT_POLLING_STATES.STARTED }, | ||
{ status: DEFAULT_POLLING_STATES.FAILURE }, | ||
]); | ||
|
||
const pollPromise = poll(mockCallback); | ||
|
||
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2); | ||
|
||
await expect(pollPromise).rejects.toEqual({ | ||
status: DEFAULT_POLLING_STATES.FAILURE, | ||
}); | ||
}); | ||
|
||
it('should reject when status is REVERTED', async () => { | ||
const mockCallback = createMockCallback([ | ||
{ status: DEFAULT_POLLING_STATES.STARTED }, | ||
{ status: DEFAULT_POLLING_STATES.REVERTED }, | ||
]); | ||
|
||
const pollPromise = poll(mockCallback); | ||
|
||
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY * 2); | ||
|
||
await expect(pollPromise).rejects.toEqual({ | ||
status: DEFAULT_POLLING_STATES.REVERTED, | ||
}); | ||
}); | ||
|
||
it('should reject when callback throws an error', async () => { | ||
const mockCallback = jest | ||
.fn() | ||
.mockRejectedValue(new Error('Network error')); | ||
|
||
const pollPromise = poll(mockCallback); | ||
|
||
jest.advanceTimersByTime(DEFAULT_POLLING_DELAY); | ||
|
||
await expect(pollPromise).rejects.toThrow('Network error'); | ||
expect(mockCallback).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,42 +1,54 @@ | ||
import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; | ||
import { ValueOf } from '@hubspot/local-dev-lib/types/Utils'; | ||
import { POLLING_DELAY, POLLING_STATUS } from './constants'; | ||
import { DEFAULT_POLLING_DELAY } from './constants'; | ||
|
||
export const DEFAULT_POLLING_STATES = { | ||
STARTED: 'STARTED', | ||
SUCCESS: 'SUCCESS', | ||
ERROR: 'ERROR', | ||
REVERTED: 'REVERTED', | ||
FAILURE: 'FAILURE', | ||
} as const; | ||
|
||
const DEFAULT_POLLING_STATUS_LOOKUP = { | ||
successStates: [DEFAULT_POLLING_STATES.SUCCESS], | ||
errorStates: [ | ||
DEFAULT_POLLING_STATES.ERROR, | ||
DEFAULT_POLLING_STATES.REVERTED, | ||
DEFAULT_POLLING_STATES.FAILURE, | ||
], | ||
}; | ||
|
||
type GenericPollingResponse = { | ||
status: ValueOf<typeof POLLING_STATUS>; | ||
status: string; | ||
}; | ||
|
||
type PollingCallback<T extends GenericPollingResponse> = ( | ||
accountId: number, | ||
taskId: number | string | ||
) => HubSpotPromise<T>; | ||
type PollingCallback<T extends GenericPollingResponse> = | ||
() => HubSpotPromise<T>; | ||
|
||
export function poll<T extends GenericPollingResponse>( | ||
callback: PollingCallback<T>, | ||
accountId: number, | ||
taskId: number | string | ||
statusLookup: { | ||
successStates: string[]; | ||
errorStates: string[]; | ||
} = DEFAULT_POLLING_STATUS_LOOKUP | ||
): Promise<T> { | ||
return new Promise((resolve, reject) => { | ||
const pollInterval = setInterval(async () => { | ||
try { | ||
const { data: pollResp } = await callback(accountId, taskId); | ||
const { data: pollResp } = await callback(); | ||
const { status } = pollResp; | ||
|
||
if (status === POLLING_STATUS.SUCCESS) { | ||
if (statusLookup.successStates.includes(status)) { | ||
clearInterval(pollInterval); | ||
resolve(pollResp); | ||
} else if ( | ||
status === POLLING_STATUS.ERROR || | ||
status === POLLING_STATUS.REVERTED || | ||
status === POLLING_STATUS.FAILURE | ||
) { | ||
} else if (statusLookup.errorStates.includes(status)) { | ||
clearInterval(pollInterval); | ||
reject(pollResp); | ||
} | ||
} catch (error) { | ||
clearInterval(pollInterval); | ||
reject(error); | ||
} | ||
}, POLLING_DELAY); | ||
}, DEFAULT_POLLING_DELAY); | ||
}); | ||
} |
Oops, something went wrong.