Skip to content

Commit bcde675

Browse files
committed
refactor(uploads): let getFileMetadata delegate to the provider head helpers
getFileMetadata re-implemented the S3 and Blob HEAD calls inline, so it had to inspect provider errors itself and needed a second, stricter predicate to do it safely. headS3Object and headBlobObject already perform exactly those calls and already report absence as null, so delegating removes the duplication, the error inspection, and the extra predicate at once. GCS keeps raising, as before. Covers the real provider path in the S3 client's own suite, where mocking the seam had been hiding whether the two layers agree.
1 parent 48133e9 commit bcde675

5 files changed

Lines changed: 92 additions & 126 deletions

File tree

apps/sim/lib/uploads/core/errors.test.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { hasObjectNotFoundLabel, isObjectNotFoundError } from '@/lib/uploads/core/errors'
5+
import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
66

77
describe('isObjectNotFoundError', () => {
88
it('matches the shapes each storage provider uses for a missing object', () => {
@@ -67,23 +67,3 @@ describe('isObjectNotFoundError', () => {
6767
expect(isObjectNotFoundError(404)).toBe(false)
6868
})
6969
})
70-
71-
describe('hasObjectNotFoundLabel', () => {
72-
it('accepts only a provider that names the object as the missing resource', () => {
73-
expect(hasObjectNotFoundLabel({ name: 'NotFound' })).toBe(true)
74-
expect(hasObjectNotFoundLabel({ name: 'NoSuchKey' })).toBe(true)
75-
expect(hasObjectNotFoundLabel({ name: 'RestError', code: 'BlobNotFound' })).toBe(true)
76-
})
77-
78-
it('rejects an unlabelled 404, which cannot be attributed to the object', () => {
79-
/** GCS answers a missing object and a missing bucket identically. */
80-
expect(hasObjectNotFoundLabel({ code: 404 })).toBe(false)
81-
expect(hasObjectNotFoundLabel({ statusCode: 404 })).toBe(false)
82-
expect(hasObjectNotFoundLabel({ $metadata: { httpStatusCode: 404 } })).toBe(false)
83-
})
84-
85-
it('rejects a missing bucket or container', () => {
86-
expect(hasObjectNotFoundLabel({ name: 'NoSuchBucket' })).toBe(false)
87-
expect(hasObjectNotFoundLabel({ name: 'RestError', code: 'ContainerNotFound' })).toBe(false)
88-
})
89-
})

apps/sim/lib/uploads/core/errors.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,17 @@ function readLabels(error: unknown): string[] | null {
1818
return [name, code].filter((value): value is string => typeof value === 'string')
1919
}
2020

21-
/**
22-
* True when a provider names the missing resource as the object itself.
23-
*
24-
* The strict form. Use it wherever the caller cannot vouch for what was requested,
25-
* because an unlabelled 404 is ambiguous: GCS answers a missing object and a
26-
* missing bucket identically (`code: 404`, `errors[].reason: 'notFound'`), so only
27-
* the human-readable message separates them. Treating that as absence would let a
28-
* bucket misconfiguration read as "no metadata" and fail every read closed with
29-
* nothing left to alert on, so it stays an error here.
30-
*/
31-
export function hasObjectNotFoundLabel(error: unknown): boolean {
32-
const labels = readLabels(error)
33-
if (!labels) return false
34-
if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false
35-
return labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))
36-
}
37-
3821
/**
3922
* True when a storage provider reports that an object does not exist.
4023
*
41-
* The lenient form, for a caller that has just performed an object-level operation
42-
* and can therefore attribute a bare 404 to that object — which is what every
43-
* provider client here does, and how each spelled this check before it was shared.
44-
* It additionally accepts a bare 404 (`code`, `statusCode`, or
45-
* `$metadata.httpStatusCode`), which GCS relies on since it carries no label.
24+
* Call this only from code that has just performed an object-level operation, so a
25+
* bare 404 can be attributed to that object. A bare 404 is otherwise ambiguous —
26+
* GCS answers a missing object and a missing bucket identically (`code: 404`,
27+
* `errors[].reason: 'notFound'`), separable only by a human-readable message — and
28+
* every caller here is a provider client that knows exactly what it asked for.
29+
*
30+
* Absence is an expected outcome of a lookup, so callers turn it into an empty
31+
* result rather than propagating it.
4632
*
4733
* A network failure, a permission denial, or a provider 5xx still propagates.
4834
*/

