'use strict' import FormData from 'form-data' import fs from 'fs' import app from '../app' import { getConfig, mergeConfig } from '../config' import { signJWT } from '@internal/auth' import { Obj, backends } from '../storage' import { useMockObject, useMockQueue } from './common' import { getServiceKeyUser, getPostgresConnection } from '@internal/database' import { Knex } from 'knex' import { ErrorCode, StorageBackendError } from '@internal/errors' const { jwtSecret, serviceKey, tenantId } = getConfig() const anonKey = process.env.ANON_KEY || '' const S3Backend = backends.S3Backend let tnx: Knex.Transaction | undefined async function getSuperuserPostgrestClient() { const superUser = await getServiceKeyUser(tenantId) const conn = await getPostgresConnection({ superUser, user: superUser, tenantId, host: 'localhost', }) tnx = await conn.transaction() return tnx } useMockObject() useMockQueue() beforeEach(() => { getConfig({ reload: true }) }) afterEach(async () => { if (tnx) { await tnx.commit() } }) /* * GET /object/:id */ describe('testing GET object', () => { test('check if RLS policies are respected: authenticated user is able to read authenticated resource', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Thu, 12 Aug 2021 16:00:00 GMT') expect(S3Backend.prototype.getObject).toBeCalled() }) test('check if RLS policies are respected: authenticated user is able to read authenticated resource without /authenticated prefix', async () => { const response = await app().inject({ method: 'GET', url: '/object/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Thu, 12 Aug 2021 16:00:00 GMT') expect(S3Backend.prototype.getObject).toBeCalled() }) test('forward 304 and If-Modified-Since/If-None-Match headers', async () => { const mockGetObject = jest.spyOn(S3Backend.prototype, 'getObject') mockGetObject.mockRejectedValue({ $metadata: { httpStatusCode: 304, }, }) const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'if-modified-since': 'Thu, 12 Aug 2021 16:00:00 GMT', 'if-none-match': 'abc', }, }) expect(response.statusCode).toBe(304) expect(mockGetObject.mock.calls[0][3]).toMatchObject({ ifModifiedSince: 'Thu, 12 Aug 2021 16:00:00 GMT', ifNoneMatch: 'abc', }) }) test('get authenticated object info', async () => { const response = await app().inject({ method: 'HEAD', url: '/object/authenticated/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT') expect(response.headers['content-length']).toBe('3746') expect(response.headers['cache-control']).toBe('no-cache') }) test('get authenticated object info without the /authenticated prefix', async () => { const response = await app().inject({ method: 'HEAD', url: '/object/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT') expect(response.headers['content-length']).toBe('3746') expect(response.headers['cache-control']).toBe('no-cache') }) test('cannot get authenticated object info without the /authenticated prefix if no jwt is provided', async () => { const response = await app().inject({ method: 'HEAD', url: '/object/bucket2/authenticated/casestudy.png', }) expect(response.statusCode).toBe(400) }) test('get public object info without using the /public prefix', async () => { const response = await app().inject({ method: 'HEAD', url: '/object/public-bucket-2/favicon.ico', headers: { authorization: ``, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT') expect(response.headers['content-length']).toBe('3746') expect(response.headers['cache-control']).toBe('no-cache') }) test('get public object info', async () => { const response = await app().inject({ method: 'HEAD', url: '/object/public-bucket-2/favicon.ico', headers: { authorization: ``, }, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT') expect(response.headers['content-length']).toBe('3746') expect(response.headers['cache-control']).toBe('no-cache') }) test('force downloading file with default name', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png?download', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(S3Backend.prototype.getObject).toBeCalled() expect(response.headers).toEqual( expect.objectContaining({ 'content-disposition': `attachment;`, }) ) }) test('force downloading file with a custom name', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png?download=testname.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(S3Backend.prototype.getObject).toBeCalled() expect(response.headers).toEqual( expect.objectContaining({ 'content-disposition': `attachment; filename=testname.png; filename*=UTF-8''testname.png;`, }) ) }) test('check if RLS policies are respected: anon user is not able to read authenticated resource', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) test('check if RLS policies are respected: anon user is not able to read authenticated resource without /authenticated prefix', async () => { const response = await app().inject({ method: 'GET', url: '/object/bucket2/authenticated/casestudy.png', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) test('user is not able to read a resource without Auth header', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/casestudy.png', }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) test('user is not able to read a resource without Auth header without the /authenticated prefix', async () => { const response = await app().inject({ method: 'GET', url: '/object/bucket2/authenticated/casestudy.png', }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) test('return 400 when reading a non existent object', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/bucket2/authenticated/notfound', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) test('return 400 when reading a non existent bucket', async () => { const response = await app().inject({ method: 'GET', url: '/object/authenticated/notfound/authenticated/casestudy.png', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.getObject).not.toHaveBeenCalled() }) }) /* * POST /object/:id * multipart upload */ describe('testing POST object via multipart upload', () => { test('check if RLS policies are respected: authenticated user is able to upload authenticated resource', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/casestudy1.png', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toBeCalled() expect(await response.json()).toEqual( expect.objectContaining({ Id: expect.any(String), Key: 'bucket2/authenticated/casestudy1.png', }) ) }) test('check if RLS policies are respected: anon user is not able to upload authenticated resource', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/casestudy.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() expect(response.body).toBe( JSON.stringify({ statusCode: '403', error: 'Unauthorized', message: 'new row violates row-level security policy', }) ) }) test('check if RLS policies are respected: user is not able to upload a resource without Auth header', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/casestudy.png', headers: form.getHeaders(), payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when uploading to a non existent bucket', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'POST', url: '/object/notfound/authenticated/casestudy.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when uploading to duplicate object', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 200 when uploading an object within bucket max size limit', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/public-limit-max-size-2/sadcat-upload25.png', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) test('return 400 when uploading an object that exceed bucket level max size', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/public-limit-max-size/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(await response.json()).toEqual({ error: 'Payload too large', message: 'The object exceeded the maximum allowed size', statusCode: '413', }) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) test('successfully uploading an object with a the allowed mime-type', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', 'content-type': 'image/jpeg', }) const response = await app().inject({ method: 'POST', url: '/object/public-limit-mime-types/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) test('successfully uploading an object with custom metadata using form data', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) form.append( 'metadata', JSON.stringify({ test1: 'test1', test2: 'test2', }) ) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', ...form.getHeaders(), }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/sadcat-upload3012.png', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() const client = await getSuperuserPostgrestClient() const object = await client .table('objects') .select('*') .where('name', 'sadcat-upload3012.png') .where('bucket_id', 'bucket2') .first() expect(object).not.toBeFalsy() expect(object?.user_metadata).toEqual({ test1: 'test1', test2: 'test2', }) }) test('successfully uploading an object with custom metadata using stream', async () => { const file = fs.createReadStream(`./src/test/assets/sadcat.jpg`) const headers = { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', 'x-metadata': Buffer.from( JSON.stringify({ test1: 'test1', test2: 'test2', }) ).toString('base64'), } const response = await app().inject({ method: 'POST', url: '/object/bucket2/sadcat-upload3018.png', headers, payload: file, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() const client = await getSuperuserPostgrestClient() const object = await client .table('objects') .select('*') .where('name', 'sadcat-upload3018.png') .where('bucket_id', 'bucket2') .first() expect(object).not.toBeFalsy() expect(object?.user_metadata).toEqual({ test1: 'test1', test2: 'test2', }) }) test('fetch object metadata', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) form.append( 'metadata', JSON.stringify({ test1: 'test1', test2: 'test2', }) ) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', }) const uploadResponse = await app().inject({ method: 'POST', url: '/object/bucket2/sadcat-upload3019.png', headers: { ...headers, ...form.getHeaders(), }, payload: form, }) expect(uploadResponse.statusCode).toBe(200) const response = await app().inject({ method: 'GET', url: '/object/info/bucket2/sadcat-upload3019.png', headers, }) const data = await response.json() expect(data.metadata).toEqual({ test1: 'test1', test2: 'test2', }) }) test('return 422 when uploading an object with a not allowed mime-type', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', 'content-type': 'image/png', }) const response = await app().inject({ method: 'POST', url: '/object/public-limit-mime-types/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(await response.json()).toEqual({ error: 'invalid_mime_type', message: `mime type image/png is not supported`, statusCode: '415', }) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('can create an empty folder when mime-type is set', async () => { const form = new FormData() const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', }) form.append('file', Buffer.alloc(0)) const response = await app().inject({ method: 'POST', url: '/object/public-limit-mime-types/nested/.emptyFolderPlaceholder', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) test('cannot create an empty folder with more than 0kb', async () => { const form = new FormData() const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', }) form.append('file', Buffer.alloc(1)) const response = await app().inject({ method: 'POST', url: '/object/public-limit-mime-types/nested-2/.emptyFolderPlaceholder', headers, payload: form, }) expect(response.statusCode).toBe(400) }) test('return 422 when uploading an object with a malformed mime-type', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${serviceKey}`, 'x-upsert': 'true', 'content-type': 'thisisnotarealmimetype', }) const response = await app().inject({ method: 'POST', url: '/object/public-limit-mime-types/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(await response.json()).toEqual({ error: 'invalid_mime_type', message: `mime type thisisnotarealmimetype is not supported`, statusCode: '415', }) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 200 when upserting duplicate object', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat-upload23.png', headers, payload: form, }) expect(response.statusCode).toBe(200) }) test('return 400 when exceeding file size limit', async () => { mergeConfig({ uploadFileSizeLimit: 1, }) const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, // 'x-upsert': 'true', }) const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat55.jpg', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(response.body).toBe( JSON.stringify({ statusCode: '413', error: 'Payload too large', message: 'The object exceeded the maximum allowed size', }) ) }) test('return 400 when uploading to object with no file name', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'POST', url: '/object/bucket4/', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('should not add row to database if upload fails', async () => { // Mock S3 upload failure. jest.spyOn(S3Backend.prototype, 'uploadObject').mockRejectedValue( StorageBackendError.fromError({ name: 'S3ServiceException', message: 'Unknown error', $fault: 'server', $metadata: { httpStatusCode: 500, }, }) ) process.env.FILE_SIZE_LIMIT = '1' const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'public/should-not-insert/sadcat.jpg' const createObjectResponse = await app().inject({ method: 'POST', url: `/object/${BUCKET_ID}/${OBJECT_NAME}`, headers, payload: form, }) expect(createObjectResponse.statusCode).toBe(500) expect(JSON.parse(createObjectResponse.body)).toStrictEqual({ code: ErrorCode.S3Error, statusCode: '500', error: 'Unknown error', message: 'S3ServiceException', }) // Ensure that row does not exist in database. const db = await getSuperuserPostgrestClient() const objectResponse = await db .from('objects') .select('*') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .first() expect(objectResponse).toBe(undefined) }) }) /* * POST /object/:id * binary upload */ describe('testing POST object via binary upload', () => { test('check if RLS policies are respected: authenticated user is able to upload authenticated resource', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', 'x-upsert': 'true', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/binary-casestudy1.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toBeCalled() expect(await response.json()).toEqual( expect.objectContaining({ Id: expect.any(String), Key: 'bucket2/authenticated/binary-casestudy1.png', }) ) }) test('check if RLS policies are respected: anon user is not able to upload authenticated resource', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${anonKey}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/binary-casestudy.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() expect(response.body).toBe( JSON.stringify({ statusCode: '403', error: 'Unauthorized', message: 'new row violates row-level security policy', }) ) }) test('check if RLS policies are respected: user is not able to upload a resource without Auth header', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/authenticated/binary-casestudy1.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when uploading to a non existent bucket', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'POST', url: '/object/notfound/authenticated/binary-casestudy1.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when uploading to duplicate object', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat-upload23.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 200 when upserting duplicate object', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', 'x-upsert': 'true', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat-upload23.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) test('return 400 when exceeding file size limit', async () => { mergeConfig({ uploadFileSizeLimit: 1, }) const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'POST', url: '/object/bucket2/public/sadcat.jpg', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(response.body).toBe( JSON.stringify({ statusCode: '413', error: 'Payload too large', message: 'The object exceeded the maximum allowed size', }) ) }) test('return 400 when uploading to object with no file name', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${anonKey}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', 'x-upsert': 'true', } const response = await app().inject({ method: 'POST', url: '/object/bucket4/', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('should not add row to database if upload fails', async () => { // Mock S3 upload failure. jest.spyOn(S3Backend.prototype, 'uploadObject').mockRejectedValue( StorageBackendError.fromError({ name: 'S3ServiceException', message: 'Unknown error', $fault: 'server', $metadata: { httpStatusCode: 500, }, }) ) process.env.FILE_SIZE_LIMIT = '1' const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'public/should-not-insert/sadcat.jpg' const createObjectResponse = await app().inject({ method: 'POST', url: `/object/${BUCKET_ID}/${OBJECT_NAME}`, headers, payload: fs.createReadStream(path), }) expect(createObjectResponse.statusCode).toBe(500) expect(JSON.parse(createObjectResponse.body)).toStrictEqual({ statusCode: '500', code: ErrorCode.S3Error, error: 'Unknown error', message: 'S3ServiceException', }) // Ensure that row does not exist in database. const db = await getSuperuserPostgrestClient() const objectResponse = await db .from('objects') .select('*') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .first() expect(objectResponse).toBe(undefined) }) }) /** * PUT /object/:id * multipart upload */ describe('testing PUT object', () => { test('check if RLS policies are respected: authenticated user is able to update authenticated resource', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }) const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toBeCalled() expect(await response.json()).toEqual( expect.objectContaining({ Id: expect.any(String), Key: 'bucket2/authenticated/cat.jpg', }) ) }) test('check if RLS policies are respected: anon user is not able to update authenticated resource', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('user is not able to update a resource without Auth header', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers: form.getHeaders(), payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when update to a non existent bucket', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'PUT', url: '/object/notfound/authenticated/cat.jpg', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when updating a non existent key', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { authorization: `Bearer ${anonKey}`, }) const response = await app().inject({ method: 'PUT', url: '/object/notfound/authenticated/notfound.jpg', headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) }) /* * PUT /object/:id * binary upload */ describe('testing PUT object via binary upload', () => { test('check if RLS policies are respected: authenticated user is able to update authenticated resource', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toBeCalled() expect(await response.json()).toEqual( expect.objectContaining({ Id: expect.any(String), Key: 'bucket2/authenticated/cat.jpg', }) ) }) test('check if RLS policies are respected: anon user is not able to update authenticated resource', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${anonKey}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('check if RLS policies are respected: user is not able to upload a resource without Auth header', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'PUT', url: '/object/bucket2/authenticated/cat.jpg', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when updating an object in a non existent bucket', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'PUT', url: '/object/notfound/authenticated/binary-casestudy1.png', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('return 400 when updating an object in a non existent key', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) const headers = { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'Content-Length': size, 'Content-Type': 'image/jpeg', } const response = await app().inject({ method: 'PUT', url: '/object/notfound/authenticated/notfound.jpg', headers, payload: fs.createReadStream(path), }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) }) /** * POST /copy */ describe('testing copy object', () => { test('check if RLS policies are respected: authenticated user is able to copy authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toBeCalled() const jsonResponse = await response.json() expect(jsonResponse.Key).toBe(`bucket2/authenticated/casestudy11.png`) }) test('can copy objects across buckets', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationBucket: 'bucket3', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toBeCalled() const jsonResponse = await response.json() expect(jsonResponse.Key).toBe(`bucket3/authenticated/casestudy11.png`) }) test('can copy objects keeping their metadata', async () => { const copiedKey = 'casestudy-2349.png' const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationKey: `authenticated/${copiedKey}`, copyMetadata: true, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toBeCalled() const jsonResponse = response.json() expect(jsonResponse.Key).toBe(`bucket2/authenticated/${copiedKey}`) const conn = await getSuperuserPostgrestClient() const object = await conn .table('objects') .select('*') .where('bucket_id', 'bucket2') .where('name', `authenticated/${copiedKey}`) .first() expect(object).not.toBeFalsy() expect(object.user_metadata).toEqual({ test1: 1234, }) }) test('can copy objects to itself overwriting their metadata', async () => { const copiedKey = 'casestudy-2349.png' const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, 'x-upsert': 'true', 'x-metadata': Buffer.from( JSON.stringify({ newMetadata: 'test1', }) ).toString('base64'), }, payload: { bucketId: 'bucket2', sourceKey: `authenticated/${copiedKey}`, destinationKey: `authenticated/${copiedKey}`, metadata: { cacheControl: 'max-age=999', mimetype: 'image/gif', }, copyMetadata: false, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toBeCalled() const parsedBody = JSON.parse(response.body) expect(parsedBody.Key).toBe(`bucket2/authenticated/${copiedKey}`) expect(parsedBody.name).toBe(`authenticated/${copiedKey}`) expect(parsedBody.bucket_id).toBe(`bucket2`) expect(parsedBody.metadata).toEqual( expect.objectContaining({ cacheControl: 'max-age=999', mimetype: 'image/gif', }) ) const conn = await getSuperuserPostgrestClient() const object = await conn .table('objects') .select('*') .where('bucket_id', 'bucket2') .where('name', `authenticated/${copiedKey}`) .first() expect(object).not.toBeFalsy() expect(object.user_metadata).toEqual({ newMetadata: 'test1', }) expect(object.metadata).toEqual( expect.objectContaining({ cacheControl: 'max-age=999', mimetype: 'image/gif', }) ) }) test('can copy objects excluding their metadata', async () => { const copiedKey = 'casestudy-2450.png' const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationKey: `authenticated/${copiedKey}`, copyMetadata: false, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toBeCalled() const jsonResponse = response.json() expect(jsonResponse.Key).toBe(`bucket2/authenticated/${copiedKey}`) const conn = await getSuperuserPostgrestClient() const object = await conn .table('objects') .select('*') .where('bucket_id', 'bucket2') .where('name', `authenticated/${copiedKey}`) .first() expect(object).not.toBeFalsy() expect(object.user_metadata).toBeNull() }) test('cannot copy objects across buckets when RLS dont allow it', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationBucket: 'bucket3', destinationKey: 'somekey/casestudy11.png', }, }) expect(response.statusCode).toBe(400) }) test('check if RLS policies are respected: anon user is not able to update authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${anonKey}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() }) test('user is not able to copy a resource without Auth header', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', payload: { bucketId: 'bucket2', sourceKey: 'authenticated/casestudy.png', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() }) test('return 400 when copy from a non existent bucket', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${anonKey}`, }, payload: { bucketId: 'notfound', sourceKey: 'authenticated/casestudy.png', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() }) test('return 400 when copying a non existent key', async () => { const response = await app().inject({ method: 'POST', url: '/object/copy', headers: { authorization: `Bearer ${anonKey}`, }, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/notfound.png', destinationKey: 'authenticated/casestudy11.png', }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() }) }) /** * DELETE /object * */ describe('testing delete object', () => { test('check if RLS policies are respected: authenticated user is able to delete authenticated resource', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2/authenticated/delete.png', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObject).toBeCalled() }) test('check if RLS policies are respected: anon user is not able to delete authenticated resource', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2/authenticated/delete1.png', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('user is not able to delete a resource without Auth header', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2/authenticated/delete1.png', }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('return 400 when delete from a non existent bucket', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/notfound/authenticated/delete1.png', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('return 400 when deleting a non existent key', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/notfound/authenticated/notfound.jpg', headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) }) /** * DELETE /objects * */ describe('testing deleting multiple objects', () => { test('check if RLS policies are respected: authenticated user is able to delete authenticated resource', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { prefixes: [...Array(10001).keys()].map((i) => `authenticated/${i}`), }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObjects).toBeCalled() const result = JSON.parse(response.body) expect(result).toHaveLength(10001) expect(result[0].name).toBe('authenticated/0') expect(result[1].name).toBe('authenticated/1') }) test('check if RLS policies are respected: anon user is not able to delete authenticated resource', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2', headers: { authorization: `Bearer ${anonKey}`, }, payload: { prefixes: ['authenticated/delete-multiple3.png', 'authenticated/delete-multiple4.png'], }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObjects).not.toHaveBeenCalled() const results = JSON.parse(response.body) expect(results).toHaveLength(0) }) test('user is not able to delete a resource without Auth header', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2', payload: { prefixes: ['authenticated/delete-multiple3.png', 'authenticated/delete-multiple4.png'], }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.deleteObjects).not.toHaveBeenCalled() }) test('deleting from a non existent bucket', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/notfound', headers: { authorization: `Bearer ${anonKey}`, }, payload: { prefixes: ['authenticated/delete-multiple3.png', 'authenticated/delete-multiple4.png'], }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObjects).not.toHaveBeenCalled() }) test('deleting a non existent key', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2', headers: { authorization: `Bearer ${anonKey}`, }, payload: { prefixes: ['authenticated/delete-multiple5.png', 'authenticated/delete-multiple6.png'], }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObjects).not.toHaveBeenCalled() const results = JSON.parse(response.body) expect(results).toHaveLength(0) }) test('check if RLS policies are respected: user has permission to delete only one of the objects', async () => { const response = await app().inject({ method: 'DELETE', url: '/object/bucket2', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { prefixes: ['authenticated/delete-multiple7.png', 'private/sadcat-upload3.png'], }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.deleteObjects).toBeCalled() const results = JSON.parse(response.body) expect(results).toHaveLength(1) expect(results[0].name).toBe('authenticated/delete-multiple7.png') }) }) /** * POST /sign/:bucketName/* */ describe('testing generating signed URL', () => { test('check if RLS policies are respected: authenticated user is able to sign URL for an authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2/authenticated/cat.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result.signedURL).toBeTruthy() }) test('check if RLS policies are respected: anon user is not able to generate signedURL for authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2/authenticated/cat.jpg', headers: { authorization: `Bearer ${anonKey}`, }, payload: { expiresIn: 1000, }, }) expect(response.statusCode).toBe(400) }) test('user is not able to generate signedURLs without Auth header', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2/authenticated/cat.jpg', payload: { expiresIn: 1000, }, }) expect(response.statusCode).toBe(400) }) test('return 400 when generate signed urls from a non existent bucket', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/notfound/authenticated/cat.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, }, }) expect(response.statusCode).toBe(400) }) test('signing url of a non existent key', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2/authenticated/notfound.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, }, }) expect(response.statusCode).toBe(400) }) }) /** * POST /upload/sign/:bucketName/* */ describe('testing generating signed URL for upload', () => { test('check if RLS policies are respected: authenticated user is able to sign upload URL for a resource', async () => { const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'authenticated/cat1.jpg' const response = await app().inject({ method: 'POST', url: `/object/upload/sign/${BUCKET_ID}/${OBJECT_NAME}`, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result.url).toBeTruthy() // Ensure that row does not exist in database. const db = await getSuperuserPostgrestClient() const objectResponse = await db .from('objects') .select('*') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .first() expect(objectResponse).toBe(undefined) }) test('check if RLS policies are respected: anon user is not able to sign upload URL for authenticated resource', async () => { const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'authenticated/cat1.jpg' const response = await app().inject({ method: 'POST', url: `/object/upload/sign/${BUCKET_ID}/${OBJECT_NAME}`, headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(response.body).toBe( JSON.stringify({ statusCode: '403', error: 'Unauthorized', message: 'new row violates row-level security policy', }) ) // Ensure that row does not exist in database. const db = await getSuperuserPostgrestClient() const objectResponse = await db .from('objects') .select('*') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .first() expect(objectResponse).toBe(undefined) }) test('user is not able to sign a upload url without Auth header', async () => { const response = await app().inject({ method: 'POST', url: '/object/upload/sign/bucket2/authenticated/cat.jpg', }) expect(response.statusCode).toBe(400) }) test('return 400 when generating signed upload urls from a non existent bucket', async () => { const response = await app().inject({ method: 'POST', url: '/object/upload/sign/notfound/authenticated/cat.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) }) test('signing upload url of a non existent key', async () => { const response = await app().inject({ method: 'POST', url: '/object/upload/sign/bucket2/authenticated/notfound.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) }) test('signing upload url of an existent key', async () => { const response = await app().inject({ method: 'POST', url: '/object/upload/sign/bucket2/authenticated/cat.jpg', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) expect(JSON.parse(response.body).statusCode).toBe('409') }) }) /** * PUT /upload/sign/:bucketName/* */ describe('testing uploading with generated signed upload URL', () => { test('upload object with a token', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { 'content-type': 'image/jpeg', }) const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'public/sadcat-upload1.png' const urlToSign = `${BUCKET_ID}/${OBJECT_NAME}` const owner = '317eadce-631a-4429-a0bb-f19a7a517b4a' const jwtToken = await signJWT({ owner, url: urlToSign }, jwtSecret, 100) const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/${urlToSign}?token=${jwtToken}`, headers, payload: form, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() // check that row has neccessary data const db = await getSuperuserPostgrestClient() const objectResponse = await db .from('objects') .select('*') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .first() expect(objectResponse?.owner).toBe(owner) // remove row to not to break other tests await db .from('objects') .where({ name: OBJECT_NAME, bucket_id: BUCKET_ID, }) .delete() }) test('upload object without a token', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { 'content-type': 'image/jpeg', }) const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/bucket2/public/sadcat-upload1.png`, headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('upload object with a malformed JWT', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { 'content-type': 'image/jpeg', }) const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/bucket2/public/sadcat-upload1.png?token=xxx`, headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) test('upload object with an expired JWT', async () => { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) const headers = Object.assign({}, form.getHeaders(), { 'content-type': 'image/jpeg', }) const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'public/sadcat-upload1.png' const urlToSign = `${BUCKET_ID}/${OBJECT_NAME}` const owner = '317eadce-631a-4429-a0bb-f19a7a517b4a' const jwtToken = await signJWT({ owner, url: urlToSign }, jwtSecret, -1) const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/${urlToSign}?token=${jwtToken}`, headers, payload: form, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.uploadObject).not.toHaveBeenCalled() }) it('will allow overwriting a file when the generating a signed upload url with x-upsert:true', async () => { function createUpload() { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) return form } const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'signed/sadcat-upload-signed-2.png' const urlToSign = `${BUCKET_ID}/${OBJECT_NAME}` // Upload a file first const resp = await app().inject({ method: 'POST', url: `/object/${urlToSign}`, payload: createUpload(), headers: { 'x-upsert': 'true', authorization: serviceKey, }, }) expect(resp.statusCode).toBe(200) // generate signed upload url with upsert const signedUrlResp = await app().inject({ method: 'POST', url: `/object/upload/sign/${urlToSign}`, headers: { 'x-upsert': 'true', authorization: serviceKey, }, }) expect(signedUrlResp.statusCode).toBe(200) const jwtToken = (await signedUrlResp.json()).token const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/${urlToSign}?token=${jwtToken}`, payload: createUpload(), }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.uploadObject).toHaveBeenCalled() }) it('will allow not be able overwriting a file when the generating a signed upload url without x-upsert header', async () => { function createUpload() { const form = new FormData() form.append('file', fs.createReadStream(`./src/test/assets/sadcat.jpg`)) return form } const BUCKET_ID = 'bucket2' const OBJECT_NAME = 'signed/sadcat-upload-signed-3.png' const urlToSign = `${BUCKET_ID}/${OBJECT_NAME}` const owner = '317eadce-631a-4429-a0bb-f19a7a517b4a' // Upload a file first const resp = await app().inject({ method: 'POST', url: `/object/${urlToSign}`, payload: createUpload(), headers: { authorization: serviceKey, }, }) expect(resp.statusCode).toBe(200) const jwtToken = await signJWT({ owner, url: urlToSign }, jwtSecret, 100) const response = await app().inject({ method: 'PUT', url: `/object/upload/sign/${urlToSign}?token=${jwtToken}`, payload: createUpload(), }) expect(response.statusCode).toBe(400) }) }) /** * POST /sign/:bucketName */ describe('testing generating signed URLs', () => { test('check if RLS policies are respected: authenticated user is able to sign URLs for an authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, paths: [...Array(10001).keys()].map((i) => `authenticated/${i}`), }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result).toHaveLength(10001) }) test('check if RLS policies are respected: anon user is not able to generate signedURLs for authenticated resource', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2', headers: { authorization: `Bearer ${anonKey}`, }, payload: { expiresIn: 1000, paths: [...Array(10001).keys()].map((i) => `authenticated/${i}`), }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result[0].error).toBe('Either the object does not exist or you do not have access to it') }) test('user is not able to generate signedURLs without Auth header', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2', payload: { expiresIn: 1000, paths: [...Array(10001).keys()].map((i) => `authenticated/${i}`), }, }) expect(response.statusCode).toBe(400) }) test('return 400 when generate signed urls from a non existent bucket', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/notfound', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, paths: [...Array(10001).keys()].map((i) => `authenticated/${i}`), }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result[0].error).toBe('Either the object does not exist or you do not have access to it') }) test('signing url of a non existent key', async () => { const response = await app().inject({ method: 'POST', url: '/object/sign/bucket2clearAllMocks', headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, payload: { expiresIn: 1000, paths: ['authenticated/notfound.jpg'], }, }) expect(response.statusCode).toBe(200) const result = JSON.parse(response.body) expect(result[0].error).toBe('Either the object does not exist or you do not have access to it') }) }) /** * GET /public/ */ // these tests are written in bucket.test.ts since its easier /** * GET /sign/ */ describe('testing retrieving signed URL', () => { test('get object with a token', async () => { const urlToSign = 'bucket2/public/sadcat-upload.png' const jwtToken = await signJWT({ url: urlToSign }, jwtSecret, 100) const response = await app().inject({ method: 'GET', url: `/object/sign/${urlToSign}?token=${jwtToken}`, }) expect(response.statusCode).toBe(200) expect(response.headers['etag']).toBe('abc') expect(response.headers['last-modified']).toBe('Thu, 12 Aug 2021 16:00:00 GMT') }) test('forward 304 and If-Modified-Since/If-None-Match headers', async () => { const mockGetObject = jest.spyOn(S3Backend.prototype, 'getObject') mockGetObject.mockRejectedValue({ $metadata: { httpStatusCode: 304, }, }) const urlToSign = 'bucket2/public/sadcat-upload.png' const jwtToken = await signJWT({ url: urlToSign }, jwtSecret, 100) const response = await app().inject({ method: 'GET', url: `/object/sign/${urlToSign}?token=${jwtToken}`, headers: { 'if-modified-since': 'Thu, 12 Aug 2021 16:00:00 GMT', 'if-none-match': 'abc', }, }) expect(response.statusCode).toBe(304) expect(mockGetObject.mock.calls[0][3]).toMatchObject({ ifModifiedSince: 'Thu, 12 Aug 2021 16:00:00 GMT', ifNoneMatch: 'abc', }) }) test('get object without a token', async () => { const response = await app().inject({ method: 'GET', url: '/object/sign/bucket2/public/sadcat-upload.png', }) expect(response.statusCode).toBe(400) }) test('get object with a malformed JWT', async () => { const response = await app().inject({ method: 'GET', url: '/object/sign/bucket2/public/sadcat-upload.png?token=xxx', }) expect(response.statusCode).toBe(400) }) test('get object with an expired JWT', async () => { const urlToSign = 'bucket2/public/sadcat-upload.png' const expiredJWT = await signJWT({ url: urlToSign }, jwtSecret, -1) const response = await app().inject({ method: 'GET', url: `/object/sign/${urlToSign}?token=${expiredJWT}`, }) expect(response.statusCode).toBe(400) }) }) describe('testing move object', () => { test('check if RLS policies are respected: authenticated user is able to move an authenticated object', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/move-orig.png', destinationKey: 'authenticated/move-new.png', bucketId: 'bucket2', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toHaveBeenCalled() expect(S3Backend.prototype.deleteObjects).toHaveBeenCalled() }) test('can move objects across buckets respecting RLS', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/move-orig-4.png', destinationBucket: 'bucket3', destinationKey: 'authenticated/move-new.png', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(200) expect(S3Backend.prototype.copyObject).toHaveBeenCalled() expect(S3Backend.prototype.deleteObjects).toHaveBeenCalled() }) test('cannot move objects across buckets because RLS checks', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { bucketId: 'bucket2', sourceKey: 'authenticated/move-orig-5.png', destinationBucket: 'bucket3', destinationKey: 'somekey/move-new.png', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObjects).not.toHaveBeenCalled() }) test('check if RLS policies are respected: anon user is not able to move an authenticated object', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/move-orig-2.png', destinationKey: 'authenticated/move-new-2.png', bucketId: 'bucket2', }, headers: { authorization: `Bearer ${anonKey}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('user is not able to move an object without auth header', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/move-orig-3.png', destinationKey: 'authenticated/move-orig-new-3.png', bucketId: 'bucket2', }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('user is not able to move an object in a non existent bucket', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/move-orig-3.png', destinationKey: 'authenticated/move-orig-new-3.png', bucketId: 'notfound', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('user is not able to move an non existent object', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/notfound', destinationKey: 'authenticated/move-orig-new-3.png', bucketId: 'bucket2', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) test('user is not able to move to an existing key', async () => { const response = await app().inject({ method: 'POST', url: `/object/move`, payload: { sourceKey: 'authenticated/move-orig-2.png', destinationKey: 'authenticated/move-orig-3.png', bucketId: 'bucket2', }, headers: { authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`, }, }) expect(response.statusCode).toBe(400) expect(S3Backend.prototype.copyObject).not.toHaveBeenCalled() expect(S3Backend.prototype.deleteObject).not.toHaveBeenCalled() }) }) describe('testing list objects', () => { test('searching the bucket root folder', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', headers: { authorization: `Bearer ${serviceKey}`, }, payload: { prefix: '', limit: 10, offset: 0, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(9) const names = responseJSON.map((ele: any) => ele.name) expect(names).toContain('curlimage.jpg') expect(names).toContain('private') expect(names).toContain('folder') expect(names).toContain('authenticated') expect(names).toContain('public') }) test('searching a subfolder', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', headers: { authorization: `Bearer ${serviceKey}`, }, payload: { prefix: 'folder', limit: 10, offset: 0, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) const names = responseJSON.map((ele: any) => ele.name) expect(names).toContain('only_uid.jpg') expect(names).toContain('subfolder') }) test('searching a non existent prefix', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', headers: { authorization: `Bearer ${serviceKey}`, }, payload: { prefix: 'notfound', limit: 10, offset: 0, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(0) }) test('checking if limit works', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', headers: { authorization: `Bearer ${serviceKey}`, }, payload: { prefix: '', limit: 2, offset: 0, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) }) test('listobjects: checking if RLS policies are respected', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', headers: { authorization: `Bearer ${anonKey}`, }, payload: { prefix: '', limit: 10, offset: 0, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) }) test('return 400 without Auth Header', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', payload: { prefix: '', limit: 10, offset: 0, }, }) expect(response.statusCode).toBe(400) }) test('case insensitive search should work', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', payload: { prefix: 'PUBLIC/', limit: 10, offset: 0, }, headers: { authorization: `Bearer ${serviceKey}`, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) }) test('test ascending search sorting', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', payload: { prefix: 'public/', sortBy: { column: 'name', order: 'asc', }, }, headers: { authorization: `Bearer ${serviceKey}`, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) expect(responseJSON[0].name).toBe('sadcat-upload23.png') expect(responseJSON[1].name).toBe('sadcat-upload.png') }) test('test descending search sorting', async () => { const response = await app().inject({ method: 'POST', url: '/object/list/bucket2', payload: { prefix: 'public/', sortBy: { column: 'name', order: 'desc', }, }, headers: { authorization: `Bearer ${serviceKey}`, }, }) expect(response.statusCode).toBe(200) const responseJSON = JSON.parse(response.body) expect(responseJSON).toHaveLength(2) expect(responseJSON[0].name).toBe('sadcat-upload.png') expect(responseJSON[1].name).toBe('sadcat-upload23.png') }) })