diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index b5545ab..dd9af6b 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -1,13 +1,10 @@ import { publishOCIArtifact } from '../src/ghcr-client' -import axios from 'axios' import * as fsHelper from '../src/fs-helper' import * as ociContainer from '../src/oci-container' // Mocks let fsReadFileSyncMock: jest.SpyInstance -let axiosPostMock: jest.SpyInstance -let axiosPutMock: jest.SpyInstance -let axiosHeadMock: jest.SpyInstance +let fetchMock: jest.SpyInstance const token = 'test-token' const registry = new URL('https://ghcr.io') @@ -81,28 +78,48 @@ describe('publishOCIArtifact', () => { .spyOn(fsHelper, 'readFileContents') .mockImplementation() - axiosPostMock = jest.spyOn(axios, 'post').mockImplementation() - axiosPutMock = jest.spyOn(axios, 'put').mockImplementation() - axiosHeadMock = jest.spyOn(axios, 'head').mockImplementation() + fetchMock = jest.spyOn(global, 'fetch').mockImplementation() }) it('publishes layer blobs & then a manifest to the provided registry', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + validateRequestConfig(url, options) + // Simulate none of the blobs existing currently + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate successful upload of all blobs & then the manifest + validateRequestConfig(url, options) + if ((url as string).includes('manifest')) { + return Promise.resolve({ + status: 201, + headers: { + get: (header: string) => { + if (header === 'docker-content-digest') { + return '1234567678' + } + } + } + }) } + return Promise.resolve({ + status: 201 + }) } }) @@ -111,22 +128,6 @@ describe('publishOCIArtifact', () => { return Buffer.from('test') }) - // Simulate successful upload of all blobs & then the manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(201, url, config) - - if ((url as string).includes('manifest')) { - return { - status: 201, - headers: { 'docker-content-digest': '1234567678' } - } - } - - return { - status: 201 - } - }) - await publishOCIArtifact( token, registry, @@ -137,28 +138,58 @@ describe('publishOCIArtifact', () => { testManifest ) - expect(axiosHeadMock).toHaveBeenCalledTimes(3) - expect(axiosPostMock).toHaveBeenCalledTimes(3) - expect(axiosPutMock).toHaveBeenCalledTimes(4) + expect(fetchMock).toHaveBeenCalledTimes(10) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(4) }) it('skips uploading all layer blobs when they all already exist', async () => { // Simulate all blobs already existing - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(200, url, config) - return { - status: 200 - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + // Simulate none of the blobs existing currently + validateRequestConfig(url, options) + return Promise.resolve({ + status: 200 + }) + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate successful upload of all blobs & then the manifest + validateRequestConfig(url, options) + if ((url as string).includes('manifest')) { + return Promise.resolve({ + status: 201, + headers: { + get: (header: string) => { + if (header === 'docker-content-digest') { + return '1234567678' + } + } + } + }) } + return Promise.resolve({ + status: 201 + }) } }) @@ -167,22 +198,6 @@ describe('publishOCIArtifact', () => { return Buffer.from('test') }) - // Simulate successful upload of all blobs & then the manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(201, url, config) - - if ((url as string).includes('manifest')) { - return { - status: 201, - headers: { 'docker-content-digest': '1234567678' } - } - } - - return { - status: 201 - } - }) - await publishOCIArtifact( token, registry, @@ -194,40 +209,66 @@ describe('publishOCIArtifact', () => { ) // We should only head all the blobs and then upload the manifest - expect(axiosHeadMock).toHaveBeenCalledTimes(3) - expect(axiosPostMock).toHaveBeenCalledTimes(0) - expect(axiosPutMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(0) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(1) }) it('skips uploading layer blobs that already exist', async () => { // Simulate some blobs already existing - let count = 0 - axiosHeadMock.mockImplementation(async (url, config) => { + fetchMock.mockImplementation(async (url, options) => { count++ - if (count === 1) { - // report the first blob as being there - validateRequestConfig(200, url, config) - return { - status: 200 + if (options.method === 'HEAD') { + validateRequestConfig(url, options) + if (count === 1) { + return Promise.resolve({ + status: 200 + }) + } else { + // report all others are missing + return Promise.resolve({ + status: 404 + }) } - } else { - // report all others are missing - validateRequestConfig(404, url, config) - return { - status: 404 - } - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate successful upload of all blobs & then the manifest + validateRequestConfig(url, options) + if ((url as string).includes('manifest')) { + return Promise.resolve({ + status: 201, + headers: { + get: (header: string) => { + if (header === 'docker-content-digest') { + return '1234567678' + } + } + } + }) } + return Promise.resolve({ + status: 201 + }) } }) @@ -236,22 +277,6 @@ describe('publishOCIArtifact', () => { return Buffer.from('test') }) - // Simulate successful upload of all blobs & then the manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(201, url, config) - - if ((url as string).includes('manifest')) { - return { - status: 201, - headers: { 'docker-content-digest': '1234567678' } - } - } - - return { - status: 201 - } - }) - await publishOCIArtifact( token, registry, @@ -262,18 +287,27 @@ describe('publishOCIArtifact', () => { testManifest ) + expect(fetchMock).toHaveBeenCalledTimes(8) // We should only head all the blobs and then upload the missing blobs and manifest - expect(axiosHeadMock).toHaveBeenCalledTimes(3) - expect(axiosPostMock).toHaveBeenCalledTimes(2) - expect(axiosPutMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'HEAD') + ).toHaveLength(3) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'POST') + ).toHaveLength(2) + expect( + fetchMock.mock.calls.filter(call => call[1].method === 'PUT') + ).toHaveLength(3) }) it('throws an error if checking for existing blobs fails', async () => { - // Simulate failed response code - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(503, url, config) - return { - status: 503 + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + validateRequestConfig(url, options) + // Simulate failed response code + return Promise.resolve({ + status: 503 + }) } }) @@ -291,19 +325,18 @@ describe('publishOCIArtifact', () => { }) it('throws an error if initiating layer upload fails', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate failed initiation of uploads - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(503, url, config) - return { - status: 503 + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + // Simulate none of the blobs existing currently + validateRequestConfig(url, options) + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate failed initiation of uploads + return Promise.resolve({ + status: 503 + }) } }) @@ -321,20 +354,21 @@ describe('publishOCIArtifact', () => { }) it('throws an error if the upload endpoint does not return a location', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate successful response code but no location header - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: {} + fetchMock.mockImplementation(async (url, options) => { + validateRequestConfig(url, options) + if (options.method === 'HEAD') { + // Simulate none of the blobs existing currently + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate successful response code but no location header + return Promise.resolve({ + status: 202, + headers: { + get: () => {} + } + }) } }) @@ -352,22 +386,32 @@ describe('publishOCIArtifact', () => { }) it('throws an error if a layer upload fails', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` - } + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + validateRequestConfig(url, options) + // Simulate none of the blobs existing currently + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate fails upload of all blobs & manifest + validateRequestConfig(url, options) + return Promise.resolve({ + status: 500 + }) } }) @@ -376,14 +420,6 @@ describe('publishOCIArtifact', () => { return Buffer.from('test') }) - // Simulate fails upload of all blobs & manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(500, url, config) - return { - status: 500 - } - }) - await expect( publishOCIArtifact( token, @@ -398,22 +434,37 @@ describe('publishOCIArtifact', () => { }) it('throws an error if a manifest upload fails', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + // Simulate none of the blobs existing currently + validateRequestConfig(url, options) + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate unsuccessful upload of all blobs & then the manifest + validateRequestConfig(url, options) + if (url.includes('manifest')) { + return Promise.resolve({ + status: 500 + }) } + return Promise.resolve({ + status: 201 + }) } }) @@ -422,21 +473,6 @@ describe('publishOCIArtifact', () => { return Buffer.from('test') }) - // Simulate successful upload of all blobs & then the manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - if (url.includes('manifest')) { - validateRequestConfig(500, url, config) - return { - status: 500 - } - } - - validateRequestConfig(201, url, config) - return { - status: 201 - } - }) - await expect( publishOCIArtifact( token, @@ -451,22 +487,32 @@ describe('publishOCIArtifact', () => { }) it('throws an error if reading one of the files fails', async () => { - // Simulate none of the blobs existing currently - axiosHeadMock.mockImplementation(async (url, config) => { - validateRequestConfig(404, url, config) - return { - status: 404 - } - }) - - // Simulate successful initiation of uploads for all blobs & return location - axiosPostMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(202, url, config) - return { - status: 202, - headers: { - location: `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` - } + fetchMock.mockImplementation(async (url, options) => { + if (options.method === 'HEAD') { + // Simulate none of the blobs existing currently + validateRequestConfig(url, options) + return Promise.resolve({ + status: 404 + }) + } else if (options.method === 'POST') { + // Simulate successful initiation of uploads for all blobs & return location + validateRequestConfig(url, options) + return Promise.resolve({ + status: 202, + headers: { + get: (header: string) => { + if (header === 'location') { + return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}` + } + } + } + }) + } else if (options.method === 'PUT') { + // Simulate successful upload of all blobs & then the manifest + validateRequestConfig(url, options) + return Promise.resolve({ + status: 201 + }) } }) @@ -475,14 +521,6 @@ describe('publishOCIArtifact', () => { throw new Error('failed to read a file: test') }) - // Simulate successful upload of all blobs & then the manifest - axiosPutMock.mockImplementation(async (url, data, config) => { - validateRequestConfig(201, url, config) - return { - status: 201 - } - }) - await expect( publishOCIArtifact( token, @@ -520,10 +558,10 @@ describe('publishOCIArtifact', () => { }) }) -// We expect all axios calls to have auth headers set and to not intercept any status codes so we can handle them. -// This function verifies that given an axios request config. +// We expect all fetch calls to have auth headers set +// This function verifies that given an request config. // eslint-disable-next-line @typescript-eslint/no-explicit-any -function validateRequestConfig(status: number, url: string, config: any): void { +function validateRequestConfig(url: string, config: any): void { // Basic URL checks expect(url).toBeDefined() if (!url.startsWith(registry.toString())) { @@ -536,12 +574,6 @@ function validateRequestConfig(status: number, url: string, config: any): void { // Config checks expect(config).toBeDefined() - expect(config.validateStatus).toBeDefined() - if (config.validateStatus) { - // Check axios will not intercept this status - expect(config.validateStatus(status)).toBe(true) - } - expect(config.headers).toBeDefined() if (config.headers) { // Check the auth header is set