Skip to content

Commit 3f743d4

Browse files
authored
fix(uploads): treat a missing storage object as absent metadata, not a failure (#6378)
* fix(uploads): treat a missing storage object as absent metadata, not a failure A workspace file is rewritten under a new key on every content update and the superseded object is deleted, so any reader holding the previous key finds nothing. getFileMetadata's provider lookups let that not-found propagate, so authorization's catch-all logged it at ERROR and never reached the branch already written for it. Return the function's established empty value instead, and collapse the three divergent per-provider not-found predicates onto one. * fix(uploads): read the not-found label from code as well as name Azure raises a RestError whose name carries the class and whose code carries the reason, so testing name first and falling back to code only when name was absent missed BlobNotFound outright — narrower than the per-provider check it replaced. * fix(uploads): keep a missing bucket or container out of the not-found path NoSuchBucket and ContainerNotFound also answer 404, so the status-only match read a total storage misconfiguration as an absent object — every file read would fail closed with nothing left to alert on. * fix(uploads): require an object-level label before treating a lookup as absent GCS answers a missing object and a missing bucket identically, so a bare 404 cannot be attributed to the object by a dispatcher that does not know what was requested. getFileMetadata now takes the labelled check and leaves an unlabelled 404 propagating as before; the provider clients keep the lenient form, which is what each already used. * 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. * fix(files): log a missing file at info rather than error when serving Each serve handler rethrows into the outer one, so a superseded key produced two ERROR lines for what is an ordinary 404 — two thirds of this module's error volume. Route all five catch sites through one helper that reserves error for failures that are actually the server's fault, matching how DocCompileUserError is already handled a few lines above. * test(uploads): cover the Blob not-found paths the shared predicate now governs S3 and GCS already asserted absence and non-404 rethrow; Blob asserted neither, so the container-level exclusion went unverified on the one provider whose error puts the reason in code rather than name.
1 parent 77649d3 commit 3f743d4

11 files changed

Lines changed: 362 additions & 52 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@s
77
import { NextRequest } from 'next/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10+
vi.mock('@sim/logger', () => ({
11+
createLogger: vi.fn(() => serveLogger),
12+
logger: serveLogger,
13+
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
14+
getRequestContext: vi.fn(() => undefined),
15+
}))
16+
1017
const {
1118
mockVerifyFileAccess,
1219
mockReadFile,
@@ -18,6 +25,7 @@ const {
1825
mockCreateFileResponse,
1926
mockCreateErrorResponse,
2027
FileNotFoundError,
28+
serveLogger,
2129
} = vi.hoisted(() => {
2230
class FileNotFoundErrorClass extends Error {
2331
constructor(message: string) {
@@ -26,6 +34,7 @@ const {
2634
}
2735
}
2836
return {
37+
serveLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
2938
mockVerifyFileAccess: vi.fn(),
3039
mockReadFile: vi.fn(),
3140
mockIsUsingCloudStorage: vi.fn(),
@@ -232,4 +241,32 @@ describe('File Serve API Route', () => {
232241
})
233242
}
234243
})
244+
245+
describe('failure log level', () => {
246+
it('records a missing file at info, not error', async () => {
247+
/** A superseded key is an ordinary 404, not a server fault. */
248+
const req = new NextRequest('http://localhost:3000/api/files/serve/')
249+
const response = await GET(req, { params: Promise.resolve({ path: [] }) })
250+
251+
expect(response.status).toBe(404)
252+
expect(serveLogger.info).toHaveBeenCalledWith(
253+
'Error serving file:',
254+
expect.objectContaining({ reason: expect.any(String) })
255+
)
256+
expect(serveLogger.error).not.toHaveBeenCalled()
257+
})
258+
259+
it('still records a genuine failure at error', async () => {
260+
mockVerifyFileAccess.mockRejectedValueOnce(new Error('permission backend down'))
261+
262+
const req = new NextRequest(
263+
'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt'
264+
)
265+
await GET(req, {
266+
params: Promise.resolve({ path: ['workspace', 'ws', 'test-file.txt'] }),
267+
}).catch(() => undefined)
268+
269+
expect(serveLogger.error).toHaveBeenCalled()
270+
})
271+
})
235272
})

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ import {
2626

2727
const logger = createLogger('FilesServeAPI')
2828

29+
/**
30+
* Records a failed serve at a level that matches whose fault it is.
31+
*
32+
* A file that is not there is an ordinary answer rather than a server fault: a
33+
* workspace file is rewritten under a new key on every content update, so a reader
34+
* holding the previous key lands here routinely and correctly receives a 404. Each
35+
* handler rethrows into the outer one, so logging those at `error` reports the same
36+
* expected 404 twice and buries the failures that do warrant attention.
37+
*/
38+
function logServeFailure(message: string, error: unknown): void {
39+
if (error instanceof FileNotFoundError) {
40+
logger.info(message, { reason: error.message })
41+
return
42+
}
43+
logger.error(message, error)
44+
}
45+
2946
interface ServeOptions {
3047
/** `raw=1` — bypass all resolution and serve the stored source as-is. */
3148
raw: boolean
@@ -179,7 +196,7 @@ export const GET = withRouteHandler(
179196
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
180197
}
181198

182-
logger.error('Error serving file:', error)
199+
logServeFailure('Error serving file:', error)
183200

184201
if (error instanceof FileNotFoundError) {
185202
return createErrorResponse(error)
@@ -244,7 +261,7 @@ async function handleLocalFile(
244261
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
245262
})
246263
} catch (error) {
247-
logger.error('Error reading local file:', error)
264+
logServeFailure('Error reading local file:', error)
248265
throw error
249266
}
250267
}
@@ -311,7 +328,7 @@ async function handleCloudProxy(
311328
cacheControl: resolveServeCacheControl(options.versioned, context),
312329
})
313330
} catch (error) {
314-
logger.error('Error downloading from cloud storage:', error)
331+
logServeFailure('Error downloading from cloud storage:', error)
315332
throw error
316333
}
317334
}
@@ -348,7 +365,7 @@ async function handleCloudProxyPublic(
348365
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
349366
})
350367
} catch (error) {
351-
logger.error('Error serving public cloud file:', error)
368+
logServeFailure('Error serving public cloud file:', error)
352369
throw error
353370
}
354371
}
@@ -373,7 +390,7 @@ async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
373390
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
374391
})
375392
} catch (error) {
376-
logger.error('Error reading public local file:', error)
393+
logServeFailure('Error reading public local file:', error)
377394
throw error
378395
}
379396
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
6+
7+
describe('isObjectNotFoundError', () => {
8+
it('matches the shapes each storage provider uses for a missing object', () => {
9+
/** S3 HeadObject, exactly as the production payload arrived. */
10+
expect(
11+
isObjectNotFoundError({
12+
name: 'NotFound',
13+
$fault: 'client',
14+
$metadata: { httpStatusCode: 404 },
15+
})
16+
).toBe(true)
17+
/** S3 GetObject. */
18+
expect(isObjectNotFoundError({ name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } })).toBe(
19+
true
20+
)
21+
/** Azure Blob. */
22+
expect(isObjectNotFoundError({ code: 'BlobNotFound', statusCode: 404 })).toBe(true)
23+
/** GCS, which reports a numeric code. */
24+
expect(isObjectNotFoundError({ code: 404 })).toBe(true)
25+
})
26+
27+
it('reads the label from code when name carries the error class instead', () => {
28+
/** Azure raises a `RestError`; the reason lives in `code`, not `name`. */
29+
expect(isObjectNotFoundError({ name: 'RestError', code: 'BlobNotFound' })).toBe(true)
30+
expect(isObjectNotFoundError({ name: 'Error', code: 'NoSuchKey' })).toBe(true)
31+
})
32+
33+
it('does not read a missing bucket or container as an absent object', () => {
34+
/**
35+
* These answer 404 too. Reading them as absence would turn a total storage
36+
* misconfiguration into silent fail-closed reads with nothing to alert on.
37+
*/
38+
expect(
39+
isObjectNotFoundError({ name: 'NoSuchBucket', $metadata: { httpStatusCode: 404 } })
40+
).toBe(false)
41+
expect(
42+
isObjectNotFoundError({ name: 'RestError', code: 'ContainerNotFound', statusCode: 404 })
43+
).toBe(false)
44+
})
45+
46+
it('matches on status alone when the provider sends no label', () => {
47+
expect(isObjectNotFoundError({ $metadata: { httpStatusCode: 404 } })).toBe(true)
48+
expect(isObjectNotFoundError({ statusCode: 404 })).toBe(true)
49+
})
50+
51+
it('does not swallow a genuine failure', () => {
52+
expect(
53+
isObjectNotFoundError({ name: 'AccessDenied', $metadata: { httpStatusCode: 403 } })
54+
).toBe(false)
55+
expect(
56+
isObjectNotFoundError({ name: 'InternalError', $metadata: { httpStatusCode: 500 } })
57+
).toBe(false)
58+
expect(isObjectNotFoundError({ name: 'TimeoutError' })).toBe(false)
59+
expect(isObjectNotFoundError({ code: 'ECONNRESET' })).toBe(false)
60+
expect(isObjectNotFoundError({ code: 403 })).toBe(false)
61+
})
62+
63+
it('tolerates values that are not error objects', () => {
64+
expect(isObjectNotFoundError(null)).toBe(false)
65+
expect(isObjectNotFoundError(undefined)).toBe(false)
66+
expect(isObjectNotFoundError('NotFound')).toBe(false)
67+
expect(isObjectNotFoundError(404)).toBe(false)
68+
})
69+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const OBJECT_NOT_FOUND_LABELS = new Set(['NotFound', 'NoSuchKey', 'BlobNotFound'])
2+
3+
/**
4+
* A missing bucket or container is a misconfiguration, not an absent object, and
5+
* it also answers 404. Without this it would read as "no metadata" and every file
6+
* read would fail closed with no error to alert on.
7+
*/
8+
const CONTAINER_NOT_FOUND_LABELS = new Set(['NoSuchBucket', 'ContainerNotFound'])
9+
10+
function readLabels(error: unknown): string[] | null {
11+
if (!error || typeof error !== 'object') return null
12+
const { name, code } = error as { name?: unknown; code?: unknown }
13+
/**
14+
* `name` and `code` are both consulted: Azure raises a `RestError` whose `name`
15+
* carries the class and whose `code` carries the reason, while the AWS SDK puts
16+
* the reason in `name`.
17+
*/
18+
return [name, code].filter((value): value is string => typeof value === 'string')
19+
}
20+
21+
/**
22+
* True when a storage provider reports that an object does not exist.
23+
*
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.
32+
*
33+
* A network failure, a permission denial, or a provider 5xx still propagates.
34+
*/
35+
export function isObjectNotFoundError(error: unknown): boolean {
36+
const labels = readLabels(error)
37+
if (!labels) return false
38+
if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false
39+
if (labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))) return true
40+
41+
const { code, statusCode, $metadata } = error as {
42+
code?: unknown
43+
statusCode?: unknown
44+
$metadata?: { httpStatusCode?: unknown }
45+
}
46+
return code === 404 || statusCode === 404 || $metadata?.httpStatusCode === 404
47+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetFileMetadataByKey, mockHeadS3Object } = vi.hoisted(() => ({
7+
mockGetFileMetadataByKey: vi.fn(),
8+
mockHeadS3Object: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/uploads/config', () => ({
12+
USE_S3_STORAGE: true,
13+
USE_BLOB_STORAGE: false,
14+
USE_GCS_STORAGE: false,
15+
S3_CONFIG: { bucket: 'bucket', region: 'region' },
16+
}))
17+
18+
vi.mock('@/lib/uploads/providers/s3/client', () => ({
19+
headS3Object: mockHeadS3Object,
20+
}))
21+
22+
vi.mock('@/lib/uploads/server/metadata', () => ({
23+
getFileMetadataByKey: mockGetFileMetadataByKey,
24+
}))
25+
26+
import { getFileMetadata } from '@/lib/uploads/core/storage-client'
27+
28+
describe('getFileMetadata', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
mockGetFileMetadataByKey.mockResolvedValue(null)
32+
})
33+
34+
it('reports an absent object as no metadata rather than throwing', async () => {
35+
/** The provider client owns not-found and reports absence as `null`. */
36+
mockHeadS3Object.mockResolvedValue(null)
37+
38+
await expect(getFileMetadata('workspace/ws/superseded-key.md')).resolves.toEqual({})
39+
})
40+
41+
it('still propagates a genuine storage failure', async () => {
42+
mockHeadS3Object.mockRejectedValue(
43+
Object.assign(new Error('AccessDenied'), {
44+
name: 'AccessDenied',
45+
$metadata: { httpStatusCode: 403 },
46+
})
47+
)
48+
49+
await expect(getFileMetadata('workspace/ws/key.md')).rejects.toThrow('AccessDenied')
50+
})
51+
52+
it('returns provider metadata when the object exists', async () => {
53+
mockHeadS3Object.mockResolvedValue({ size: 12, metadata: { workspaceid: 'ws-1' } })
54+
55+
await expect(getFileMetadata('workspace/ws/key.md')).resolves.toEqual({ workspaceid: 'ws-1' })
56+
})
57+
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+
64+
it('prefers the database record when one exists', async () => {
65+
mockGetFileMetadataByKey.mockResolvedValue({
66+
userId: 'user-1',
67+
workspaceId: 'ws-1',
68+
originalName: 'doc.md',
69+
uploadedAt: new Date('2026-01-01T00:00:00Z'),
70+
context: 'workspace',
71+
})
72+
73+
const metadata = await getFileMetadata('workspace/ws/key.md')
74+
75+
expect(metadata.workspaceId).toBe('ws-1')
76+
expect(mockHeadS3Object).not.toHaveBeenCalled()
77+
})
78+
})

0 commit comments

Comments
 (0)