Files
speckle-server/packages/server/modules/blobstorage/tests/unit/presigned.spec.ts
T

266 lines
9.6 KiB
TypeScript

import {
generatePresignedUrlFactory,
registerCompletedUploadFactory
} from '@/modules/blobstorage/services/presigned'
import { UserInputError } from '@/modules/core/errors/userinput'
import { expectToThrow } from '@/test/assertionHelper'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { testLogger } from '@/observability/logging'
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import { BlobUploadStatus } from '@speckle/shared/blobs'
describe('Presigned @blobstorage', async () => {
describe('generate a presigned URL', () => {
it('should generate a presigned URL for uploading a blob', async () => {
const blobId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const SUT = generatePresignedUrlFactory({
getSignedUrl: async ({ objectKey, urlExpiryDurationSeconds }) =>
`https://example.com/${objectKey}?expires=${urlExpiryDurationSeconds}`,
upsertBlob: async (blob) => ({
...blob,
fileSize: 0,
fileType: blob.fileType || 'unknown',
uploadStatus: BlobUploadStatus.Pending,
uploadError: null,
createdAt: new Date(),
fileHash: null
})
})
const response = await SUT({
projectId,
userId: cryptoRandomString({ length: 10 }),
blobId,
fileName: `test-file-${cryptoRandomString({ length: 10 })}.stl`,
urlExpiryDurationSeconds: 60
})
expect(response).to.equal(
`https://example.com/assets/${projectId}/${blobId}?expires=60`
)
})
it('should error if a file name suffix (file type) is not provided', async () => {
const blobId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const SUT = generatePresignedUrlFactory({
getSignedUrl: async ({ objectKey, urlExpiryDurationSeconds }) =>
`https://example.com/${objectKey}?expires=${urlExpiryDurationSeconds}`,
upsertBlob: async (blob) => ({
...blob,
fileSize: 0,
fileType: blob.fileType || 'unknown',
uploadStatus: BlobUploadStatus.Pending,
uploadError: null,
createdAt: new Date(),
fileHash: null
})
})
const thrownError = await expectToThrow(() =>
SUT({
projectId,
userId: cryptoRandomString({ length: 10 }),
blobId,
fileName: `test-file-${cryptoRandomString({ length: 10 })}`, // no file type
urlExpiryDurationSeconds: 60
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
})
//TODO: re-enable this test once we have a list of accepted file types including all image & video formats
it.skip('should error if a file name suffix (file type) is not accepted', async () => {
const blobId = cryptoRandomString({ length: 10 })
const projectId = cryptoRandomString({ length: 10 })
const SUT = generatePresignedUrlFactory({
getSignedUrl: async ({ objectKey, urlExpiryDurationSeconds }) =>
`https://example.com/${objectKey}?expires=${urlExpiryDurationSeconds}`,
upsertBlob: async (blob) => ({
...blob,
fileSize: 0,
fileType: blob.fileType || 'unknown',
uploadStatus: BlobUploadStatus.Pending,
uploadError: null,
createdAt: new Date(),
fileHash: null
})
})
const thrownError = await expectToThrow(() =>
SUT({
projectId,
userId: cryptoRandomString({ length: 10 }),
blobId,
fileName: `test-file-${cryptoRandomString({ length: 10 })}.verboten`, // unacceptable file type
urlExpiryDurationSeconds: 60
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
})
})
describe('register a completed blob upload', () => {
const fakeGetBlob = async () => ({
// this returned data is never used
id: cryptoRandomString({ length: 10 }),
streamId: cryptoRandomString({ length: 10 }),
fileName: `test-file-${cryptoRandomString({ length: 10 })}.stl`,
fileType: 'stl',
fileSize: null,
uploadStatus: BlobUploadStatus.Pending,
uploadError: null,
createdAt: new Date(),
fileHash: null,
userId: cryptoRandomString({ length: 10 }),
objectKey: cryptoRandomString({ length: 10 })
})
const fakeUpdateBlob = async () => ({
// this returned data is never used
id: cryptoRandomString({ length: 10 }),
streamId: cryptoRandomString({ length: 10 }),
fileName: `test-file-${cryptoRandomString({ length: 10 })}.stl`,
fileType: 'stl',
fileSize: 101,
uploadStatus: BlobUploadStatus.Completed,
uploadError: null,
createdAt: new Date(),
fileHash: cryptoRandomString({ length: 32 }),
userId: cryptoRandomString({ length: 10 }),
objectKey: cryptoRandomString({ length: 10 })
})
it('should error if the etag is not provided', async () => {
const projectId = cryptoRandomString({ length: 10 })
const fileHash = cryptoRandomString({ length: 10 })
const blobId = cryptoRandomString({ length: 10 })
const SUT = registerCompletedUploadFactory({
getBlob: fakeGetBlob,
getBlobMetadata: async () => ({
contentLength: 1000,
eTag: fileHash
}),
updateBlob: fakeUpdateBlob,
logger: testLogger
})
const thrownError = await expectToThrow(
async () =>
await SUT({
projectId,
blobId,
expectedETag: '', // no etag provided
maximumFileSize: 10_000
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
expect(thrownError.message).to.contain('ETag is required')
})
it('should error if the etag does not match the uploaded blob', async () => {
const projectId = cryptoRandomString({ length: 10 })
const fileHash = cryptoRandomString({ length: 10 })
const blobId = cryptoRandomString({ length: 10 })
const SUT = registerCompletedUploadFactory({
getBlob: fakeGetBlob,
getBlobMetadata: async () => ({
contentLength: 1000,
eTag: fileHash // the etag to match
}),
updateBlob: fakeUpdateBlob,
logger: testLogger
})
const thrownError = await expectToThrow(
async () =>
await SUT({
projectId,
blobId,
expectedETag: cryptoRandomString({ length: 32 }), // mismatched etag
maximumFileSize: 10_000
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
expect(thrownError.message).to.contain('ETag mismatch')
})
it('should error if the content length is greater than the maximum file size', async () => {
const projectId = cryptoRandomString({ length: 10 })
const fileHash = cryptoRandomString({ length: 10 })
const blobId = cryptoRandomString({ length: 10 })
const maximumFileSize = 100
const SUT = registerCompletedUploadFactory({
getBlob: fakeGetBlob,
getBlobMetadata: async () => ({
contentLength: maximumFileSize + 1,
eTag: fileHash
}),
updateBlob: fakeUpdateBlob,
logger: testLogger
})
const thrownError = await expectToThrow(
async () =>
await SUT({
projectId,
blobId,
expectedETag: fileHash,
maximumFileSize
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
expect(thrownError.message).to.contain('File size exceeds maximum')
})
it('should error if the maximum file size is not logical', async () => {
const projectId = cryptoRandomString({ length: 10 })
const fileHash = cryptoRandomString({ length: 10 })
const blobId = cryptoRandomString({ length: 10 })
const maximumFileSize = -22 // negative file size for this test
const SUT = registerCompletedUploadFactory({
getBlob: fakeGetBlob,
getBlobMetadata: async () => ({
contentLength: 100,
eTag: fileHash
}),
updateBlob: fakeUpdateBlob,
logger: testLogger
})
const thrownError = await expectToThrow(
async () =>
await SUT({
projectId,
blobId,
expectedETag: fileHash,
maximumFileSize
})
)
expect(thrownError).to.be.instanceOf(MisconfiguredEnvironmentError)
expect(thrownError.message).to.contain('Maximum file size must be greater than')
})
it('should throw an error if there is no existing blob with the given ID', async () => {
const projectId = cryptoRandomString({ length: 10 })
const fileHash = cryptoRandomString({ length: 10 })
const blobId = cryptoRandomString({ length: 10 })
const SUT = registerCompletedUploadFactory({
getBlob: async () => undefined, // simulate no existing blob
getBlobMetadata: async () => ({
contentLength: 1000,
eTag: fileHash
}),
updateBlob: fakeUpdateBlob,
logger: testLogger
})
const thrownError = await expectToThrow(
async () =>
await SUT({
projectId,
blobId,
expectedETag: fileHash,
maximumFileSize: 10_000
})
)
expect(thrownError).to.be.instanceOf(UserInputError)
expect(thrownError.message).to.contain(
'Please use mutation generateUploadUrl to create a blob before registering a completed upload'
)
})
})
})