266 lines
9.6 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|
|
})
|