diff --git a/commands/function/deploy.ts b/commands/function/deploy.ts index 11c2f1f92..f2afea049 100644 --- a/commands/function/deploy.ts +++ b/commands/function/deploy.ts @@ -61,7 +61,9 @@ exports.handler = async options => { derivedAccountId, functionPath ); - const successResp = await poll(getBuildStatus, derivedAccountId, buildId); + const successResp = await poll(() => + getBuildStatus(derivedAccountId, buildId) + ); const buildTimeSeconds = (successResp.buildTime / 1000).toFixed(2); SpinniesManager.succeed('loading'); diff --git a/commands/project/cloneApp.ts b/commands/project/cloneApp.ts index 5a8ec9535..8a027dd3a 100644 --- a/commands/project/cloneApp.ts +++ b/commands/project/cloneApp.ts @@ -104,7 +104,9 @@ exports.handler = async options => { const { data: { exportId }, } = await cloneApp(derivedAccountId, appId); - const { status } = await poll(checkCloneStatus, derivedAccountId, exportId); + const { status } = await poll(() => + checkCloneStatus(derivedAccountId, exportId) + ); if (status === 'SUCCESS') { // Ensure correct project folder structure exists const baseDestPath = path.resolve(getCwd(), projectDest); diff --git a/commands/project/migrateApp.ts b/commands/project/migrateApp.ts index 85b893d18..1380a5878 100644 --- a/commands/project/migrateApp.ts +++ b/commands/project/migrateApp.ts @@ -189,7 +189,9 @@ exports.handler = async options => { projectName ); const { id } = migrateResponse; - const pollResponse = await poll(checkMigrationStatus, derivedAccountId, id); + const pollResponse = await poll(() => + checkMigrationStatus(derivedAccountId, id) + ); const { status, project } = pollResponse; if (status === 'SUCCESS') { const absoluteDestPath = path.resolve(getCwd(), projectDest); diff --git a/lang/en.lyaml b/lang/en.lyaml index 7f07353a8..0ba9cc830 100644 --- a/lang/en.lyaml +++ b/lang/en.lyaml @@ -1113,7 +1113,6 @@ en: notFound: "Your project {{#bold}}{{ projectName }}{{/bold}} could not be found in {{#bold}}{{ accountIdentifier }}{{/bold}}." pollFetchProject: checkingProject: "Checking if project exists in {{ accountIdentifier }}" - unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}." logFeedbackMessage: feedbackHeader: "We'd love to hear your feedback!" feedbackMessage: "How are you liking the new projects and developer tools? \n > Run `{{#yellow}}hs feedback{{/yellow}}` to let us know what you think!\n" @@ -1129,6 +1128,7 @@ en: buildSucceededAutomaticallyDeploying: "Build #{{ buildId }} succeeded. {{#bold}}Automatically deploying{{/bold}} to {{ accountIdentifier }}\n" cleanedUpTempFile: "Cleaned up temporary file {{ path }}" viewDeploys: "View all deploys for this project in HubSpot" + unableToFindAutodeployStatus: "Unable to find the auto deploy for build #{{ buildId }}. This deploy may have been skipped. {{ viewDeploysLink }}." projectUpload: uploadProjectFiles: add: "Uploading {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}" diff --git a/lib/__tests__/oauth.test.ts b/lib/__tests__/oauth.test.ts new file mode 100644 index 000000000..2de71816e --- /dev/null +++ b/lib/__tests__/oauth.test.ts @@ -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') + ); + }); + }); +}); diff --git a/lib/__tests__/polling.test.ts b/lib/__tests__/polling.test.ts new file mode 100644 index 000000000..45afc1a68 --- /dev/null +++ b/lib/__tests__/polling.test.ts @@ -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); + }); + }); +}); diff --git a/lib/constants.ts b/lib/constants.ts index 94c30f6ad..993c6364b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -11,14 +11,7 @@ export const CONFIG_FLAGS = { USE_CUSTOM_OBJECT_HUBFILE: 'useCustomObjectHubfile', } as const; -export const POLLING_DELAY = 2000; - -export const POLLING_STATUS = { - SUCCESS: 'SUCCESS', - ERROR: 'ERROR', - REVERTED: 'REVERTED', - FAILURE: 'FAILURE', -} as const; +export const DEFAULT_POLLING_DELAY = 2000; export const PROJECT_CONFIG_FILE = 'hsproject.json' as const; diff --git a/lib/polling.ts b/lib/polling.ts index baced84f2..3c3a91d78 100644 --- a/lib/polling.ts +++ b/lib/polling.ts @@ -1,35 +1,47 @@ 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); } @@ -37,6 +49,6 @@ export function poll<T extends GenericPollingResponse>( clearInterval(pollInterval); reject(error); } - }, POLLING_DELAY); + }, DEFAULT_POLLING_DELAY); }); } diff --git a/lib/projects/buildAndDeploy.ts b/lib/projects/buildAndDeploy.ts index 83f2c5d63..b9841daff 100644 --- a/lib/projects/buildAndDeploy.ts +++ b/lib/projects/buildAndDeploy.ts @@ -16,7 +16,7 @@ import { import { WarnLogsResponse } from '@hubspot/local-dev-lib/types/Project'; import { - POLLING_DELAY, + DEFAULT_POLLING_DELAY, PROJECT_BUILD_TEXT, PROJECT_DEPLOY_TEXT, PROJECT_TASK_TYPES, @@ -366,7 +366,7 @@ function makePollTaskStatusFunc<T extends ProjectTask>({ resolve(taskStatus); } } - }, POLLING_DELAY); + }, DEFAULT_POLLING_DELAY); }); }; } @@ -377,7 +377,7 @@ function pollBuildAutodeployStatus( buildId: number ): Promise<Build> { return new Promise((resolve, reject) => { - let maxIntervals = (30 * 1000) / POLLING_DELAY; // Num of intervals in ~30s + let maxIntervals = (30 * 1000) / DEFAULT_POLLING_DELAY; // Num of intervals in ~30s const pollInterval = setInterval(async () => { let build: Build; @@ -407,7 +407,7 @@ function pollBuildAutodeployStatus( } else { maxIntervals -= 1; } - }, POLLING_DELAY); + }, DEFAULT_POLLING_DELAY); }); } diff --git a/lib/projects/index.ts b/lib/projects/index.ts index 963ca14ae..b73bf1954 100644 --- a/lib/projects/index.ts +++ b/lib/projects/index.ts @@ -18,7 +18,7 @@ import { HubSpotPromise } from '@hubspot/local-dev-lib/types/Http'; import { FEEDBACK_INTERVAL, - POLLING_DELAY, + DEFAULT_POLLING_DELAY, PROJECT_CONFIG_FILE, HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, PROJECT_COMPONENT_TYPES, @@ -242,7 +242,7 @@ async function pollFetchProject( reject(err); } } - }, POLLING_DELAY); + }, DEFAULT_POLLING_DELAY); }); } diff --git a/package.json b/package.json index ffe9f9ce4..c43f20cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hubspot/cli", - "version": "7.0.1", + "version": "7.0.2", "description": "The official CLI for developing on HubSpot", "license": "Apache-2.0", "repository": "https://github.com/HubSpot/hubspot-cli", @@ -8,7 +8,7 @@ "@hubspot/local-dev-lib": "3.1.3", "@hubspot/serverless-dev-runtime": "7.0.2", "@hubspot/theme-preview-dev-server": "0.0.10", - "@hubspot/ui-extensions-dev-server": "0.8.40", + "@hubspot/ui-extensions-dev-server": "0.8.42", "archiver": "7.0.1", "chalk": "4.1.2", "chokidar": "3.6.0",