2024-08-23 13:17:07 +01:00
|
|
|
import { Client } from '../src/ghcr-client'
|
2024-08-23 10:56:04 +01:00
|
|
|
import * as ociContainer from '../src/oci-container'
|
|
|
|
|
import * as crypto from 'crypto'
|
|
|
|
|
|
|
|
|
|
// Mocks
|
|
|
|
|
let fetchMock: jest.SpyInstance
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
let client: Client
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
const token = 'test-token'
|
|
|
|
|
const registry = new URL('https://ghcr.io')
|
|
|
|
|
const repository = 'test-org/test-repo'
|
|
|
|
|
const semver = '1.2.3'
|
|
|
|
|
const genericSha = '1234567890' // We should look at using different shas here to catch bug, but that make location validation harder
|
|
|
|
|
|
|
|
|
|
const checkBlobNoExistingBlobs = (): object => {
|
|
|
|
|
// Simulate none of the blobs existing currently
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}'
|
|
|
|
|
},
|
|
|
|
|
status: 404,
|
|
|
|
|
statusText: 'Not Found'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const checkBlobAllExistingBlobs = (): object => {
|
|
|
|
|
// Simulate all of the blobs existing currently
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let count = 0
|
|
|
|
|
const checkBlobSomeExistingBlobs = (): object => {
|
|
|
|
|
count++
|
|
|
|
|
// report one as existing
|
|
|
|
|
if (count === 1) {
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
statusText: 'OK'
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// report all others are missing
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}'
|
|
|
|
|
},
|
|
|
|
|
status: 404,
|
|
|
|
|
statusText: 'Not Found'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const checkBlobFailure = (): object => {
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
// In this case we'll simulate a response which does not use the expected error format
|
|
|
|
|
return '503 Service Unavailable'
|
|
|
|
|
},
|
|
|
|
|
status: 503,
|
|
|
|
|
statusText: 'Service Unavailable'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initiateBlobUploadSuccessForAllBlobs = (): object => {
|
|
|
|
|
// Simulate successful initiation of uploads for all blobs & return location
|
|
|
|
|
return {
|
|
|
|
|
status: 202,
|
|
|
|
|
headers: {
|
|
|
|
|
get: (header: string) => {
|
|
|
|
|
if (header === 'location') {
|
|
|
|
|
return `https://ghcr.io/v2/${repository}/blobs/uploads/${genericSha}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initiateBlobUploadFailureForAllBlobs = (): object => {
|
|
|
|
|
// Simulate failed initiation of uploads
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
// In this case we'll simulate a response which does not use the expected error format
|
|
|
|
|
return '503 Service Unavailable'
|
|
|
|
|
},
|
|
|
|
|
status: 503,
|
|
|
|
|
statusText: 'Service Unavailable'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initiateBlobUploadNoLocationHeader = (): object => {
|
|
|
|
|
return {
|
|
|
|
|
status: 202,
|
|
|
|
|
headers: {
|
|
|
|
|
get: () => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const putManifestSuccessful = (
|
|
|
|
|
digestToReturn: string,
|
|
|
|
|
expectedVersion: string
|
|
|
|
|
): ((url: string) => object) => {
|
|
|
|
|
return (url: string): object => {
|
|
|
|
|
expect(url.endsWith(`manifests/${expectedVersion}`)).toBeTruthy()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 201,
|
|
|
|
|
headers: {
|
|
|
|
|
get: (header: string) => {
|
|
|
|
|
if (header === 'docker-content-digest') {
|
|
|
|
|
return digestToReturn
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const putBlobSuccess = (): object => {
|
|
|
|
|
return {
|
|
|
|
|
status: 201
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const putManifestFailure = (): object => {
|
|
|
|
|
// Simulate fails upload of all blobs & manifest
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}'
|
|
|
|
|
},
|
|
|
|
|
status: 400,
|
|
|
|
|
statusText: 'Bad Request'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const putBlobFailure = (): object => {
|
|
|
|
|
// Simulate fails upload of all blobs & manifest
|
|
|
|
|
return {
|
|
|
|
|
text() {
|
|
|
|
|
return '{"errors": [{"code": "BAD_REQUEST", "message": "digest issue."}]}'
|
|
|
|
|
},
|
|
|
|
|
status: 400,
|
|
|
|
|
statusText: 'Bad Request'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MethodHandlers = {
|
|
|
|
|
checkBlobMock?: (url: string, options: { method: string }) => object
|
|
|
|
|
initiateBlobUploadMock?: (url: string, options: { method: string }) => object
|
|
|
|
|
putManifestMock?: (url: string, options: { method: string }) => object
|
|
|
|
|
putBlobMock?: (url: string, options: { method: string }) => object
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
type ForcedRetries = {
|
|
|
|
|
checkBlob: number
|
|
|
|
|
initiateBlobUpload: number
|
|
|
|
|
putBlob: number
|
|
|
|
|
putManifest: number
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
function configureFetchMock(
|
|
|
|
|
fetchMockInstance: jest.SpyInstance,
|
2024-08-23 13:17:07 +01:00
|
|
|
methodHandlers: MethodHandlers,
|
|
|
|
|
forcedRetries: ForcedRetries = {
|
|
|
|
|
checkBlob: 0,
|
|
|
|
|
initiateBlobUpload: 0,
|
|
|
|
|
putBlob: 0,
|
|
|
|
|
putManifest: 0
|
|
|
|
|
}
|
2024-08-23 10:56:04 +01:00
|
|
|
): void {
|
2024-08-23 13:17:07 +01:00
|
|
|
const retriableError = async (retries: number): Promise<object> => {
|
|
|
|
|
if (retries % 2 === 0) {
|
|
|
|
|
throw new Error('Network Error')
|
|
|
|
|
} else {
|
|
|
|
|
return {
|
|
|
|
|
status: 429,
|
|
|
|
|
statusText: 'Too Many Requests',
|
|
|
|
|
headers: {
|
|
|
|
|
get: (header: string) => {
|
|
|
|
|
if (header === 'retry-after') {
|
|
|
|
|
return '0.1'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
fetchMockInstance.mockImplementation(
|
|
|
|
|
async (url: string, options: { method: string }) => {
|
2024-08-23 13:17:07 +01:00
|
|
|
// Simulate retries for every request until the number of forced retries is exhausted.
|
|
|
|
|
// We'll simulate both failing status codes and network errors for full coverage.
|
2024-08-23 10:56:04 +01:00
|
|
|
validateRequestConfig(url, options)
|
|
|
|
|
switch (options.method) {
|
|
|
|
|
case 'HEAD':
|
2024-08-23 13:17:07 +01:00
|
|
|
if (forcedRetries.checkBlob > 0) {
|
|
|
|
|
forcedRetries.checkBlob--
|
|
|
|
|
return retriableError(forcedRetries.checkBlob)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
return methodHandlers.checkBlobMock?.(url, options)
|
|
|
|
|
case 'POST':
|
2024-08-23 13:17:07 +01:00
|
|
|
if (forcedRetries.initiateBlobUpload > 0) {
|
|
|
|
|
forcedRetries.initiateBlobUpload--
|
|
|
|
|
return retriableError(forcedRetries.initiateBlobUpload)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
return methodHandlers.initiateBlobUploadMock?.(url, options)
|
|
|
|
|
case 'PUT':
|
|
|
|
|
if (url.includes('manifest')) {
|
2024-08-23 13:17:07 +01:00
|
|
|
if (forcedRetries.putManifest > 0) {
|
|
|
|
|
forcedRetries.putManifest--
|
|
|
|
|
return retriableError(forcedRetries.putManifest)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
return methodHandlers.putManifestMock?.(url, options)
|
|
|
|
|
} else {
|
2024-08-23 13:17:07 +01:00
|
|
|
if (forcedRetries.putBlob > 0) {
|
|
|
|
|
forcedRetries.putBlob--
|
|
|
|
|
return retriableError(forcedRetries.putBlob)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
return methodHandlers.putBlobMock?.(url, options)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 11:06:03 +01:00
|
|
|
describe('uploadOCIIndexManifest', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks()
|
|
|
|
|
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
2024-08-23 13:17:07 +01:00
|
|
|
|
|
|
|
|
client = new Client(token, registry, {
|
|
|
|
|
retries: 5,
|
|
|
|
|
backoff: 1
|
|
|
|
|
})
|
2024-08-23 11:06:03 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uploads the tagged manifest with the appropriate tag', async () => {
|
|
|
|
|
const { manifest, sha } = testIndexManifest()
|
|
|
|
|
const tag = 'sha-1234'
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, tag)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
await client.uploadOCIIndexManifest(repository, manifest, tag)
|
2024-08-23 11:06:03 +01:00
|
|
|
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(
|
|
|
|
|
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
|
|
|
|
).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws an error if a manifest upload fails', async () => {
|
|
|
|
|
const { manifest, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobAllExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestFailure
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 11:06:03 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
2024-08-23 13:17:07 +01:00
|
|
|
const { manifest, sha } = testIndexManifest()
|
|
|
|
|
|
|
|
|
|
const tag = 'sha-1234'
|
2024-08-23 11:06:03 +01:00
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobAllExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
2024-08-23 13:17:07 +01:00
|
|
|
putManifestMock: putManifestSuccessful('some-garbage-digest', tag)
|
2024-08-23 11:06:03 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIIndexManifest(repository, manifest, tag)
|
2024-08-23 11:06:03 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
describe('uploadOCIImageManifest', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks()
|
|
|
|
|
fetchMock = jest.spyOn(global, 'fetch').mockImplementation()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uploads blobs then untagged manifest to the provided registry', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
|
|
|
|
|
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('uploads blobs then tagged manifest to the provided registry', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, semver)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
await client.uploadOCIImageManifest(repository, manifest, blobs, semver)
|
2024-08-23 10:56:04 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
it('uploads everything to the provided registry by retrying requests', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(
|
|
|
|
|
fetchMock,
|
|
|
|
|
{
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
checkBlob: 2,
|
|
|
|
|
initiateBlobUpload: 2,
|
|
|
|
|
putBlob: 2,
|
|
|
|
|
putManifest: 2
|
|
|
|
|
}
|
|
|
|
|
) // Fail each request twice before succeeding
|
|
|
|
|
|
|
|
|
|
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
|
|
|
|
|
|
|
|
|
// 8 Additional requests - 2 for each of the 4 failed request types
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(18)
|
|
|
|
|
expect(
|
|
|
|
|
fetchMock.mock.calls.filter(call => call[1].method === 'HEAD')
|
|
|
|
|
).toHaveLength(5)
|
|
|
|
|
expect(
|
|
|
|
|
fetchMock.mock.calls.filter(call => call[1].method === 'POST')
|
|
|
|
|
).toHaveLength(5)
|
|
|
|
|
expect(
|
|
|
|
|
fetchMock.mock.calls.filter(call => call[1].method === 'PUT')
|
|
|
|
|
).toHaveLength(8)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
it('skips blob uploads if all blobs already exist', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobAllExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
|
|
|
|
|
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 blob uploads if some blobs already exist', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobSomeExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
await client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(8)
|
|
|
|
|
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 () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobFailure,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
/^Unexpected 503 Service Unavailable response from check blob/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 13:17:07 +01:00
|
|
|
it('throws an error if a blob file is not provided', async () => {
|
|
|
|
|
const { manifest, sha } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
client.uploadOCIImageManifest(
|
|
|
|
|
repository,
|
|
|
|
|
manifest,
|
|
|
|
|
new Map<string, Buffer>()
|
|
|
|
|
)
|
|
|
|
|
).rejects.toThrow(/^Blob for layer sha256:[a-zA-Z0-9]+ not found/)
|
|
|
|
|
})
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
it('throws an error if initiating layer upload fails', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadFailureForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws an error if the upload endpoint does not return a location', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
2024-03-25 17:44:45 +00:00
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadNoLocationHeader,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
2024-03-25 17:44:45 +00:00
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(/^No location header in response from upload post/)
|
2024-03-25 17:44:45 +00:00
|
|
|
})
|
2024-08-23 10:56:04 +01:00
|
|
|
|
|
|
|
|
it('throws an error if a layer upload fails', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobNoExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobFailure,
|
|
|
|
|
putManifestMock: putManifestSuccessful(sha, sha)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws an error if a manifest upload fails', async () => {
|
|
|
|
|
const { manifest, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobAllExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestFailure
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.'
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws an error if the returned digest does not match the precalculated one', async () => {
|
|
|
|
|
const { manifest, sha, blobs } = testImageManifest()
|
|
|
|
|
|
|
|
|
|
configureFetchMock(fetchMock, {
|
|
|
|
|
checkBlobMock: checkBlobAllExistingBlobs,
|
|
|
|
|
initiateBlobUploadMock: initiateBlobUploadSuccessForAllBlobs,
|
|
|
|
|
putBlobMock: putBlobSuccess,
|
|
|
|
|
putManifestMock: putManifestSuccessful('some-garbage-digest', sha)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
2024-08-23 13:17:07 +01:00
|
|
|
client.uploadOCIImageManifest(repository, manifest, blobs)
|
2024-08-23 10:56:04 +01:00
|
|
|
).rejects.toThrow(
|
|
|
|
|
`Digest mismatch. Expected ${sha}, got some-garbage-digest.`
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function testImageManifest(): {
|
|
|
|
|
manifest: ociContainer.OCIImageManifest
|
|
|
|
|
sha: string
|
|
|
|
|
blobs: Map<string, Buffer>
|
|
|
|
|
} {
|
|
|
|
|
const blobs = new Map<string, Buffer>()
|
|
|
|
|
blobs.set(ociContainer.emptyConfigSha, Buffer.from('{}'))
|
|
|
|
|
|
|
|
|
|
const firstFile = Buffer.from('test1')
|
|
|
|
|
const secondFile = Buffer.from('test2')
|
|
|
|
|
|
|
|
|
|
const firstFileDigest = `sha256:${crypto
|
|
|
|
|
.createHash('sha256')
|
|
|
|
|
.update(firstFile)
|
|
|
|
|
.digest('hex')}`
|
|
|
|
|
|
|
|
|
|
const secondFileDigest = `sha256:${crypto
|
|
|
|
|
.createHash('sha256')
|
|
|
|
|
.update(secondFile)
|
|
|
|
|
.digest('hex')}`
|
|
|
|
|
|
|
|
|
|
blobs.set(firstFileDigest, firstFile)
|
|
|
|
|
blobs.set(secondFileDigest, secondFile)
|
|
|
|
|
|
|
|
|
|
const manifest: ociContainer.OCIImageManifest = {
|
|
|
|
|
schemaVersion: 2,
|
|
|
|
|
mediaType: ociContainer.imageManifestMediaType,
|
|
|
|
|
artifactType: ociContainer.imageManifestMediaType,
|
|
|
|
|
config: ociContainer.createEmptyConfigLayer(),
|
|
|
|
|
layers: [
|
|
|
|
|
{
|
|
|
|
|
mediaType: 'application/octet-stream',
|
|
|
|
|
size: firstFile.length,
|
|
|
|
|
digest: firstFileDigest
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
mediaType: 'application/octet-stream',
|
|
|
|
|
size: secondFile.length,
|
|
|
|
|
digest: secondFileDigest
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
annotations: {
|
|
|
|
|
'org.opencontainers.image.created': new Date().toISOString()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sha = ociContainer.sha256Digest(manifest)
|
|
|
|
|
|
|
|
|
|
return { manifest, sha, blobs }
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 11:06:03 +01:00
|
|
|
function testIndexManifest(): {
|
|
|
|
|
manifest: ociContainer.OCIIndexManifest
|
|
|
|
|
sha: string
|
|
|
|
|
} {
|
|
|
|
|
const manifest = ociContainer.createReferrerTagManifest(
|
|
|
|
|
'attestation-digest',
|
|
|
|
|
1234,
|
2024-08-27 20:52:44 +01:00
|
|
|
'bundle-media-type',
|
|
|
|
|
'bundle-predicate-type',
|
2024-08-23 11:06:03 +01:00
|
|
|
new Date(),
|
|
|
|
|
new Date()
|
|
|
|
|
)
|
|
|
|
|
const sha = ociContainer.sha256Digest(manifest)
|
|
|
|
|
return { manifest, sha }
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-23 10:56:04 +01:00
|
|
|
// 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(url: string, config: any): void {
|
|
|
|
|
// Basic URL checks
|
|
|
|
|
expect(url).toBeDefined()
|
|
|
|
|
if (!url.startsWith(registry.toString())) {
|
|
|
|
|
console.log(`${url} does not start with ${registry}`)
|
|
|
|
|
}
|
|
|
|
|
// if these expect fails, run the test again with `-- --silent=false`
|
|
|
|
|
// the console.log above should give a clue about which URL is failing
|
|
|
|
|
expect(url.startsWith(registry.toString())).toBeTruthy()
|
|
|
|
|
|
|
|
|
|
// Config checks
|
|
|
|
|
expect(config).toBeDefined()
|
|
|
|
|
|
|
|
|
|
expect(config.headers).toBeDefined()
|
|
|
|
|
if (config.headers) {
|
|
|
|
|
// Check the auth header is set
|
|
|
|
|
expect(config.headers.Authorization).toBeDefined()
|
|
|
|
|
// Check the auth header is the base 64 encoded token
|
|
|
|
|
expect(config.headers.Authorization).toBe(
|
|
|
|
|
`Bearer ${Buffer.from(token).toString('base64')}`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|