Skip to content

Commit 4dce061

Browse files
authored
test: add @payloadcms/storage-s3 clientUploads integration test suite (#15194)
This PR extends #15176 and adds the following tests: * should generate a signed upload URL * should reject signed URL generation by access control when 'x-disallow-access' header is set * should not allow bypassing with passing a smaller file size but uploading a larger file - tests that were added by #15176 did not account for this and relied only on the passed `filesize` Moves client-upload specific tests to its own file/payload config
1 parent c684c6b commit 4dce061

7 files changed

Lines changed: 389 additions & 203 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { s3Storage } from '@payloadcms/storage-s3'
2+
import dotenv from 'dotenv'
3+
import { fileURLToPath } from 'node:url'
4+
import path from 'path'
5+
6+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
7+
import { devUser } from '../credentials.js'
8+
import { Media } from './collections/Media.js'
9+
import { Users } from './collections/Users.js'
10+
import { mediaSlug } from './shared.js'
11+
import { MB } from './test-utils.js'
12+
const filename = fileURLToPath(import.meta.url)
13+
const dirname = path.dirname(filename)
14+
15+
// Load config to work with emulated services
16+
dotenv.config({
17+
path: path.resolve(dirname, '../plugin-cloud-storage/.env.emulated'),
18+
})
19+
20+
export default buildConfigWithDefaults({
21+
admin: {
22+
importMap: {
23+
baseDir: path.resolve(dirname),
24+
},
25+
},
26+
collections: [Media, Users],
27+
onInit: async (payload) => {
28+
await payload.create({
29+
collection: 'users',
30+
data: {
31+
email: devUser.email,
32+
password: devUser.password,
33+
},
34+
})
35+
},
36+
plugins: [
37+
s3Storage({
38+
collections: {
39+
[mediaSlug]: true,
40+
},
41+
bucket: process.env.S3_BUCKET!,
42+
clientUploads: {
43+
access: ({ req }) => (req.headers.get('x-disallow-access') ? false : true),
44+
},
45+
config: {
46+
credentials: {
47+
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
48+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
49+
},
50+
endpoint: process.env.S3_ENDPOINT,
51+
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
52+
region: process.env.S3_REGION,
53+
},
54+
}),
55+
],
56+
upload: {
57+
limits: {
58+
fileSize: MB(10),
59+
}, // 10 mb
60+
},
61+
typescript: {
62+
outputFile: path.resolve(dirname, 'payload-types.ts'),
63+
},
64+
})
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { Payload } from 'payload'
2+
3+
import { readFileSync } from 'fs'
4+
import path from 'path'
5+
import { assert } from 'ts-essentials'
6+
import { fileURLToPath } from 'url'
7+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
8+
9+
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
10+
11+
import { initPayloadInt } from '../helpers/initPayloadInt.js'
12+
import {
13+
clearTestBucket,
14+
createTestBucket,
15+
getAWSClient,
16+
getTestBucketName,
17+
MB,
18+
} from './test-utils.js'
19+
20+
const filename = fileURLToPath(import.meta.url)
21+
const dirname = path.dirname(filename)
22+
23+
let restClient: NextRESTClient
24+
25+
let payload: Payload
26+
27+
const signedURLEndpoint = '/storage-s3-generate-signed-url'
28+
29+
const signedURLBody = (
30+
collectionSlug: string,
31+
filename: string,
32+
filesize: number,
33+
mimeType: string,
34+
) =>
35+
JSON.stringify({
36+
collectionSlug,
37+
filename,
38+
filesize,
39+
mimeType,
40+
})
41+
42+
describe('@payloadcms/storage-s3 clientUploads', () => {
43+
beforeAll(async () => {
44+
;({ payload, restClient } = await initPayloadInt(
45+
dirname,
46+
undefined,
47+
undefined,
48+
path.resolve(dirname, 'clientUploads.config.ts'),
49+
))
50+
51+
await createTestBucket()
52+
await clearTestBucket()
53+
})
54+
55+
it('should generate a signed upload URL', async () => {
56+
const file = readFileSync(path.resolve(dirname, '../uploads/image.png'))
57+
58+
const { url } = await restClient
59+
.POST(signedURLEndpoint, {
60+
body: signedURLBody('media', 'image.png', file.length, 'image/png'),
61+
})
62+
.then((res) => res.json<{ url: string }>())
63+
64+
expect(url).toBeDefined()
65+
66+
// Upload the file to S3 using the signed URL
67+
const uploadResponse = await fetch(url, {
68+
method: 'PUT',
69+
headers: {
70+
'Content-Type': 'image/png',
71+
'Content-Length': String(file.length),
72+
},
73+
body: file,
74+
})
75+
76+
expect(uploadResponse.ok).toBe(true)
77+
78+
const res = await getAWSClient()
79+
.headObject({
80+
Bucket: getTestBucketName(),
81+
Key: 'image.png',
82+
})
83+
.catch((e) => {
84+
console.error(e)
85+
return null
86+
})
87+
88+
expect(res).not.toBeNull()
89+
assert(res)
90+
expect(res.ContentLength).toBe(file.length)
91+
expect(res.ContentType).toBe('image/png')
92+
})
93+
94+
it("should reject signed URL generation by access control when 'x-disallow-access' header is set", async () => {
95+
const response = await restClient.POST(signedURLEndpoint, {
96+
headers: {
97+
'x-disallow-access': 'true',
98+
},
99+
body: signedURLBody('media', 'image.png', MB(1), 'image/png'),
100+
})
101+
102+
expect(response.status).toBe(403)
103+
})
104+
105+
it('should generate signed URL for file within size limit', async () => {
106+
const filename = 'small-file.png'
107+
const filesize = 500_000 // 500KB (within 1MB limit)
108+
const mimeType = 'image/png'
109+
110+
const response = await restClient.POST(signedURLEndpoint, {
111+
body: signedURLBody('media', filename, filesize, mimeType),
112+
})
113+
114+
expect(response.status).toBe(200)
115+
const { url } = (await response.json()) as any
116+
expect(url).toBeDefined()
117+
expect(url).toContain(getTestBucketName())
118+
expect(url).toContain(filename)
119+
})
120+
121+
it('should reject file exceeding size limit', async () => {
122+
const filename = 'large-file.png'
123+
const filesize = MB(11) // exceeds 10MB limit
124+
const mimeType = 'image/png'
125+
126+
const response = await restClient.POST(signedURLEndpoint, {
127+
body: signedURLBody('media', filename, filesize, mimeType),
128+
})
129+
130+
expect(response.status).toBe(400)
131+
const { errors } = (await response.json()) as any
132+
expect(errors).toBeDefined()
133+
expect(errors[0].message).toContain('Exceeded file size limit')
134+
expect(errors[0].message).toMatch(/Limit: 10\.0\dMB/) // 10,000,000 bytes = 10.0MB
135+
expect(errors[0].message).toMatch(/got: 11\.0\dMB/) // 11,000,000 bytes = 11.0MB
136+
})
137+
138+
it('should reject file exactly at limit boundary', async () => {
139+
const filename = 'boundary-file.png'
140+
const filesize = MB(10.1) // Just over 10MB limit
141+
const mimeType = 'image/png'
142+
143+
const response = await restClient.POST(signedURLEndpoint, {
144+
body: signedURLBody('media', filename, filesize, mimeType),
145+
})
146+
147+
expect(response.status).toBe(400)
148+
const { errors } = (await response.json()) as any
149+
expect(errors).toBeDefined()
150+
expect(errors[0].message).toContain('Exceeded file size limit')
151+
})
152+
153+
it('should accept file exactly at limit', async () => {
154+
const filename = 'exact-limit.png'
155+
const filesize = MB(10) // Exactly 10MB
156+
const mimeType = 'image/png'
157+
158+
const response = await restClient.POST(signedURLEndpoint, {
159+
body: signedURLBody('media', filename, filesize, mimeType),
160+
})
161+
162+
expect(response.status).toBe(200)
163+
const { url } = (await response.json()) as any
164+
expect(url).toBeDefined()
165+
})
166+
167+
it('should not allow bypassing with passing a smaller file size but uploading a larger file', async () => {
168+
const filename = 'bypass-file.png'
169+
const declaredFilesize = MB(5) // Declare 5MB
170+
const actualFilesize = MB(15) // But actually upload 15MB
171+
const mimeType = 'text/plain'
172+
173+
const buffer = Buffer.alloc(actualFilesize, 0)
174+
const file = new Blob([buffer], { type: mimeType })
175+
176+
const { url } = await restClient
177+
.POST(signedURLEndpoint, {
178+
body: signedURLBody('media', filename, declaredFilesize, mimeType),
179+
})
180+
.then((res) => res.json<{ url: string }>())
181+
182+
expect(url).toBeDefined()
183+
184+
// Attempt to upload the larger file to S3 using the signed URL
185+
const uploadResponse = await fetch(url, {
186+
method: 'PUT',
187+
headers: {
188+
'Content-Type': mimeType,
189+
'Content-Length': String(actualFilesize),
190+
},
191+
body: file,
192+
})
193+
194+
if (process.env.S3_ENDPOINT?.includes('localhost')) {
195+
// localstack does not enforce content-length limits on signed URLs
196+
console.warn(
197+
'Skipping assertion for localstack local S3 endpoint, which does not enforce content-length limits on signed URLs',
198+
)
199+
return
200+
}
201+
202+
// Expect the upload to be rejected, works with AWS S3 / Cloudflare R2
203+
expect(uploadResponse.ok).toBe(false)
204+
expect(uploadResponse.status).toBe(403) // S3 should reject the upload
205+
})
206+
207+
afterAll(async () => {
208+
await payload.destroy()
209+
})
210+
211+
afterEach(async () => {
212+
await clearTestBucket()
213+
})
214+
})

test/storage-s3/collections/MediaWithClientUploads.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

test/storage-s3/config.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
77
import { devUser } from '../credentials.js'
88
import { Media } from './collections/Media.js'
99
import { MediaWithAlwaysInsertFields } from './collections/MediaWithAlwaysInsertFields.js'
10-
import { MediaWithClientUploads } from './collections/MediaWithClientUploads.js'
1110
import { MediaWithDirectAccess } from './collections/MediaWithDirectAccess.js'
1211
import { MediaWithDynamicPrefix } from './collections/MediaWithDynamicPrefix.js'
1312
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
@@ -16,7 +15,6 @@ import { Users } from './collections/Users.js'
1615
import {
1716
mediaSlug,
1817
mediaWithAlwaysInsertFieldsSlug,
19-
mediaWithClientUploadsSlug,
2018
mediaWithDirectAccessSlug,
2119
mediaWithDynamicPrefixSlug,
2220
mediaWithPrefixSlug,
@@ -40,7 +38,6 @@ export default buildConfigWithDefaults({
4038
collections: [
4139
Media,
4240
MediaWithAlwaysInsertFields,
43-
MediaWithClientUploads,
4441
MediaWithDirectAccess,
4542
MediaWithDynamicPrefix,
4643
MediaWithPrefix,
@@ -58,10 +55,8 @@ export default buildConfigWithDefaults({
5855
},
5956
plugins: [
6057
s3Storage({
61-
clientUploads: true,
6258
collections: {
6359
[mediaSlug]: true,
64-
[mediaWithClientUploadsSlug]: true,
6560
[mediaWithDirectAccessSlug]: {
6661
disablePayloadAccessControl: true,
6762
},

0 commit comments

Comments
 (0)