import * as http from 'http' import * as net from 'net' import {HttpClient} from '@actions/http-client' import * as config from '../src/internal/shared/config' import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client' import {noopLogs} from './common' import {NetworkError, UsageError} from '../src/internal/shared/errors' jest.mock('@actions/http-client') const clientOptions = { maxAttempts: 5, retryIntervalMs: 1, retryMultiplier: 1.5 } describe('artifact-http-client', () => { beforeAll(() => { noopLogs() jest .spyOn(config, 'getResultsServiceUrl') .mockReturnValue('http://localhost:8080') jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token') }) beforeEach(() => { jest.clearAllMocks() }) it('should successfully create a client', () => { const client = internalArtifactTwirpClient() expect(client).toBeDefined() }) it('should make a request', async () => { const mockPost = jest.fn(() => { const msg = new http.IncomingMessage(new net.Socket()) msg.statusCode = 200 return { message: msg, readBody: async () => { return Promise.resolve( `{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}` ) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient() const artifact = await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1) expect(artifact).toBeDefined() expect(artifact.ok).toBe(true) expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload') }) it('should retry if the request fails', async () => { const mockPost = jest .fn(() => { const msgSucceeded = new http.IncomingMessage(new net.Socket()) msgSucceeded.statusCode = 200 return { message: msgSucceeded, readBody: async () => { return Promise.resolve( `{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}` ) } } }) .mockImplementationOnce(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 500 msgFailed.statusMessage = 'Internal Server Error' return { message: msgFailed, readBody: async () => { return Promise.resolve(`{"ok": false}`) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient(clientOptions) const artifact = await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(artifact).toBeDefined() expect(artifact.ok).toBe(true) expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload') expect(mockPost).toHaveBeenCalledTimes(2) }) it('should retry if invalid body response', async () => { const mockPost = jest .fn(() => { const msgSucceeded = new http.IncomingMessage(new net.Socket()) msgSucceeded.statusCode = 200 return { message: msgSucceeded, readBody: async () => { return Promise.resolve( `{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}` ) } } }) .mockImplementationOnce(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 502 msgFailed.statusMessage = 'Bad Gateway' return { message: msgFailed, readBody: async () => { return Promise.resolve('💥') } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient(clientOptions) const artifact = await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(artifact).toBeDefined() expect(artifact.ok).toBe(true) expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload') expect(mockPost).toHaveBeenCalledTimes(2) }) it('should fail if the request fails 5 times', async () => { const mockPost = jest.fn(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 500 msgFailed.statusMessage = 'Internal Server Error' return { message: msgFailed, readBody: async () => { return Promise.resolve(`{"ok": false}`) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient(clientOptions) await expect(async () => { await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) }).rejects.toThrowError( 'Failed to make request after 5 attempts: Failed request: (500) Internal Server Error' ) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(5) }) it('should fail immediately if there is a non-retryable error', async () => { const mockPost = jest.fn(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 401 msgFailed.statusMessage = 'Unauthorized' return { message: msgFailed, readBody: async () => { return Promise.resolve(`{"ok": false}`) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient(clientOptions) await expect(async () => { await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) }).rejects.toThrowError( 'Received non-retryable error: Failed request: (401) Unauthorized' ) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1) }) it('should fail with a descriptive error', async () => { // 409 duplicate error const mockPost = jest.fn(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 409 msgFailed.statusMessage = 'Conflict' return { message: msgFailed, readBody: async () => { return Promise.resolve( `{"msg": "an artifact with this name already exists on the workflow run"}` ) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient(clientOptions) await expect(async () => { await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) }).rejects.toThrowError( 'Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run' ) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1) }) it('should properly describe a network failure', async () => { class FakeNodeError extends Error { code: string constructor(code: string) { super() this.code = code } } const mockPost = jest.fn(() => { throw new FakeNodeError('ENOTFOUND') }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient() await expect(async () => { await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) }).rejects.toThrowError(new NetworkError('ENOTFOUND').message) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1) }) it('should properly describe a usage error', async () => { const mockPost = jest.fn(() => { const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 403 msgFailed.statusMessage = 'Forbidden' return { message: msgFailed, readBody: async () => { return Promise.resolve( `{"msg": "insufficient usage to create artifact"}` ) } } }) const mockHttpClient = ( HttpClient as unknown as jest.Mock ).mockImplementation(() => { return { post: mockPost } }) const client = internalArtifactTwirpClient() await expect(async () => { await client.CreateArtifact({ workflowRunBackendId: '1234', workflowJobRunBackendId: '5678', name: 'artifact', version: 4 }) }).rejects.toThrowError(new UsageError().message) expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1) }) })