|
| 1 | +/** |
| 2 | + * Runs the real AWS SigV4 presigner (signing is local, no network) to pin where |
| 3 | + * direct-upload metadata travels. A header the uploader must send but that is |
| 4 | + * missing from `headers`, or a metadata key both signed as a header and hoisted |
| 5 | + * into the query, makes the provider reject or strip the upload. |
| 6 | + */ |
| 7 | +import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' |
| 8 | +import { setUploadsConfig, uploadsConfigMock } from '@sim/testing/mocks/uploads-config.mock' |
| 9 | +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 10 | + |
| 11 | +vi.mock('@/lib/uploads/config', () => uploadsConfigMock) |
| 12 | + |
| 13 | +import { getS3PresignedUploadUrl, resetS3ClientForTesting } from '@/lib/uploads/providers/s3/client' |
| 14 | + |
| 15 | +setEnv({ AWS_ACCESS_KEY_ID: 'test-access-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key' }) |
| 16 | +afterAll(resetEnvMock) |
| 17 | + |
| 18 | +/** Headers the browser sets itself and the uploader never supplies. */ |
| 19 | +const TRANSPORT_HEADERS = new Set(['host', 'content-length']) |
| 20 | + |
| 21 | +function configure(endpoint: string | undefined, forcePathStyle = false) { |
| 22 | + setUploadsConfig({ |
| 23 | + S3_CONFIG: { bucket: 'sim-files', region: 'de', endpoint, forcePathStyle }, |
| 24 | + }) |
| 25 | + resetS3ClientForTesting() |
| 26 | +} |
| 27 | + |
| 28 | +async function presign() { |
| 29 | + const transfer = await getS3PresignedUploadUrl({ |
| 30 | + key: 'kb/upload-1/report.pdf', |
| 31 | + contentType: 'application/pdf', |
| 32 | + fileSize: 3, |
| 33 | + metadata: { uploadId: 'upload-1', originalName: 'Q3 report.pdf' }, |
| 34 | + customConfig: { bucket: 'sim-files', region: 'de' }, |
| 35 | + expiresIn: 600, |
| 36 | + }) |
| 37 | + const url = new URL(transfer.url) |
| 38 | + const signedHeaders = (url.searchParams.get('X-Amz-SignedHeaders') ?? '').split(';') |
| 39 | + const queryMetadata = [...url.searchParams.keys()].filter((k) => |
| 40 | + k.toLowerCase().startsWith('x-amz-meta-') |
| 41 | + ) |
| 42 | + const suppliedHeaders = new Set(Object.keys(transfer.headers).map((k) => k.toLowerCase())) |
| 43 | + return { transfer, signedHeaders, queryMetadata, suppliedHeaders } |
| 44 | +} |
| 45 | + |
| 46 | +describe('getS3PresignedUploadUrl', () => { |
| 47 | + beforeEach(() => configure(undefined)) |
| 48 | + |
| 49 | + it('signs metadata as uploader-sent headers for a custom S3-compatible endpoint', async () => { |
| 50 | + configure('https://s3.de.io.cloud.ovh.net') |
| 51 | + const { transfer, signedHeaders, queryMetadata } = await presign() |
| 52 | + |
| 53 | + expect(queryMetadata).toEqual([]) |
| 54 | + expect(signedHeaders).toEqual( |
| 55 | + expect.arrayContaining(['x-amz-meta-uploadid', 'x-amz-meta-originalname']) |
| 56 | + ) |
| 57 | + expect(transfer.headers).toMatchObject({ |
| 58 | + 'x-amz-meta-uploadid': 'upload-1', |
| 59 | + 'x-amz-meta-originalname': 'Q3 report.pdf', |
| 60 | + }) |
| 61 | + }) |
| 62 | + |
| 63 | + it('keeps AWS metadata in the signed query so existing bucket CORS rules still apply', async () => { |
| 64 | + const { signedHeaders, queryMetadata } = await presign() |
| 65 | + |
| 66 | + expect(queryMetadata.sort()).toEqual(['x-amz-meta-originalname', 'x-amz-meta-uploadid']) |
| 67 | + expect(signedHeaders.some((h) => h.startsWith('x-amz-meta-'))).toBe(false) |
| 68 | + }) |
| 69 | + |
| 70 | + it.each([ |
| 71 | + ['AWS', undefined], |
| 72 | + ['custom endpoint', 'https://s3.de.io.cloud.ovh.net'], |
| 73 | + ])('supplies every signed header the uploader controls (%s)', async (_, endpoint) => { |
| 74 | + configure(endpoint) |
| 75 | + const { signedHeaders, suppliedHeaders, queryMetadata } = await presign() |
| 76 | + |
| 77 | + for (const header of signedHeaders) { |
| 78 | + if (!TRANSPORT_HEADERS.has(header)) expect(suppliedHeaders).toContain(header) |
| 79 | + } |
| 80 | + for (const key of queryMetadata) expect(suppliedHeaders).not.toContain(key.toLowerCase()) |
| 81 | + }) |
| 82 | +}) |
0 commit comments