Files
payloadcms/test/storage-s3/int.spec.ts
Anders Semb Hermansen a19921d08f fix(storage-s3): return error status 404 when file is not found instead of 500 (#11733)
### What?

The s3 storage adapter returns a 500 internal server error when a file
is not found.
It's expected that it will return 404 when a file is not found.

### Why?

The getObject function from aws s3 sdk does not return undefined when a
blob is not found, but throws a NoSuchKey error:
https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-s3/Class/NoSuchKey/

### How?

Check if exception thrown is of type NoSuchKey and return a 404 in that
case.

Related discord discussion:

https://discord.com/channels/967097582721572934/1350826594062696539/1350826594062696539
2025-06-11 12:04:25 +00:00

202 lines
5.7 KiB
TypeScript

import type { Payload } from 'payload'
import * as AWS from '@aws-sdk/client-s3'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let restClient: NextRESTClient
let payload: Payload
describe('@payloadcms/storage-s3', () => {
let TEST_BUCKET: string
let client: AWS.S3Client
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
TEST_BUCKET = process.env.S3_BUCKET
client = new AWS.S3({
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
})
await createTestBucket()
await clearTestBucket()
})
afterAll(async () => {
await payload.destroy()
})
afterEach(async () => {
await clearTestBucket()
})
it('can upload', async () => {
const upload = await payload.create({
collection: mediaSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})
expect(upload.id).toBeTruthy()
await verifyUploads({
collectionSlug: mediaSlug,
uploadId: upload.id,
})
expect(upload.url).toEqual(`/api/${mediaSlug}/file/${String(upload.filename)}`)
})
it('can upload with prefix', async () => {
const upload = await payload.create({
collection: mediaWithPrefixSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})
expect(upload.id).toBeTruthy()
await verifyUploads({
collectionSlug: mediaWithPrefixSlug,
uploadId: upload.id,
prefix,
})
expect(upload.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${String(upload.filename)}`)
})
it('can download with signed downloads', async () => {
await payload.create({
collection: mediaWithSignedDownloadsSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/image.png'),
})
const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/image.png`)
expect(response.status).toBe(302)
const url = response.headers.get('Location')
expect(url).toBeDefined()
expect(url!).toContain(`/${TEST_BUCKET}/image.png`)
expect(new URLSearchParams(url!).get('x-id')).toBe('GetObject')
const file = await fetch(url!)
expect(file.headers.get('Content-Type')).toBe('image/png')
})
it('should skip signed download', async () => {
await payload.create({
collection: mediaWithSignedDownloadsSlug,
data: {},
filePath: path.resolve(dirname, '../uploads/small.png'),
})
const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/small.png`, {
headers: { 'X-Disable-Signed-URL': 'true' },
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
})
it('should return 404 when the file is not found', async () => {
const response = await restClient.GET(`/${mediaSlug}/file/missing.png`)
expect(response.status).toBe(404)
})
describe('R2', () => {
it.todo('can upload')
})
async function createTestBucket() {
try {
const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET }))
if (makeBucketRes.$metadata.httpStatusCode !== 200) {
throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`)
}
} catch (e) {
if (e instanceof AWS.BucketAlreadyOwnedByYou) {
console.log('Bucket already exists')
}
}
}
async function clearTestBucket() {
const listedObjects = await client.send(
new AWS.ListObjectsV2Command({
Bucket: TEST_BUCKET,
}),
)
if (!listedObjects?.Contents?.length) {
return
}
const deleteParams = {
Bucket: TEST_BUCKET,
Delete: { Objects: [] },
}
listedObjects.Contents.forEach(({ Key }) => {
deleteParams.Delete.Objects.push({ Key })
})
const deleteResult = await client.send(new AWS.DeleteObjectsCommand(deleteParams))
if (deleteResult.Errors?.length) {
throw new Error(JSON.stringify(deleteResult.Errors))
}
}
async function verifyUploads({
collectionSlug,
uploadId,
prefix = '',
}: {
collectionSlug: string
prefix?: string
uploadId: number | string
}) {
const uploadData = (await payload.findByID({
collection: collectionSlug,
id: uploadId,
})) as unknown as { filename: string; sizes: Record<string, { filename: string }> }
const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => {
const rawFilename = uploadData.sizes[key].filename
return prefix ? `${prefix}/${rawFilename}` : rawFilename
})
fileKeys.push(`${prefix ? `${prefix}/` : ''}${uploadData.filename}`)
try {
for (const key of fileKeys) {
const { $metadata } = await client.send(
new AWS.HeadObjectCommand({ Bucket: TEST_BUCKET, Key: key }),
)
if ($metadata.httpStatusCode !== 200) {
console.error('Error verifying uploads', key, $metadata)
throw new Error(`Error verifying uploads: ${key}, ${$metadata.httpStatusCode}`)
}
// Verify each size was properly uploaded
expect($metadata.httpStatusCode).toBe(200)
}
} catch (error: unknown) {
console.error('Error verifying uploads:', fileKeys, error)
throw error
}
}
})