apps/sim/lib/uploads/core/storage-client.test.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,9 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockGetFileMetadataByKey, mockSend, mockHeadObjectCommand } = vi.hoisted(() => ({
6+
const { mockGetFileMetadataByKey, mockHeadS3Object } = vi.hoisted(() => ({
77
mockGetFileMetadataByKey: vi.fn(),
8-
mockSend: vi.fn(),
9-
mockHeadObjectCommand: vi.fn().mockImplementation(class {}),
10-
}))
11-
12-
vi.mock('@aws-sdk/client-s3', () => ({
13-
HeadObjectCommand: mockHeadObjectCommand,
8+
mockHeadS3Object: vi.fn(),
149
}))
1510

1611
vi.mock('@/lib/uploads/config', () => ({
@@ -21,7 +16,7 @@ vi.mock('@/lib/uploads/config', () => ({
2116
}))
2217

2318
vi.mock('@/lib/uploads/providers/s3/client', () => ({
24-
getS3Client: () => ({ send: mockSend }),
19+
headS3Object: mockHeadS3Object,
2520
}))
2621

2722
vi.mock('@/lib/uploads/server/metadata', () => ({
@@ -30,41 +25,42 @@ vi.mock('@/lib/uploads/server/metadata', () => ({
3025

3126
import { getFileMetadata } from '@/lib/uploads/core/storage-client'
3227

33-
/** The exact error the AWS SDK raises from HeadObject for an absent object. */
34-
const notFound = Object.assign(new Error('NotFound'), {
35-
name: 'NotFound',
36-
$fault: 'client',
37-
$metadata: { httpStatusCode: 404 },
38-
})
39-
4028
describe('getFileMetadata', () => {
4129
beforeEach(() => {
4230
vi.clearAllMocks()
4331
mockGetFileMetadataByKey.mockResolvedValue(null)
4432
})
4533

4634
it('reports an absent object as no metadata rather than throwing', async () => {
47-
mockSend.mockRejectedValue(notFound)
35+
/** The provider client owns not-found and reports absence as `null`. */
36+
mockHeadS3Object.mockResolvedValue(null)
4837

4938
await expect(getFileMetadata('workspace/ws/superseded-key.md')).resolves.toEqual({})
5039
})
5140

5241
it('still propagates a genuine storage failure', async () => {
53-
const denied = Object.assign(new Error('AccessDenied'), {
54-
name: 'AccessDenied',
55-
$metadata: { httpStatusCode: 403 },
56-
})
57-
mockSend.mockRejectedValue(denied)
42+
mockHeadS3Object.mockRejectedValue(
43+
Object.assign(new Error('AccessDenied'), {
44+
name: 'AccessDenied',
45+
$metadata: { httpStatusCode: 403 },
46+
})
47+
)
5848

5949
await expect(getFileMetadata('workspace/ws/key.md')).rejects.toThrow('AccessDenied')
6050
})
6151

6252
it('returns provider metadata when the object exists', async () => {
63-
mockSend.mockResolvedValue({ Metadata: { workspaceid: 'ws-1' } })
53+
mockHeadS3Object.mockResolvedValue({ size: 12, metadata: { workspaceid: 'ws-1' } })
6454

6555
await expect(getFileMetadata('workspace/ws/key.md')).resolves.toEqual({ workspaceid: 'ws-1' })
6656
})
6757

58+
it('treats an object carrying no metadata as no metadata', async () => {
59+
mockHeadS3Object.mockResolvedValue({ size: 12 })
60+
61+
await expect(getFileMetadata('workspace/ws/key.md')).resolves.toEqual({})
62+
})
63+
6864
it('prefers the database record when one exists', async () => {
6965
mockGetFileMetadataByKey.mockResolvedValue({
7066
userId: 'user-1',
@@ -77,6 +73,6 @@ describe('getFileMetadata', () => {
7773
const metadata = await getFileMetadata('workspace/ws/key.md')
7874

7975
expect(metadata.workspaceId).toBe('ws-1')
80-
expect(mockSend).not.toHaveBeenCalled()
76+
expect(mockHeadS3Object).not.toHaveBeenCalled()
8177
})
8278
})

apps/sim/lib/uploads/core/storage-client.ts

Lines changed: 25 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { USE_BLOB_STORAGE, USE_GCS_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
2-
import { hasObjectNotFoundLabel } from '@/lib/uploads/core/errors'
32
import type { StorageConfig } from '@/lib/uploads/shared/types'
43

54
export type { StorageConfig } from '@/lib/uploads/shared/types'
@@ -30,29 +29,6 @@ export function getServePathPrefix(): string {
3029
export async function getFileMetadata(
3130
key: string,
3231
customConfig?: StorageConfig
33-
): Promise<Record<string, string>> {
34-
try {
35-
return await readProviderMetadata(key, customConfig)
36-
} catch (error) {
37-
/**
38-
* A key that no longer resolves is an ordinary outcome — a workspace file is
39-
* rewritten under a new key on every content update, so any reader holding the
40-
* previous key finds nothing. Report it the way this function already reports
41-
* "nothing known about this key" rather than as a failure, so callers fall
42-
* through to their own not-found handling instead of an error path.
43-
*
44-
* Deliberately the labelled check: this dispatches across every provider and so
45-
* cannot attribute a bare 404 to the object rather than its bucket. An
46-
* unlabelled 404 keeps propagating, exactly as it did before.
47-
*/
48-
if (hasObjectNotFoundLabel(error)) return {}
49-
throw error
50-
}
51-
}
52-
53-
async function readProviderMetadata(
54-
key: string,
55-
customConfig?: StorageConfig
5632
): Promise<Record<string, string>> {
5733
const { getFileMetadataByKey } = await import('../server/metadata')
5834
const metadataRecord = await getFileMetadataByKey(key)
@@ -68,57 +44,46 @@ async function readProviderMetadata(
6844
}
6945

7046
if (USE_BLOB_STORAGE) {
71-
const { getBlobServiceClient } = await import('@/lib/uploads/providers/blob/client')
47+
const { headBlobObject } = await import('@/lib/uploads/providers/blob/client')
7248
const { BLOB_CONFIG } = await import('@/lib/uploads/config')
73-
74-
let blobServiceClient = await getBlobServiceClient()
75-
let containerName = BLOB_CONFIG.containerName
76-
77-
if (customConfig) {
78-
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
79-
if (customConfig.connectionString) {
80-
blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString)
81-
} else if (customConfig.accountName && customConfig.accountKey) {
82-
const credential = new StorageSharedKeyCredential(
83-
customConfig.accountName,
84-
customConfig.accountKey
85-
)
86-
blobServiceClient = new BlobServiceClient(
87-
`https://${customConfig.accountName}.blob.core.windows.net`,
88-
credential
89-
)
90-
}
91-
containerName = customConfig.containerName || containerName
92-
}
93-
94-
const containerClient = blobServiceClient.getContainerClient(containerName)
95-
const blockBlobClient = containerClient.getBlockBlobClient(key)
96-
const properties = await blockBlobClient.getProperties()
97-
return properties.metadata || {}
49+
/** `headBlobObject` rejects a config that names no credentials, so only pass one that does. */
50+
const credentialed = Boolean(
51+
customConfig?.connectionString || (customConfig?.accountName && customConfig?.accountKey)
52+
)
53+
const object = await headBlobObject(
54+
key,
55+
credentialed
56+
? {
57+
...customConfig,
58+
containerName: customConfig?.containerName || BLOB_CONFIG.containerName,
59+
}
60+
: undefined
61+
)
62+
return object?.metadata || {}
9863
}
9964

10065
if (USE_S3_STORAGE) {
101-
const { getS3Client } = await import('@/lib/uploads/providers/s3/client')
102-
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
66+
const { headS3Object } = await import('@/lib/uploads/providers/s3/client')
10367
const { S3_CONFIG } = await import('@/lib/uploads/config')
104-
105-
const s3Client = getS3Client()
10668
const bucket = customConfig?.bucket || S3_CONFIG.bucket
10769

10870
if (!bucket) {
10971
throw new Error('S3 bucket not configured')
11072
}
11173

112-
const command = new HeadObjectCommand({
113-
Bucket: bucket,
114-
Key: key,
74+
const object = await headS3Object(key, {
75+
bucket,
76+
region: customConfig?.region || S3_CONFIG.region,
11577
})
116-
117-
const response = await s3Client.send(command)
118-
return response.Metadata || {}
78+
return object?.metadata || {}
11979
}
12080

12181
if (USE_GCS_STORAGE) {
82+
/**
83+
* Unlike the other two, this raises on a missing object rather than reporting
84+
* absence, because GCS answers a missing object and a missing bucket the same
85+
* way and only the caller's own bucket configuration separates them.
86+
*/
12287
const { getGcsObjectMetadata } = await import('@/lib/uploads/providers/gcs/client')
12388
return getGcsObjectMetadata(
12489
key,

apps/sim/lib/uploads/providers/s3/client.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,45 @@ describe('S3 Client', () => {
213213
metadata: { simuploadid: 'receipt-1' },
214214
})
215215
})
216+
217+
it('reports an absent object as null rather than raising', async () => {
218+
/**
219+
* A workspace file is rewritten under a new key on every content update, so a
220+
* reader holding the previous key lands here routinely. Absence is the answer,
221+
* not a failure.
222+
*/
223+
mockSend.mockRejectedValueOnce(
224+
Object.assign(new Error('NotFound'), {
225+
name: 'NotFound',
226+
$metadata: { httpStatusCode: 404 },
227+
})
228+
)
229+
230+
await expect(headS3Object('workspace/superseded.md')).resolves.toBeNull()
231+
})
232+
233+
it('raises when the bucket itself is missing', async () => {
234+
/** Also a 404, but a misconfiguration — reporting absence would hide an outage. */
235+
mockSend.mockRejectedValueOnce(
236+
Object.assign(new Error('NoSuchBucket'), {
237+
name: 'NoSuchBucket',
238+
$metadata: { httpStatusCode: 404 },
239+
})
240+
)
241+
242+
await expect(headS3Object('workspace/file.txt')).rejects.toThrow('NoSuchBucket')
243+
})
244+
245+
it('raises on a permission failure', async () => {
246+
mockSend.mockRejectedValueOnce(
247+
Object.assign(new Error('AccessDenied'), {
248+
name: 'AccessDenied',
249+
$metadata: { httpStatusCode: 403 },
250+
})
251+
)
252+
253+
await expect(headS3Object('workspace/file.txt')).rejects.toThrow('AccessDenied')
254+
})
216255
})
217256

218257
describe('getPresignedUrl', () => {

0 commit comments

Comments
 (0)