diff --git a/migrations/tenant/0057-s3-multipart-uploads-metadata.sql b/migrations/tenant/0057-s3-multipart-uploads-metadata.sql new file mode 100644 index 00000000..ef9496bb --- /dev/null +++ b/migrations/tenant/0057-s3-multipart-uploads-metadata.sql @@ -0,0 +1 @@ +ALTER TABLE storage.s3_multipart_uploads ADD COLUMN IF NOT EXISTS metadata jsonb NULL; \ No newline at end of file diff --git a/src/http/routes/object/getSignedUploadURL.ts b/src/http/routes/object/getSignedUploadURL.ts index 5a68afa3..6016cb89 100644 --- a/src/http/routes/object/getSignedUploadURL.ts +++ b/src/http/routes/object/getSignedUploadURL.ts @@ -4,6 +4,7 @@ import { createDefaultSchema } from '../../routes-helper' import { AuthenticatedRequest } from '../../types' import { getConfig } from '../../../config' import { ROUTE_OPERATIONS } from '../operations' +import { parseUserMetadata } from '../../../storage/uploader' const { uploadSignedUrlExpirationTime } = getConfig() @@ -20,6 +21,8 @@ const getSignedUploadURLHeadersSchema = { type: 'object', properties: { 'x-upsert': { type: 'string' }, + 'content-type': { type: 'string' }, + 'content-length': { type: 'string' }, authorization: { type: 'string' }, }, required: ['authorization'], @@ -69,10 +72,27 @@ export default async function routes(fastify: FastifyInstance) { const urlPath = `${bucketName}/${objectName}` + let userMetadata: Record | undefined + + const customMd = request.headers['x-metadata'] + + if (typeof customMd === 'string') { + userMetadata = parseUserMetadata(customMd) + } + + const contentType = request.headers['content-type'] + const contentLengthHeader = request.headers['content-length'] + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined + const signedUpload = await request.storage .from(bucketName) .signUploadObjectUrl(objectName, urlPath as string, uploadSignedUrlExpirationTime, owner, { upsert: request.headers['x-upsert'] === 'true', + userMetadata, + metadata: { + mimetype: contentType, + contentLength, + }, }) return response.status(200).send({ url: signedUpload.url, token: signedUpload.token }) diff --git a/src/http/routes/tus/index.ts b/src/http/routes/tus/index.ts index 097c5a03..8700500d 100644 --- a/src/http/routes/tus/index.ts +++ b/src/http/routes/tus/index.ts @@ -137,7 +137,7 @@ function createTusServer( namingFunction: namingFunction, onUploadCreate: onCreate, onUploadFinish: onUploadFinish, - onIncomingRequest: onIncomingRequest, + onIncomingRequest: (req, id) => onIncomingRequest(req, id, datastore), generateUrl: generateUrl, getFileIdFromRequest: getFileIdFromRequest, onResponseError: onResponseError, diff --git a/src/http/routes/tus/lifecycle.ts b/src/http/routes/tus/lifecycle.ts index a3471648..deee5bd5 100644 --- a/src/http/routes/tus/lifecycle.ts +++ b/src/http/routes/tus/lifecycle.ts @@ -1,6 +1,6 @@ import http from 'http' import { BaseLogger } from 'pino' -import { Upload } from '@tus/server' +import { DataStore, Metadata, Upload } from '@tus/server' import { randomUUID } from 'crypto' import { TenantConnection } from '@internal/database' import { ERRORS, isRenderableError } from '@internal/errors' @@ -44,7 +44,7 @@ export type MultiPartRequest = http.IncomingMessage & { /** * Runs on every TUS incoming request */ -export async function onIncomingRequest(rawReq: Request, id: string) { +export async function onIncomingRequest(rawReq: Request, id: string, datastore: DataStore) { const req = getNodeRequest(rawReq) const res = rawReq.node?.res as http.ServerResponse @@ -92,11 +92,53 @@ export async function onIncomingRequest(rawReq: Request, id: string) { req.upload.storage.location ) + let contentType: string | undefined + let contentLength: number | undefined + let rawMetadata: string | null | undefined + + if (req.method === 'POST') { + const uploadMetadataHeader = req.headers['upload-metadata'] + if (uploadMetadataHeader && typeof uploadMetadataHeader === 'string') { + try { + const parsedMetadata = Metadata.parse(uploadMetadataHeader) + contentType = parsedMetadata?.contentType ?? undefined + rawMetadata = parsedMetadata?.metadata + } catch (e) { + req.log.warn({ error: e }, 'Failed to parse upload metadata') + throw ERRORS.InvalidParameter('upload-metadata', { + error: e as Error, + message: 'Invalid Upload-Metadata header', + }) + } + } + const uploadLength = req.headers['upload-length'] + contentLength = uploadLength ? Number(uploadLength) : undefined + } else { + const upload = await datastore.getUpload(id) + contentType = upload.metadata?.contentType ?? undefined + contentLength = upload.size ?? undefined + rawMetadata = upload.metadata?.metadata + } + + let customMd: Record | undefined + if (rawMetadata) { + try { + customMd = JSON.parse(rawMetadata) + } catch (e) { + req.log.warn({ error: e }, 'Failed to parse user metadata') + } + } + await uploader.canUpload({ owner: req.upload.owner, bucketId: uploadID.bucket, objectName: uploadID.objectName, isUpsert: isUpsert, + userMetadata: customMd, + metadata: { + mimetype: contentType, + contentLength: contentLength, + }, }) } diff --git a/src/internal/database/migrations/types.ts b/src/internal/database/migrations/types.ts index 35bd839c..1abbd291 100644 --- a/src/internal/database/migrations/types.ts +++ b/src/internal/database/migrations/types.ts @@ -56,4 +56,5 @@ export const DBMigration = { 'drop-index-object-level': 54, 'prevent-direct-deletes': 55, 'fix-optimized-search-function': 56, + 's3-multipart-uploads-metadata': 57, } diff --git a/src/storage/database/adapter.ts b/src/storage/database/adapter.ts index 8cba19eb..e2285fef 100644 --- a/src/storage/database/adapter.ts +++ b/src/storage/database/adapter.ts @@ -195,7 +195,8 @@ export interface Database { version: string, signature: string, owner?: string, - metadata?: Record + userMetadata?: Record, + metadata?: Partial ): Promise findMultipartUpload( diff --git a/src/storage/database/knex.ts b/src/storage/database/knex.ts index 9d27318b..d4214738 100644 --- a/src/storage/database/knex.ts +++ b/src/storage/database/knex.ts @@ -904,22 +904,30 @@ export class StorageKnexDB implements Database { version: string, signature: string, owner?: string, - metadata?: Record + userMetadata?: Record, + metadata?: Partial ) { return this.runQuery('CreateMultipartUpload', async (knex, signal) => { + const data: Record = { + id: uploadId, + bucket_id: bucketId, + key: objectName, + version, + upload_signature: signature, + owner_id: owner, + user_metadata: userMetadata, + } + + if ( + !this.latestMigration || + DBMigration[this.latestMigration] >= DBMigration['s3-multipart-uploads-metadata'] + ) { + data.metadata = metadata + } + const multipart = await knex .table('s3_multipart_uploads') - .insert( - this.normalizeColumns({ - id: uploadId, - bucket_id: bucketId, - key: objectName, - version, - upload_signature: signature, - owner_id: owner, - user_metadata: metadata, - }) - ) + .insert(this.normalizeColumns(data)) .returning('*') .abortOnSignal(signal) diff --git a/src/storage/object.ts b/src/storage/object.ts index 4818ad13..73b17e4e 100644 --- a/src/storage/object.ts +++ b/src/storage/object.ts @@ -6,7 +6,7 @@ import { getJwtSecret } from '@internal/database' import { ObjectMetadata, StorageBackendAdapter } from './backend' import { Database, FindObjectFilters, SearchObjectOption } from './database' import { mustBeValidKey } from './limits' -import { fileUploadFromRequest, Uploader, UploadRequest } from './uploader' +import { fileUploadFromRequest, Uploader, UploadRequest, CanUploadMetadata } from './uploader' import { getConfig } from '../config' import { ObjectAdminDelete, @@ -98,6 +98,7 @@ export class ObjectStorage { owner: file.owner, isUpsert: Boolean(file.isUpsert), signal: file.signal, + userMetadata: uploadRequest.userMetadata, }) } @@ -339,6 +340,8 @@ export class ObjectStorage { objectName: destinationKey, owner, isUpsert: upsert, + userMetadata: userMetadata, + metadata: destinationMetadata, }) try { @@ -792,7 +795,11 @@ export class ObjectStorage { url: string, expiresIn: number, owner?: string, - options?: { upsert?: boolean } + options?: { + upsert?: boolean + userMetadata?: Record + metadata?: CanUploadMetadata + } ) { // check if user has INSERT permissions await this.uploader.canUpload({ @@ -800,6 +807,8 @@ export class ObjectStorage { objectName, owner, isUpsert: options?.upsert ?? false, + userMetadata: options?.userMetadata, + metadata: options?.metadata, }) const { urlSigningKey } = await getJwtSecret(this.db.tenantId) diff --git a/src/storage/protocols/s3/s3-handler.ts b/src/storage/protocols/s3/s3-handler.ts index a6883a57..4e347b8d 100644 --- a/src/storage/protocols/s3/s3-handler.ts +++ b/src/storage/protocols/s3/s3-handler.ts @@ -413,6 +413,10 @@ export class S3ProtocolHandler { objectName: command.Key as string, isUpsert: true, owner: this.owner, + userMetadata: command.Metadata, + metadata: { + mimetype: command.ContentType, + }, }) const uploadId = await this.storage.backend.createMultiPartUpload( @@ -441,7 +445,8 @@ export class S3ProtocolHandler { version, signature, this.owner, - command.Metadata + command.Metadata, + { mimetype: command.ContentType } ) return { @@ -470,17 +475,19 @@ export class S3ProtocolHandler { throw ERRORS.InvalidUploadId() } + const multiPartUpload = await this.storage.db + .asSuperUser() + .findMultipartUpload(UploadId, 'id,version,user_metadata,metadata') + await uploader.canUpload({ bucketId: Bucket as string, objectName: Key as string, isUpsert: true, owner: this.owner, + userMetadata: multiPartUpload.user_metadata || undefined, + metadata: multiPartUpload.metadata || undefined, }) - const multiPartUpload = await this.storage.db - .asSuperUser() - .findMultipartUpload(UploadId, 'id,version,user_metadata') - const parts = command.MultipartUpload?.Parts || [] if (parts.length === 0) { @@ -578,15 +585,18 @@ export class S3ProtocolHandler { const maxFileSize = await getFileSizeLimit(this.storage.db.tenantId, bucket?.file_size_limit) const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location) + + const multipart = await this.shouldAllowPartUpload(UploadId, ContentLength, maxFileSize) + await uploader.canUpload({ bucketId: Bucket as string, objectName: Key as string, owner: this.owner, isUpsert: true, + userMetadata: multipart.user_metadata || undefined, + metadata: multipart.metadata || undefined, }) - const multipart = await this.shouldAllowPartUpload(UploadId, ContentLength, maxFileSize) - if (signal?.aborted) { throw ERRORS.AbortedTerminate('UploadPart aborted') } @@ -695,9 +705,10 @@ export class S3ProtocolHandler { cacheControl: command.CacheControl!, mimeType: command.ContentType!, isTruncated: options.isTruncated, - userMetadata: command.Metadata, + contentLength: command.ContentLength, }, objectName: command.Key as string, + userMetadata: command.Metadata, owner: this.owner, isUpsert: true, uploadType: 's3', @@ -735,7 +746,7 @@ export class S3ProtocolHandler { const multipart = await this.storage.db .asSuperUser() - .findMultipartUpload(UploadId, 'id,version') + .findMultipartUpload(UploadId, 'id,version,user_metadata,metadata') const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location) await uploader.canUpload({ @@ -743,6 +754,8 @@ export class S3ProtocolHandler { objectName: Key, owner: this.owner, isUpsert: true, + userMetadata: multipart.user_metadata || undefined, + metadata: multipart.metadata || undefined, }) await this.storage.backend.abortMultipartUpload( @@ -1233,13 +1246,6 @@ export class S3ProtocolHandler { const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location) - await uploader.canUpload({ - bucketId: Bucket, - objectName: Key, - owner: this.owner, - isUpsert: true, - }) - const [destinationBucket] = await this.storage.db.asSuperUser().withTransaction(async (db) => { return Promise.all([ db.findBucketById(Bucket, 'file_size_limit'), @@ -1253,6 +1259,15 @@ export class S3ProtocolHandler { const multipart = await this.shouldAllowPartUpload(UploadId, Number(copySize), maxFileSize) + await uploader.canUpload({ + bucketId: Bucket, + objectName: Key, + owner: this.owner, + isUpsert: true, + userMetadata: multipart.user_metadata || undefined, + metadata: multipart.metadata || undefined, + }) + const uploadPart = await this.storage.backend.uploadPartCopy( storageS3Bucket, this.storage.location.getKeyLocation({ @@ -1324,7 +1339,7 @@ export class S3ProtocolHandler { return this.storage.db.asSuperUser().withTransaction(async (db) => { const multipart = await db.findMultipartUpload( uploadId, - 'in_progress_size,version,upload_signature', + 'in_progress_size,version,upload_signature,user_metadata,metadata', { forUpdate: true, } diff --git a/src/storage/schemas/multipart.ts b/src/storage/schemas/multipart.ts index 1ca6eb29..3a1849ac 100644 --- a/src/storage/schemas/multipart.ts +++ b/src/storage/schemas/multipart.ts @@ -15,6 +15,9 @@ export const multipartUploadSchema = { user_metadata: { anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], }, + metadata: { + anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], + }, }, required: [ 'id', diff --git a/src/storage/uploader.ts b/src/storage/uploader.ts index c30ff4ac..eb3c7c14 100644 --- a/src/storage/uploader.ts +++ b/src/storage/uploader.ts @@ -22,19 +22,32 @@ interface FileUpload { cacheControl: string isTruncated: () => boolean xRobotsTag?: string - userMetadata?: Record + contentLength?: number } export interface UploadRequest { bucketId: string objectName: string file: FileUpload + userMetadata: Record | undefined owner?: string isUpsert?: boolean uploadType?: 'standard' | 's3' | 'resumable' signal?: AbortSignal } +export type CanUploadMetadata = Partial> & + Record + +export interface CanUploadOptions { + bucketId: string + objectName: string + owner: string | undefined + isUpsert: boolean | undefined + userMetadata: Record | undefined + metadata: CanUploadMetadata | undefined +} + const MAX_CUSTOM_METADATA_SIZE = 1024 * 1024 /** @@ -48,7 +61,7 @@ export class Uploader { private readonly location: StorageObjectLocator ) {} - async canUpload(options: Pick) { + async canUpload(options: CanUploadOptions) { const shouldCreateObject = !options.isUpsert if (shouldCreateObject) { @@ -58,6 +71,8 @@ export class Uploader { name: options.objectName, version: '1', owner: options.owner, + metadata: options.metadata, + user_metadata: options.userMetadata, }) }) } else { @@ -67,6 +82,8 @@ export class Uploader { name: options.objectName, version: '1', owner: options.owner, + metadata: options.metadata, + user_metadata: options.userMetadata, }) }) } @@ -77,7 +94,7 @@ export class Uploader { * We check RLS policies before proceeding * @param options */ - async prepareUpload(options: Omit) { + async prepareUpload(options: CanUploadOptions & { uploadType?: string }) { await this.canUpload(options) fileUploadStarted.add(1, { uploadType: options.uploadType, @@ -94,7 +111,15 @@ export class Uploader { * @param options */ async upload(request: UploadRequest) { - const version = await this.prepareUpload(request) + const version = await this.prepareUpload({ + bucketId: request.bucketId, + objectName: request.objectName, + owner: request.owner, + isUpsert: request.isUpsert, + userMetadata: request.userMetadata, + metadata: { mimetype: request.file.mimeType, contentLength: request.file.contentLength }, + uploadType: request.uploadType, + }) try { const file = request.file @@ -127,7 +152,7 @@ export class Uploader { ...request, version, objectMetadata: objectMetadata, - userMetadata: { ...file.userMetadata }, + userMetadata: { ...request.userMetadata }, }) } catch (e) { await ObjectAdminDelete.send({ @@ -310,7 +335,14 @@ export async function fileUploadFromRequest( allowedMimeTypes?: string[] objectName: string } -): Promise { +): Promise< + FileUpload & { + mimeType: string + maxFileSize: number + userMetadata: Record | undefined + contentLength: number | undefined + } +> { const contentType = request.headers['content-type'] const xRobotsTag = request.headers['x-robots-tag'] as string | undefined @@ -409,6 +441,10 @@ export async function fileUploadFromRequest( throw ERRORS.NoContentProvided(new Error('Request stream closed before upload could begin')) } + const contentLength = request.headers['content-length'] + ? Number(request.headers['content-length']) + : undefined + return { body, mimeType, @@ -417,6 +453,7 @@ export async function fileUploadFromRequest( userMetadata, maxFileSize, xRobotsTag, + contentLength, } } diff --git a/src/test/cdn.test.ts b/src/test/cdn.test.ts index 0bd260d1..cef80ebe 100644 --- a/src/test/cdn.test.ts +++ b/src/test/cdn.test.ts @@ -94,12 +94,12 @@ describe('CDN Cache Manager', () => { await storageHook.storage.from(bucketName).uploadNewObject({ isUpsert: true, objectName, + userMetadata: {}, file: { body: Readable.from(Buffer.from('test')), cacheControl: 'public, max-age=31536000', mimeType: 'text/plain', isTruncated: () => false, - userMetadata: {}, }, }) diff --git a/src/test/rls.test.ts b/src/test/rls.test.ts index f6084ee4..3436998f 100644 --- a/src/test/rls.test.ts +++ b/src/test/rls.test.ts @@ -6,6 +6,8 @@ import FormData from 'form-data' import yaml from 'js-yaml' import Mustache from 'mustache' import { CreateBucketCommand, S3Client } from '@aws-sdk/client-s3' +import * as tus from 'tus-js-client' +import { DetailedError } from 'tus-js-client' import { StorageKnexDB } from '@storage/database' import { createStorageBackend } from '@storage/backend' @@ -42,6 +44,7 @@ interface TestCaseAssert { operation: | 'upload' | 'upload.upsert' + | 'upload.tus' | 'bucket.create' | 'bucket.get' | 'bucket.list' @@ -58,6 +61,9 @@ interface TestCaseAssert { useExistingBucketName?: string role?: string policies?: string[] + userMetadata?: Record + mimeType?: string + contentLength?: number status: number error?: string } @@ -236,6 +242,9 @@ describe('RLS policies', () => { bucket: bucketName, objectName: objectName, jwt: assert.role === 'service' ? await serviceKeyAsync : jwt, + userMetadata: assert.userMetadata, + mimeType: assert.mimeType, + contentLength: assert.contentLength, }) console.log( @@ -254,8 +263,7 @@ describe('RLS policies', () => { } if (assert.error) { - const body = await response.json() - + const body = response.json() expect(body.message).toBe(assert.error) } } finally { @@ -294,15 +302,24 @@ describe('RLS policies', () => { async function runOperation( operation: TestCaseAssert['operation'], - options: { bucket: string; jwt: string; objectName: string } + options: { + bucket: string + jwt: string + objectName: string + userMetadata?: Record + mimeType?: string + contentLength?: number + } ) { - const { jwt, bucket, objectName } = options + const { jwt, bucket, objectName, userMetadata, mimeType, contentLength } = options switch (operation) { case 'upload': - return uploadFile(bucket, objectName, jwt) + return uploadFile(bucket, objectName, jwt, false, userMetadata, mimeType, contentLength) case 'upload.upsert': - return uploadFile(bucket, objectName, jwt, true) + return uploadFile(bucket, objectName, jwt, true, userMetadata, mimeType, contentLength) + case 'upload.tus': + return tusUploadFile(bucket, objectName, jwt, userMetadata, mimeType, contentLength) case 'bucket.list': return appInstance.inject({ method: 'GET', @@ -454,13 +471,31 @@ async function createPolicy(db: Knex, policy: Policy) { return Promise.all(created) } -async function uploadFile(bucket: string, fileName: string, jwt: string, upsert?: boolean) { +async function uploadFile( + bucket: string, + fileName: string, + jwt: string, + upsert?: boolean, + userMetadata?: Record, + mimeType?: string, + contentLength?: number +) { const testFile = fs.createReadStream(path.resolve(__dirname, 'assets', 'sadcat.jpg')) const form = new FormData() form.append('file', testFile) + + if (userMetadata) { + form.append('metadata', JSON.stringify(userMetadata)) + } + + if (mimeType) { + form.append('contentType', mimeType) + } + const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${jwt}`, ...(upsert ? { 'x-upsert': 'true' } : {}), + ...(contentLength ? { 'content-length': contentLength.toString() } : {}), }) return appInstance.inject({ @@ -470,3 +505,68 @@ async function uploadFile(bucket: string, fileName: string, jwt: string, upsert? payload: form, }) } + +async function tusUploadFile( + bucket: string, + objectName: string, + jwt: string, + userMetadata?: Record, + mimeType?: string, + contentLength?: number +) { + if (!appInstance.server.listening) { + await appInstance.listen({ port: 0 }) + } + + const addressInfo = appInstance.server.address() + if (!addressInfo || typeof addressInfo === 'string') { + throw new Error('Unable to resolve local server address') + } + + const localServerAddress = `http://127.0.0.1:${addressInfo.port}` + + const file = fs.createReadStream(path.resolve(__dirname, 'assets', 'sadcat.jpg')) + + let statusCode = 200 + let message = '' + + try { + await new Promise((resolve, reject) => { + const upload = new tus.Upload(file, { + endpoint: `${localServerAddress}/upload/resumable`, + uploadSize: contentLength || undefined, + onShouldRetry: () => false, + uploadDataDuringCreation: false, + headers: { + authorization: `Bearer ${jwt}`, + }, + metadata: { + bucketName: bucket, + objectName: objectName, + contentType: mimeType || 'application/octet-stream', + cacheControl: '3600', + ...(userMetadata ? { metadata: JSON.stringify(userMetadata) } : {}), + }, + onError: function (error) { + console.log('Failed because: ' + error) + reject(error) + }, + onSuccess: () => { + resolve(true) + }, + }) + + upload.start() + }) + } catch (e) { + if (e instanceof DetailedError) { + statusCode = e.originalResponse.getStatus() + message = e.originalResponse.getBody() + } else { + throw e + } + } + + const body = message ? { message } : {} + return { statusCode, body: JSON.stringify(body), json: () => body } +} diff --git a/src/test/rls_tests.yaml b/src/test/rls_tests.yaml index 150c43a7..ae882021 100644 --- a/src/test/rls_tests.yaml +++ b/src/test/rls_tests.yaml @@ -47,6 +47,24 @@ policies: permissions: ['delete'] content: "USING(owner = '{{uid}}')" + - name: insert_with_metadata_check + tables: ['storage.objects'] + roles: ['authenticated'] + permissions: ['insert'] + content: "WITH CHECK(user_metadata->>'department' = 'engineering')" + + - name: insert_only_images + tables: ['storage.objects'] + roles: ['authenticated'] + permissions: ['insert'] + content: "WITH CHECK(metadata->>'mimetype' LIKE 'image/%')" + + - name: insert_max_size_limit + tables: ['storage.objects'] + roles: ['authenticated'] + permissions: ['insert'] + content: "WITH CHECK((metadata->>'contentLength')::int <= 100000)" + tests: - description: 'Will only able to read objects' policies: @@ -475,3 +493,77 @@ tests: - operation: bucket.delete status: 400 error: 'Bucket not found' + + - description: 'Will only upload files with correct user metadata' + policies: + - insert_with_metadata_check + asserts: + - operation: upload + objectName: 'test_file.jpg' + userMetadata: + department: 'engineering' + status: 200 + + - operation: upload + status: 400 + error: 'new row violates row-level security policy' + + - operation: upload.tus + objectName: 'test_file_tus.jpg' + userMetadata: + department: 'engineering' + status: 200 + + - operation: upload.tus + status: 403 + error: 'new row violates row-level security policy' + + - description: 'Will only upload image files based on mimetype' + policies: + - insert_only_images + asserts: + - operation: upload + objectName: 'test_image.jpg' + mimeType: 'image/jpeg' + status: 200 + + - operation: upload + objectName: 'test_file.txt' + mimeType: 'text/plain' + status: 400 + error: 'new row violates row-level security policy' + + - operation: upload.tus + objectName: 'test_image_tus.jpg' + mimeType: 'image/jpeg' + status: 200 + + - operation: upload.tus + objectName: 'test_file_tus.txt' + mimeType: 'text/plain' + status: 403 + error: 'new row violates row-level security policy' + + - description: 'Will only upload files under size limit based on contentLength' + policies: + - insert_max_size_limit + asserts: + - operation: upload + objectName: 'small_file.jpg' + status: 200 + + - operation: upload + objectName: 'large_file.jpg' + contentLength: 200000 + status: 400 + error: 'new row violates row-level security policy' + + - operation: upload.tus + objectName: 'small_file_tus.jpg' + status: 200 + + - operation: upload.tus + objectName: 'large_file_tus.jpg' + contentLength: 200000 + status: 403 + error: 'new row violates row-level security policy' diff --git a/src/test/scanner.test.ts b/src/test/scanner.test.ts index ab4cbbe3..5c0cd5b1 100644 --- a/src/test/scanner.test.ts +++ b/src/test/scanner.test.ts @@ -24,11 +24,11 @@ describe('ObjectScanner', () => { bucketId: bucket.id, objectName: randomUUID() + `-test-${i}.text`, uploadType: 'standard', + userMetadata: {}, file: { body: Readable.from(Buffer.from('test')), mimeType: 'text/plain', cacheControl: 'no-cache', - userMetadata: {}, isTruncated: () => false, }, }) @@ -91,11 +91,11 @@ describe('ObjectScanner', () => { bucketId: bucket.id, objectName: randomUUID() + `-test-${i}.text`, uploadType: 'standard', + userMetadata: {}, file: { body: Readable.from(Buffer.from('test')), mimeType: 'text/plain', cacheControl: 'no-cache', - userMetadata: {}, isTruncated: () => false, }, })