Skip to content

Commit 8d14915

Browse files
authored
fix(plugin-cloud-storage): preserve user-defined prefix.defaultValue (#16529)
Backports #16523
1 parent 64b2860 commit 8d14915

10 files changed

Lines changed: 752 additions & 44 deletions

File tree

.github/workflows/e2e.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export default createE2EConfig([
8686
{ file: 'locked-documents', shards: 1 },
8787
{ file: 'i18n', shards: 1 },
8888
{ file: 'plugin-cloud-storage', shards: 1 },
89+
{ file: 'storage-azure__client-uploads#client-uploads/config.ts', shards: 1 },
8990
{ file: 'storage-s3__client-uploads#client-uploads/config.ts', shards: 1 },
9091
{ file: 'storage-vercel-blob__client-uploads#client-uploads/config.ts', shards: 1 },
9192
{ file: 'plugin-form-builder', shards: 1 },

packages/plugin-cloud-storage/src/admin/fields/getFields.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ export const getFields = ({
132132
fields.push({
133133
...basePrefixField,
134134
...(existingPrefixField || {}),
135-
defaultValue: useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : '',
135+
defaultValue:
136+
existingPrefixField?.defaultValue ??
137+
(useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : ''),
136138
} as TextField)
137139
}
138140

packages/plugin-cloud-storage/src/fields/getFields.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ export const getFields = ({
200200
fields.push({
201201
...basePrefixField,
202202
...(existingPrefixField || {}),
203-
defaultValue: useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : '',
203+
defaultValue:
204+
existingPrefixField?.defaultValue ??
205+
(useCompositePrefixes ? '' : prefix ? path.posix.join(prefix) : ''),
204206
} as TextField)
205207
}
206208

test/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const CI = process.env.CI === 'true'
1414
let multiplier = CI ? 4 : 1
1515
let smallMultiplier = CI ? 3 : 1
1616

17-
export const TEST_TIMEOUT_LONG = 320000 * multiplier // 4*8 minutes - used as timeOut for the beforeAll
17+
export const TEST_TIMEOUT_LONG = 60000 * multiplier // used as timeOut for the beforeAll
1818
export const TEST_TIMEOUT = 20000 * smallMultiplier
1919
export const EXPECT_TIMEOUT = 6000 * smallMultiplier
2020
export const POLL_TOPASS_TIMEOUT = EXPECT_TIMEOUT * 4 // That way expect.poll() or expect().toPass can retry 4 times. 4x higher than default expect timeout => can retry 4 times if retryable expects are used inside
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const mediaWithDocPrefixSlug = 'media-with-doc-prefix'
4+
5+
export const MediaWithDocPrefix: CollectionConfig = {
6+
slug: mediaWithDocPrefixSlug,
7+
upload: {
8+
filenameCompoundIndex: ['prefix', 'filename'],
9+
},
10+
fields: [
11+
{
12+
name: 'prefix',
13+
type: 'text',
14+
defaultValue: () => `doc-${Math.random().toString(36).slice(2, 10)}`,
15+
},
16+
],
17+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { azureStorage } from '@payloadcms/storage-azure'
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 { MediaWithPrefix } from '../collections/MediaWithPrefix.js'
10+
import { Users } from '../collections/Users.js'
11+
import { mediaSlug, mediaWithPrefixSlug, prefix } from '../shared.js'
12+
import { MediaWithDocPrefix, mediaWithDocPrefixSlug } from './collections/MediaWithDocPrefix.js'
13+
14+
const filename = fileURLToPath(import.meta.url)
15+
const dirname = path.dirname(filename)
16+
17+
dotenv.config({
18+
path: path.resolve(dirname, '../../plugin-cloud-storage/.env.emulated'),
19+
})
20+
21+
export default buildConfigWithDefaults({
22+
admin: {
23+
importMap: {
24+
baseDir: path.resolve(dirname, '..'),
25+
},
26+
},
27+
collections: [Media, MediaWithPrefix, MediaWithDocPrefix, Users],
28+
onInit: async (payload) => {
29+
await payload.create({
30+
collection: 'users',
31+
data: {
32+
email: devUser.email,
33+
password: devUser.password,
34+
},
35+
})
36+
},
37+
plugins: [
38+
azureStorage({
39+
collections: {
40+
[mediaSlug]: true,
41+
[mediaWithPrefixSlug]: {
42+
prefix,
43+
},
44+
// Configure a collection-level prefix on this slug to test that
45+
// a custom `prefix.defaultValue` does override the static prefix
46+
[mediaWithDocPrefixSlug]: {
47+
prefix: 'docprefix-collection',
48+
},
49+
},
50+
allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
51+
baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL!,
52+
clientUploads: true,
53+
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING!,
54+
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME!,
55+
}),
56+
],
57+
typescript: {
58+
outputFile: path.resolve(dirname, 'payload-types.ts'),
59+
},
60+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { ContainerClient } from '@azure/storage-blob'
2+
import type { Page } from '@playwright/test'
3+
4+
import { BlobServiceClient } from '@azure/storage-blob'
5+
import { expect, test } from '@playwright/test'
6+
import dotenv from 'dotenv'
7+
import * as path from 'path'
8+
import { fileURLToPath } from 'url'
9+
10+
import type { PayloadTestSDK } from '../../__helpers/shared/sdk/index.js'
11+
12+
import { ensureCompilationIsDone, saveDocAndAssert } from '../../__helpers/e2e/helpers.js'
13+
import { AdminUrlUtil } from '../../__helpers/shared/adminUrlUtil.js'
14+
import { initPayloadE2ENoConfig } from '../../__helpers/shared/initPayloadE2ENoConfig.js'
15+
import { TEST_TIMEOUT_LONG } from '../../playwright.config.js'
16+
import { mediaWithDocPrefixSlug } from './collections/MediaWithDocPrefix.js'
17+
18+
const filename = fileURLToPath(import.meta.url)
19+
const dirname = path.dirname(filename)
20+
21+
dotenv.config({ path: path.resolve(dirname, '../../plugin-cloud-storage/.env.emulated') })
22+
23+
test.describe('storage-azure client uploads E2E', () => {
24+
let page: Page
25+
let mediaWithDocPrefixURL: AdminUrlUtil
26+
let payloadSDK: PayloadTestSDK<{ collections: Record<string, unknown> }>
27+
let containerClient: ContainerClient
28+
29+
test.beforeAll(async ({ browser }, testInfo) => {
30+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
31+
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
32+
33+
payloadSDK = payload as unknown as typeof payloadSDK
34+
mediaWithDocPrefixURL = new AdminUrlUtil(serverURL, mediaWithDocPrefixSlug)
35+
36+
const blobService = BlobServiceClient.fromConnectionString(
37+
process.env.AZURE_STORAGE_CONNECTION_STRING!,
38+
)
39+
40+
await blobService.setProperties({
41+
cors: [
42+
{
43+
allowedHeaders: '*',
44+
allowedMethods: 'GET,PUT,POST,DELETE,OPTIONS,HEAD',
45+
allowedOrigins: '*',
46+
exposedHeaders: '*',
47+
maxAgeInSeconds: 3600,
48+
},
49+
],
50+
})
51+
52+
containerClient = blobService.getContainerClient(process.env.AZURE_STORAGE_CONTAINER_NAME!)
53+
await containerClient.createIfNotExists()
54+
55+
// Clear leftover blobs from previous runs so the listBlobsFlat assertion
56+
// below only sees uploads from this test.
57+
for await (const blob of containerClient.listBlobsFlat()) {
58+
await containerClient.deleteBlob(blob.name)
59+
}
60+
61+
const context = await browser.newContext()
62+
page = await context.newPage()
63+
await ensureCompilationIsDone({ page, serverURL })
64+
})
65+
66+
/**
67+
* Drives the actual admin form through a clientUploads submit and asserts
68+
* that the user-defined `prefix.defaultValue` ends up on the saved doc — i.e.
69+
* the plugin no longer clobbers the user's callback (see getFields.ts).
70+
*/
71+
test('respects user-defined prefix.defaultValue when creating a doc via clientUploads', async () => {
72+
await page.goto(mediaWithDocPrefixURL.create)
73+
await page.setInputFiles('input[type="file"]', path.resolve(dirname, '../../uploads/image.png'))
74+
await saveDocAndAssert(page)
75+
76+
const docId = page.url().split('/').pop()!
77+
78+
const result = await payloadSDK.find({
79+
collection: mediaWithDocPrefixSlug,
80+
where: { id: { equals: docId } },
81+
})
82+
83+
const doc = result.docs[0]
84+
85+
const blobNames: string[] = []
86+
for await (const blob of containerClient.listBlobsFlat()) {
87+
blobNames.push(blob.name)
88+
}
89+
90+
expect(blobNames).toEqual(
91+
expect.arrayContaining([expect.stringMatching(/^doc-[a-z0-9]{1,8}\/image\.png$/)]),
92+
)
93+
94+
expect(doc?.prefix).toMatch(/^doc-[a-z0-9]{1,8}$/)
95+
})
96+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { ContainerClient } from '@azure/storage-blob'
2+
import type { Payload } from 'payload'
3+
4+
import { BlobServiceClient } from '@azure/storage-blob'
5+
import { readFile } from 'node:fs/promises'
6+
import path from 'path'
7+
import { fileURLToPath } from 'url'
8+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
9+
10+
import type { NextRESTClient } from '../../__helpers/shared/NextRESTClient.js'
11+
12+
import { initPayloadInt } from '../../__helpers/shared/initPayloadInt.js'
13+
import { mediaSlug } from '../shared.js'
14+
import { mediaWithDocPrefixSlug } from './collections/MediaWithDocPrefix.js'
15+
16+
const filename = fileURLToPath(import.meta.url)
17+
const dirname = path.dirname(filename)
18+
19+
let payload: Payload
20+
let restClient: NextRESTClient
21+
let containerClient: ContainerClient
22+
let TEST_CONTAINER: string
23+
24+
describe('@payloadcms/storage-azure clientUploads', () => {
25+
const clearContainer = async () => {
26+
for await (const blob of containerClient.listBlobsFlat()) {
27+
await containerClient.deleteBlob(blob.name)
28+
}
29+
}
30+
31+
beforeAll(async () => {
32+
;({ payload, restClient } = await initPayloadInt(dirname))
33+
34+
TEST_CONTAINER = process.env.AZURE_STORAGE_CONTAINER_NAME!
35+
containerClient = BlobServiceClient.fromConnectionString(
36+
process.env.AZURE_STORAGE_CONNECTION_STRING!,
37+
).getContainerClient(TEST_CONTAINER)
38+
await containerClient.createIfNotExists()
39+
await clearContainer()
40+
}, 90000)
41+
42+
afterAll(async () => {
43+
await payload.destroy()
44+
})
45+
46+
afterEach(async () => {
47+
await clearContainer()
48+
})
49+
50+
/**
51+
* When a doc with the same filename already exists, the signed-URL endpoint
52+
* should sanitize the filename (e.g. `duplicate-target-1.png`) so the
53+
* browser PUT lands on a fresh blob instead of overwriting the existing one.
54+
*/
55+
it('sanitizes the filename when a duplicate already exists', async () => {
56+
const dupFilename = 'duplicate-target.png'
57+
const fileBuffer = await readFile(`${dirname}/../../uploads/image.png`)
58+
59+
const seedForm = new FormData()
60+
seedForm.append('file', new Blob([fileBuffer], { type: 'image/png' }), dupFilename)
61+
const seedRes = await restClient.POST(`/${mediaSlug}`, { body: seedForm })
62+
63+
expect(seedRes.status).toBe(201)
64+
const { doc: seedDoc }: { doc: { filename: string; id: number | string } } =
65+
await seedRes.json()
66+
67+
expect(seedDoc.filename).toBe(dupFilename)
68+
69+
const signedURLRes = await restClient.POST('/storage-azure-generate-signed-url', {
70+
body: JSON.stringify({
71+
collectionSlug: mediaSlug,
72+
filename: dupFilename,
73+
mimeType: 'image/png',
74+
}),
75+
})
76+
77+
expect(signedURLRes.status).toBe(200)
78+
const { url: signedURL }: { url: string } = await signedURLRes.json()
79+
80+
const blobKey = decodeURIComponent(
81+
new URL(signedURL).pathname.replace(`/devstoreaccount1/${TEST_CONTAINER}/`, ''),
82+
)
83+
84+
expect(blobKey).toBe('duplicate-target-1.png')
85+
86+
await payload.delete({ collection: mediaSlug, id: seedDoc.id })
87+
})
88+
89+
it('preserves a user-defined prefix.defaultValue across the plugin', async () => {
90+
const upload = await payload.create({
91+
collection: mediaWithDocPrefixSlug,
92+
data: {},
93+
filePath: path.resolve(dirname, '../../uploads/image.png'),
94+
})
95+
96+
expect(upload.prefix).toMatch(/^doc-[a-z0-9]{1,8}$/)
97+
98+
const props = await containerClient
99+
.getBlobClient(`${upload.prefix}/${upload.filename}`)
100+
.getProperties()
101+
expect(props.contentLength).toBeGreaterThan(0)
102+
})
103+
})

0 commit comments

Comments
 (0)