feat(file uploads): large file uploads API is always available (#5103)
This commit is contained in:
@@ -32,7 +32,6 @@ services:
|
||||
NUXT_PUBLIC_BACKEND_API_ORIGIN: 'http://speckle-server:3000'
|
||||
NUXT_PUBLIC_LOG_LEVEL: 'warn'
|
||||
NUXT_REDIS_URL: 'redis://redis'
|
||||
NUXT_PUBLIC_FF_LARGE_FILE_IMPORTS_ENABLED: 'true'
|
||||
LOG_LEVEL: 'info'
|
||||
LOG_PRETTY: 'true'
|
||||
depends_on:
|
||||
@@ -95,8 +94,6 @@ services:
|
||||
|
||||
FRONTEND_ORIGIN: 'http://127.0.0.1'
|
||||
ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10'
|
||||
|
||||
FF_LARGE_FILE_IMPORTS_ENABLED: 'true'
|
||||
depends_on:
|
||||
[]
|
||||
# - minio
|
||||
|
||||
@@ -204,7 +204,7 @@ const startFileImportMutation = graphql(`
|
||||
|
||||
export const useFileImportApi = () => {
|
||||
const {
|
||||
public: { FF_LARGE_FILE_IMPORTS_ENABLED }
|
||||
public: { FF_LEGACY_FILE_IMPORTS_ENABLED }
|
||||
} = useRuntimeConfig()
|
||||
const apollo = useApolloClient().client
|
||||
const { registerActiveUpload, unregisterActiveUpload } = useGlobalFileImportManager()
|
||||
@@ -321,7 +321,7 @@ export const useFileImportApi = () => {
|
||||
const uploadId = resolveUploadId()
|
||||
try {
|
||||
registerActiveUpload(uploadId)
|
||||
return await (FF_LARGE_FILE_IMPORTS_ENABLED ? importFileV2 : importFileLegacy)(
|
||||
return await (FF_LEGACY_FILE_IMPORTS_ENABLED ? importFileLegacy : importFileV2)(
|
||||
...args
|
||||
)
|
||||
} finally {
|
||||
|
||||
+326
-336
@@ -37,341 +37,331 @@ import type {
|
||||
GetBlobMetadata,
|
||||
RegisterCompletedUpload
|
||||
} from '@/modules/blobstorage/domain/operations'
|
||||
import { getFeatureFlags } from '@speckle/shared/environment'
|
||||
|
||||
const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
|
||||
|
||||
;(FF_LARGE_FILE_IMPORTS_ENABLED ? describe : describe.skip)(
|
||||
'Presigned integration @blobstorage',
|
||||
async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
|
||||
let projectDb: Knex
|
||||
let projectStorage: { private: ObjectStorage; public: ObjectStorage }
|
||||
let getBlobMetadata: GetBlobMetadata
|
||||
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
;[projectDb, projectStorage] = await Promise.all([
|
||||
getProjectDbClient({ projectId: ownedProject.id }),
|
||||
getProjectObjectStorage({ projectId: ownedProject.id })
|
||||
])
|
||||
getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
|
||||
})
|
||||
|
||||
describe('generate a presigned URL', () => {
|
||||
let SUT: ReturnType<typeof generatePresignedUrlFactory>
|
||||
|
||||
before(() => {
|
||||
SUT = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should provision a blob with uploadStatus 0 and return a presigned URL', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 20
|
||||
const url = await SUT({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
expect(url).to.contain(`/assets/${ownedProject.id}/${blobId}?`)
|
||||
expect(url).to.contain(`X-Amz-Expires=${expiryDuration}`)
|
||||
expect(url).to.contain('X-Amz-Credential') // we don't need to check the whole url, we can trust S3; only that it appears to be signed
|
||||
|
||||
const storedBlob = await getBlobMetadata({ blobId, streamId: ownedProject.id })
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.id).to.equal(blobId)
|
||||
expect(storedBlob.uploadStatus).to.equal(0)
|
||||
expect(storedBlob.fileName).to.equal(fileName)
|
||||
expect(storedBlob.streamId).to.equal(ownedProject.id)
|
||||
expect(storedBlob.fileType).to.equal('stl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register completed upload', () => {
|
||||
let generatePresignedUrl: GeneratePresignedUrl
|
||||
let SUT: RegisterCompletedUpload
|
||||
before(() => {
|
||||
generatePresignedUrl = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
SUT = registerCompletedUploadFactory({
|
||||
getBlob: getBlobFactory({ db: projectDb }),
|
||||
getBlobMetadata: getBlobMetadataFromStorage({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
updateBlob: updateBlobFactory({
|
||||
db: projectDb
|
||||
}),
|
||||
logger: testLogger
|
||||
})
|
||||
})
|
||||
it('should update the blob with uploadStatus 1 given the correct ETag', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const fileSize = 100
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
expect(storedBlob.fileSize).to.equal(fileSize)
|
||||
})
|
||||
it('should throw an StoredBlobAccessError if the blob cannot be found', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
// do not upload anything, skip straight to registering the 'completed' upload
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag: cryptoRandomString({ length: 32 }),
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(StoredBlobAccessError)
|
||||
})
|
||||
it('should throw an UserInputError if the blob exceeds the maximum allowed size', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content') // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // 1 byte max
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
// Verify that the blob is now marked as in error state
|
||||
const blobs = await getBlobsFactory({ db: projectDb })({
|
||||
streamId: ownedProject.id,
|
||||
blobIds: [blobId]
|
||||
})
|
||||
expect(blobs).to.have.lengthOf(1)
|
||||
expect(blobs[0].uploadStatus).to.equal(BlobUploadStatus.Error)
|
||||
expect(blobs[0].uploadError).to.include('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering should be idempotent', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content')
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.include(
|
||||
'Blob already registered and completed'
|
||||
)
|
||||
})
|
||||
it('re-registering with increased maximum file size after failure does not change anything', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: 100 })) // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // our content exceeds this maximum file size
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // bigger allowed size, because maybe some environment variables changed
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering with decreased maximum file size does not change anything', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const fileSize = 100
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // larger than our content
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
expect(storedBlob.fileSize).to.equal(fileSize)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than our content. But as we're already registered, we should throw an error regardless of this
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain(
|
||||
'Blob already registered and completed'
|
||||
)
|
||||
})
|
||||
})
|
||||
describe('Presigned integration @blobstorage', async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
)
|
||||
|
||||
let projectDb: Knex
|
||||
let projectStorage: { private: ObjectStorage; public: ObjectStorage }
|
||||
let getBlobMetadata: GetBlobMetadata
|
||||
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
;[projectDb, projectStorage] = await Promise.all([
|
||||
getProjectDbClient({ projectId: ownedProject.id }),
|
||||
getProjectObjectStorage({ projectId: ownedProject.id })
|
||||
])
|
||||
getBlobMetadata = getBlobMetadataFactory({ db: projectDb })
|
||||
})
|
||||
|
||||
describe('generate a presigned URL', () => {
|
||||
let SUT: ReturnType<typeof generatePresignedUrlFactory>
|
||||
|
||||
before(() => {
|
||||
SUT = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should provision a blob with uploadStatus 0 and return a presigned URL', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 20
|
||||
const url = await SUT({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
expect(url).to.contain(`/assets/${ownedProject.id}/${blobId}?`)
|
||||
expect(url).to.contain(`X-Amz-Expires=${expiryDuration}`)
|
||||
expect(url).to.contain('X-Amz-Credential') // we don't need to check the whole url, we can trust S3; only that it appears to be signed
|
||||
|
||||
const storedBlob = await getBlobMetadata({ blobId, streamId: ownedProject.id })
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.id).to.equal(blobId)
|
||||
expect(storedBlob.uploadStatus).to.equal(0)
|
||||
expect(storedBlob.fileName).to.equal(fileName)
|
||||
expect(storedBlob.streamId).to.equal(ownedProject.id)
|
||||
expect(storedBlob.fileType).to.equal('stl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register completed upload', () => {
|
||||
let generatePresignedUrl: GeneratePresignedUrl
|
||||
let SUT: RegisterCompletedUpload
|
||||
before(() => {
|
||||
generatePresignedUrl = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
SUT = registerCompletedUploadFactory({
|
||||
getBlob: getBlobFactory({ db: projectDb }),
|
||||
getBlobMetadata: getBlobMetadataFromStorage({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
updateBlob: updateBlobFactory({
|
||||
db: projectDb
|
||||
}),
|
||||
logger: testLogger
|
||||
})
|
||||
})
|
||||
it('should update the blob with uploadStatus 1 given the correct ETag', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const fileSize = 100
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
expect(storedBlob.fileSize).to.equal(fileSize)
|
||||
})
|
||||
it('should throw an StoredBlobAccessError if the blob cannot be found', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
// do not upload anything, skip straight to registering the 'completed' upload
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag: cryptoRandomString({ length: 32 }),
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(StoredBlobAccessError)
|
||||
})
|
||||
it('should throw an UserInputError if the blob exceeds the maximum allowed size', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content') // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // 1 byte max
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
// Verify that the blob is now marked as in error state
|
||||
const blobs = await getBlobsFactory({ db: projectDb })({
|
||||
streamId: ownedProject.id,
|
||||
blobIds: [blobId]
|
||||
})
|
||||
expect(blobs).to.have.lengthOf(1)
|
||||
expect(blobs[0].uploadStatus).to.equal(BlobUploadStatus.Error)
|
||||
expect(blobs[0].uploadError).to.include('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering should be idempotent', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content')
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.include('Blob already registered and completed')
|
||||
})
|
||||
it('re-registering with increased maximum file size after failure does not change anything', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: 100 })) // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // our content exceeds this maximum file size
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // bigger allowed size, because maybe some environment variables changed
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering with decreased maximum file size does not change anything', async () => {
|
||||
const blobId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const fileSize = 100
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedBlob = await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // larger than our content
|
||||
})
|
||||
|
||||
expect(storedBlob).to.exist
|
||||
expect(storedBlob.uploadStatus).to.equal(BlobUploadStatus.Completed)
|
||||
expect(storedBlob.fileHash).to.equal(expectedETag)
|
||||
expect(storedBlob.fileSize).to.equal(fileSize)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
blobId,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than our content. But as we're already registered, we should throw an error regardless of this
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain('Blob already registered and completed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
filteredSubscribe
|
||||
} from '@/modules/shared/utils/subscriptions'
|
||||
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import {
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
MisconfiguredEnvironmentError
|
||||
} from '@/modules/shared/errors'
|
||||
import { BadRequestError, ForbiddenError } from '@/modules/shared/errors'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import {
|
||||
fileImportServiceShouldUsePrivateObjectsServerUrl,
|
||||
@@ -72,8 +68,7 @@ import type {
|
||||
} from '@/modules/fileuploads/helpers/types'
|
||||
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
|
||||
|
||||
const { FF_LARGE_FILE_IMPORTS_ENABLED, FF_NEXT_GEN_FILE_IMPORTER_ENABLED } =
|
||||
getFeatureFlags()
|
||||
const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
|
||||
|
||||
const getFileUploadModel = async (params: {
|
||||
upload: FileUploadRecord | FileUploadRecordV2
|
||||
@@ -101,11 +96,6 @@ const getFileUploadModel = async (params: {
|
||||
|
||||
const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
async generateUploadUrl(_parent, args, ctx) {
|
||||
if (!FF_LARGE_FILE_IMPORTS_ENABLED) {
|
||||
throw new MisconfiguredEnvironmentError(
|
||||
'The large file import feature is not enabled on this server. Please contact your Speckle administrator.'
|
||||
)
|
||||
}
|
||||
const { projectId } = args.input
|
||||
if (!ctx.userId) {
|
||||
throw new ForbiddenError('No userId provided')
|
||||
@@ -172,12 +162,6 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
if (!isFileUploadsEnabled())
|
||||
throw new BadRequestError('File uploads are not enabled for this server')
|
||||
|
||||
if (!FF_LARGE_FILE_IMPORTS_ENABLED) {
|
||||
throw new MisconfiguredEnvironmentError(
|
||||
'The large file import feature is not enabled on this server. Please contact your Speckle administrator.'
|
||||
)
|
||||
}
|
||||
|
||||
const [projectDb, projectStorage] = await Promise.all([
|
||||
getProjectDbClient({ projectId }),
|
||||
getProjectObjectStorage({ projectId })
|
||||
|
||||
@@ -14,9 +14,6 @@ import type { Nullable } from '@speckle/shared'
|
||||
import { ensureError } from '@speckle/shared'
|
||||
import { UploadRequestErrorMessage } from '@/modules/fileuploads/helpers/rest'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
|
||||
|
||||
export const fileuploadRouterFactory = (): Router => {
|
||||
const processNewFileStream = processNewFileStreamFactory()
|
||||
@@ -97,12 +94,10 @@ export const fileuploadRouterFactory = (): Router => {
|
||||
res.status(500)
|
||||
}
|
||||
|
||||
if (FF_LARGE_FILE_IMPORTS_ENABLED) {
|
||||
res.setHeader(
|
||||
'Warning',
|
||||
'Deprecated API; use POST /graphql (mutation.fileUploadMutations.generateUploadUrl), then PUT (to the provided url), then POST /graphql (mutation.fileUploadMutations.startFileImport)'
|
||||
)
|
||||
}
|
||||
res.setHeader(
|
||||
'Warning',
|
||||
'Deprecated API; use POST /graphql (mutation.fileUploadMutations.generateUploadUrl), then PUT (to the provided url), then POST /graphql (mutation.fileUploadMutations.startFileImport)'
|
||||
)
|
||||
|
||||
res.status(201).send({ uploadResults })
|
||||
},
|
||||
|
||||
@@ -12,9 +12,6 @@ import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import gql from 'graphql-tag'
|
||||
import type { SetNonNullable } from 'type-fest'
|
||||
import { getFeatureFlags } from '@speckle/shared/environment'
|
||||
|
||||
const { FF_LARGE_FILE_IMPORTS_ENABLED } = getFeatureFlags()
|
||||
|
||||
const testForbiddenResponse = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -193,336 +190,333 @@ const startFileImport = async (params: TestContext) => {
|
||||
})
|
||||
}
|
||||
|
||||
;(FF_LARGE_FILE_IMPORTS_ENABLED ? describe : describe.skip)(
|
||||
'Presigned graph @fileuploads',
|
||||
async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const regularServerUser = {
|
||||
id: '',
|
||||
name: 'regular server user',
|
||||
role: Roles.Server.User
|
||||
}
|
||||
const archivedUser = {
|
||||
id: '',
|
||||
name: 'archived user',
|
||||
role: Roles.Server.ArchivedUser
|
||||
}
|
||||
const unaffiliatedUser = {
|
||||
id: '',
|
||||
name: 'unaffiliated user',
|
||||
role: Roles.Server.Guest
|
||||
}
|
||||
describe('Presigned graph @fileuploads', async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const regularServerUser = {
|
||||
id: '',
|
||||
name: 'regular server user',
|
||||
role: Roles.Server.User
|
||||
}
|
||||
const archivedUser = {
|
||||
id: '',
|
||||
name: 'archived user',
|
||||
role: Roles.Server.ArchivedUser
|
||||
}
|
||||
const unaffiliatedUser = {
|
||||
id: '',
|
||||
name: 'unaffiliated user',
|
||||
role: Roles.Server.Guest
|
||||
}
|
||||
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
const contributorProject = {
|
||||
id: '',
|
||||
name: 'contributions are welcome',
|
||||
isPublic: false
|
||||
}
|
||||
const reviewerProject = {
|
||||
id: '',
|
||||
name: 'reviewer stream',
|
||||
isPublic: false
|
||||
}
|
||||
const noAccessProject = {
|
||||
id: '',
|
||||
name: 'cannot touch this',
|
||||
isPublic: false
|
||||
}
|
||||
const publicProject = {
|
||||
id: '',
|
||||
name: 'everyone can look',
|
||||
isPublic: true
|
||||
}
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
const contributorProject = {
|
||||
id: '',
|
||||
name: 'contributions are welcome',
|
||||
isPublic: false
|
||||
}
|
||||
const reviewerProject = {
|
||||
id: '',
|
||||
name: 'reviewer stream',
|
||||
isPublic: false
|
||||
}
|
||||
const noAccessProject = {
|
||||
id: '',
|
||||
name: 'cannot touch this',
|
||||
isPublic: false
|
||||
}
|
||||
const publicProject = {
|
||||
id: '',
|
||||
name: 'everyone can look',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
regularServerUser.id = (await createTestUser(regularServerUser)).id
|
||||
archivedUser.id = (await createTestUser(archivedUser)).id
|
||||
unaffiliatedUser.id = (await createTestUser(unaffiliatedUser)).id
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
regularServerUser.id = (await createTestUser(regularServerUser)).id
|
||||
archivedUser.id = (await createTestUser(archivedUser)).id
|
||||
unaffiliatedUser.id = (await createTestUser(unaffiliatedUser)).id
|
||||
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
|
||||
contributorProject.id = (
|
||||
await createProject({
|
||||
...contributorProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
contributorProject.id = (
|
||||
await createProject({
|
||||
...contributorProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
|
||||
reviewerProject.id = (
|
||||
await createProject({
|
||||
...reviewerProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
reviewerProject.id = (
|
||||
await createProject({
|
||||
...reviewerProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
|
||||
noAccessProject.id = (
|
||||
await createProject({
|
||||
...noAccessProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
noAccessProject.id = (
|
||||
await createProject({
|
||||
...noAccessProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
|
||||
publicProject.id = (
|
||||
await createProject({
|
||||
...publicProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
})
|
||||
publicProject.id = (
|
||||
await createProject({
|
||||
...publicProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
})
|
||||
|
||||
const testData: {
|
||||
user: Nullable<{ id: string; name: string; role: ServerRoles }>
|
||||
projectData: {
|
||||
project: { id: string; name: string; isPublic: boolean }
|
||||
projectRole: Nullable<StreamRoles>
|
||||
cases: {
|
||||
testCase: (params: TestContext) => Promise<void>
|
||||
shouldSucceed: boolean
|
||||
}[]
|
||||
const testData: {
|
||||
user: Nullable<{ id: string; name: string; role: ServerRoles }>
|
||||
projectData: {
|
||||
project: { id: string; name: string; isPublic: boolean }
|
||||
projectRole: Nullable<StreamRoles>
|
||||
cases: {
|
||||
testCase: (params: TestContext) => Promise<void>
|
||||
shouldSucceed: boolean
|
||||
}[]
|
||||
}[] = <const>[
|
||||
{
|
||||
user: regularServerUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: Roles.Stream.Owner,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: true },
|
||||
{ testCase: startFileImport, shouldSucceed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: Roles.Stream.Contributor,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: true },
|
||||
{ testCase: startFileImport, shouldSucceed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: Roles.Stream.Reviewer,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: archivedUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: Roles.Stream.Owner,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: Roles.Stream.Contributor,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: Roles.Stream.Reviewer,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: unaffiliatedUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: null,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}[]
|
||||
}[] = <const>[
|
||||
{
|
||||
user: regularServerUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: Roles.Stream.Owner,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: true },
|
||||
{ testCase: startFileImport, shouldSucceed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: Roles.Stream.Contributor,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: true },
|
||||
{ testCase: startFileImport, shouldSucceed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: Roles.Stream.Reviewer,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: archivedUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: Roles.Stream.Owner,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: Roles.Stream.Contributor,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: Roles.Stream.Reviewer,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: unaffiliatedUser,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
user: null,
|
||||
projectData: [
|
||||
{
|
||||
project: ownedProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: contributorProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: reviewerProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: noAccessProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
project: publicProject,
|
||||
projectRole: null,
|
||||
cases: [
|
||||
{ testCase: generateUploadUrl, shouldSucceed: false },
|
||||
{ testCase: startFileImport, shouldSucceed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
testData.forEach(async (userContext) => {
|
||||
const testUser = userContext.user
|
||||
testData.forEach(async (userContext) => {
|
||||
const testUser = userContext.user
|
||||
|
||||
describe(`User: ${testUser?.name ?? 'Anonymous'} as a ${
|
||||
testUser?.role ?? 'anonymous user'
|
||||
}`, async () => {
|
||||
let apollo: Awaited<ReturnType<typeof testApolloServer>>
|
||||
before(async () => {
|
||||
apollo = await testApolloServer({
|
||||
authUserId: testUser?.id
|
||||
})
|
||||
describe(`User: ${testUser?.name ?? 'Anonymous'} as a ${
|
||||
testUser?.role ?? 'anonymous user'
|
||||
}`, async () => {
|
||||
let apollo: Awaited<ReturnType<typeof testApolloServer>>
|
||||
before(async () => {
|
||||
apollo = await testApolloServer({
|
||||
authUserId: testUser?.id
|
||||
})
|
||||
})
|
||||
|
||||
userContext.projectData.forEach((projectContext) => {
|
||||
const project = projectContext.project
|
||||
const projectRole = projectContext.projectRole
|
||||
userContext.projectData.forEach((projectContext) => {
|
||||
const project = projectContext.project
|
||||
const projectRole = projectContext.projectRole
|
||||
|
||||
describe(`testing ${projectContext.cases.length} cases for project "${
|
||||
project.name
|
||||
}" where I, "${testUser?.name ?? 'anonymous'}", ${
|
||||
testUser && projectRole ? `have the role of ${projectRole}` : 'have no role'
|
||||
}`, () => {
|
||||
before(async () => {
|
||||
if (testUser && projectRole) {
|
||||
await grantProjectPermissions({
|
||||
projectId: project.id,
|
||||
userId: testUser.id,
|
||||
role: projectRole
|
||||
})
|
||||
}
|
||||
})
|
||||
describe(`testing ${projectContext.cases.length} cases for project "${
|
||||
project.name
|
||||
}" where I, "${testUser?.name ?? 'anonymous'}", ${
|
||||
testUser && projectRole ? `have the role of ${projectRole}` : 'have no role'
|
||||
}`, () => {
|
||||
before(async () => {
|
||||
if (testUser && projectRole) {
|
||||
await grantProjectPermissions({
|
||||
projectId: project.id,
|
||||
userId: testUser.id,
|
||||
role: projectRole
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
projectContext.cases.forEach((value) => {
|
||||
it(`${value.shouldSucceed ? 'should' : 'should not be allowed to'} ${
|
||||
value.testCase.name
|
||||
}`, async () => {
|
||||
await value.testCase({
|
||||
apollo,
|
||||
projectId: project.id,
|
||||
userId: testUser?.id,
|
||||
fileName: `${cryptoRandomString({ length: 10 })}.${FILE_TYPE}`,
|
||||
shouldSucceed: value.shouldSucceed
|
||||
})
|
||||
projectContext.cases.forEach((value) => {
|
||||
it(`${value.shouldSucceed ? 'should' : 'should not be allowed to'} ${
|
||||
value.testCase.name
|
||||
}`, async () => {
|
||||
await value.testCase({
|
||||
apollo,
|
||||
projectId: project.id,
|
||||
userId: testUser?.id,
|
||||
fileName: `${cryptoRandomString({ length: 10 })}.${FILE_TYPE}`,
|
||||
shouldSucceed: value.shouldSucceed
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+349
-353
@@ -56,367 +56,363 @@ import type { RegisterUploadCompleteAndStartFileImport } from '@/modules/fileupl
|
||||
import type { BasicTestBranch } from '@/test/speckle-helpers/branchHelper'
|
||||
import { createTestBranch } from '@/test/speckle-helpers/branchHelper'
|
||||
|
||||
const { FF_LARGE_FILE_IMPORTS_ENABLED, FF_NEXT_GEN_FILE_IMPORTER_ENABLED } =
|
||||
getFeatureFlags()
|
||||
const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
|
||||
|
||||
;(FF_LARGE_FILE_IMPORTS_ENABLED ? describe : describe.skip)(
|
||||
'Presigned integration @fileuploads',
|
||||
async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
const model: BasicTestBranch = {
|
||||
name: cryptoRandomString({ length: 10 }),
|
||||
id: '',
|
||||
streamId: '',
|
||||
authorId: ''
|
||||
}
|
||||
describe('Presigned integration @fileuploads', async () => {
|
||||
const serverAdmin = { id: '', name: 'server admin', role: Roles.Server.Admin }
|
||||
const ownedProject = {
|
||||
id: '',
|
||||
name: 'owned stream',
|
||||
isPublic: false
|
||||
}
|
||||
const model: BasicTestBranch = {
|
||||
name: cryptoRandomString({ length: 10 }),
|
||||
id: '',
|
||||
streamId: '',
|
||||
authorId: ''
|
||||
}
|
||||
|
||||
let projectDb: Knex
|
||||
let projectStorage: { private: ObjectStorage; public: ObjectStorage }
|
||||
let projectDb: Knex
|
||||
let projectStorage: { private: ObjectStorage; public: ObjectStorage }
|
||||
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
).id
|
||||
|
||||
await createTestBranch({
|
||||
branch: model,
|
||||
stream: {
|
||||
id: ownedProject.id,
|
||||
name: '', //ignored
|
||||
isPublic: false, //ignored
|
||||
ownerId: '' //ignored
|
||||
},
|
||||
owner: {
|
||||
name: '', //ignored
|
||||
email: '', //ignored
|
||||
id: serverAdmin.id
|
||||
}
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
serverAdmin.id = (await createTestUser(serverAdmin)).id
|
||||
ownedProject.id = (
|
||||
await createProject({
|
||||
...ownedProject,
|
||||
ownerId: serverAdmin.id
|
||||
})
|
||||
;[projectDb, projectStorage] = await Promise.all([
|
||||
getProjectDbClient({ projectId: ownedProject.id }),
|
||||
getProjectObjectStorage({ projectId: ownedProject.id })
|
||||
])
|
||||
).id
|
||||
|
||||
await createTestBranch({
|
||||
branch: model,
|
||||
stream: {
|
||||
id: ownedProject.id,
|
||||
name: '', //ignored
|
||||
isPublic: false, //ignored
|
||||
ownerId: '' //ignored
|
||||
},
|
||||
owner: {
|
||||
name: '', //ignored
|
||||
email: '', //ignored
|
||||
id: serverAdmin.id
|
||||
}
|
||||
})
|
||||
describe('register completed upload and start file import', () => {
|
||||
let generatePresignedUrl: ReturnType<typeof generatePresignedUrlFactory>
|
||||
let SUT: RegisterUploadCompleteAndStartFileImport
|
||||
before(() => {
|
||||
generatePresignedUrl = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
;[projectDb, projectStorage] = await Promise.all([
|
||||
getProjectDbClient({ projectId: ownedProject.id }),
|
||||
getProjectObjectStorage({ projectId: ownedProject.id })
|
||||
])
|
||||
})
|
||||
describe('register completed upload and start file import', () => {
|
||||
let generatePresignedUrl: ReturnType<typeof generatePresignedUrlFactory>
|
||||
let SUT: RegisterUploadCompleteAndStartFileImport
|
||||
before(() => {
|
||||
generatePresignedUrl = generatePresignedUrlFactory({
|
||||
getSignedUrl: getSignedUrlFactory({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
const insertNewUploadAndNotify = FF_NEXT_GEN_FILE_IMPORTER_ENABLED
|
||||
? insertNewUploadAndNotifyFactoryV2({
|
||||
queues: [
|
||||
{
|
||||
supportedFileTypes: ['stl', 'obj', 'ifc'],
|
||||
scheduleJob: () => Promise.resolve()
|
||||
}
|
||||
],
|
||||
pushJobToFileImporter: pushJobToFileImporterFactory({
|
||||
getServerOrigin,
|
||||
createAppToken: createAppTokenFactory({
|
||||
storeApiToken: storeApiTokenFactory({ db: projectDb }),
|
||||
storeTokenScopes: storeTokenScopesFactory({ db: projectDb }),
|
||||
storeTokenResourceAccessDefinitions:
|
||||
storeTokenResourceAccessDefinitionsFactory({
|
||||
db: projectDb
|
||||
}),
|
||||
storeUserServerAppToken: storeUserServerAppTokenFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
}),
|
||||
saveUploadFile: saveUploadFileFactoryV2({ db: projectDb }),
|
||||
emit: getEventBus().emit
|
||||
})
|
||||
: insertNewUploadAndNotifyFactory({
|
||||
saveUploadFile: saveUploadFileFactory({ db: projectDb }),
|
||||
emit: getEventBus().emit
|
||||
})
|
||||
|
||||
SUT = registerUploadCompleteAndStartFileImportFactory({
|
||||
registerCompletedUpload: registerCompletedUploadFactory({
|
||||
getBlob: getBlobFactory({ db: projectDb }),
|
||||
getBlobMetadata: getBlobMetadataFromStorage({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
upsertBlob: upsertBlobFactory({
|
||||
updateBlob: updateBlobFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
const insertNewUploadAndNotify = FF_NEXT_GEN_FILE_IMPORTER_ENABLED
|
||||
? insertNewUploadAndNotifyFactoryV2({
|
||||
queues: [
|
||||
{
|
||||
supportedFileTypes: ['stl', 'obj', 'ifc'],
|
||||
scheduleJob: () => Promise.resolve()
|
||||
}
|
||||
],
|
||||
pushJobToFileImporter: pushJobToFileImporterFactory({
|
||||
getServerOrigin,
|
||||
createAppToken: createAppTokenFactory({
|
||||
storeApiToken: storeApiTokenFactory({ db: projectDb }),
|
||||
storeTokenScopes: storeTokenScopesFactory({ db: projectDb }),
|
||||
storeTokenResourceAccessDefinitions:
|
||||
storeTokenResourceAccessDefinitionsFactory({
|
||||
db: projectDb
|
||||
}),
|
||||
storeUserServerAppToken: storeUserServerAppTokenFactory({
|
||||
db: projectDb
|
||||
})
|
||||
})
|
||||
}),
|
||||
saveUploadFile: saveUploadFileFactoryV2({ db: projectDb }),
|
||||
emit: getEventBus().emit
|
||||
})
|
||||
: insertNewUploadAndNotifyFactory({
|
||||
saveUploadFile: saveUploadFileFactory({ db: projectDb }),
|
||||
emit: getEventBus().emit
|
||||
})
|
||||
|
||||
SUT = registerUploadCompleteAndStartFileImportFactory({
|
||||
registerCompletedUpload: registerCompletedUploadFactory({
|
||||
getBlob: getBlobFactory({ db: projectDb }),
|
||||
getBlobMetadata: getBlobMetadataFromStorage({
|
||||
objectStorage: projectStorage.public
|
||||
}),
|
||||
updateBlob: updateBlobFactory({
|
||||
db: projectDb
|
||||
}),
|
||||
logger: testLogger
|
||||
}),
|
||||
insertNewUploadAndNotify,
|
||||
getFileInfo: getFileInfoFactoryV2({ db: projectDb }),
|
||||
getModelsByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
})
|
||||
})
|
||||
it('should create a record for the uploaded file', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
})
|
||||
it('should throw a StoredBlobAccessError if the blob cannot be found', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
// Do not upload any file, and skip straight to requesting the file be imported
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
projectId: ownedProject.id,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
expectedETag: cryptoRandomString({ length: 32 }),
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(StoredBlobAccessError)
|
||||
})
|
||||
it('should throw an UserInputError if the file exceeds the maximum allowed size', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content') // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // 1 byte max
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
// Verify that the blob is now marked as in error state
|
||||
const blobs = await getBlobsFactory({ db: projectDb })({
|
||||
streamId: ownedProject.id,
|
||||
blobIds: [fileId]
|
||||
})
|
||||
expect(blobs).to.have.lengthOf(1)
|
||||
expect(blobs[0].uploadStatus).to.equal(BlobUploadStatus.Error)
|
||||
expect(blobs[0].uploadError).to.include('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering should be idempotent', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(thrownError.message).to.include('Blob already registered and completed')
|
||||
})
|
||||
it('re-registering with increased maximum file size after failure results in the file being processed', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than fileSize, so expected to throw
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: fileSize + 100 // an increased size, greater than the fileSize
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering with decreased maximum file size does not change anything', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: fileSize + 100
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than our fileSize, but it is already registered so should throw
|
||||
})
|
||||
)
|
||||
|
||||
expect(thrownError).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(thrownError.message).to.include('Blob already registered and completed')
|
||||
logger: testLogger
|
||||
}),
|
||||
insertNewUploadAndNotify,
|
||||
getFileInfo: getFileInfoFactoryV2({ db: projectDb }),
|
||||
getModelsByIds: getBranchesByIdsFactory({ db: projectDb })
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
it('should create a record for the uploaded file', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
})
|
||||
it('should throw a StoredBlobAccessError if the blob cannot be found', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
// Do not upload any file, and skip straight to requesting the file be imported
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
projectId: ownedProject.id,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
expectedETag: cryptoRandomString({ length: 32 }),
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(StoredBlobAccessError)
|
||||
})
|
||||
it('should throw an UserInputError if the file exceeds the maximum allowed size', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, 'test content') // more than 1 byte long
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // 1 byte max
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
// Verify that the blob is now marked as in error state
|
||||
const blobs = await getBlobsFactory({ db: projectDb })({
|
||||
streamId: ownedProject.id,
|
||||
blobIds: [fileId]
|
||||
})
|
||||
expect(blobs).to.have.lengthOf(1)
|
||||
expect(blobs[0].uploadStatus).to.equal(BlobUploadStatus.Error)
|
||||
expect(blobs[0].uploadError).to.include('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering should be idempotent', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 * 1024 * 1024 // 1 MB
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(thrownError.message).to.include('Blob already registered and completed')
|
||||
})
|
||||
it('re-registering with increased maximum file size after failure results in the file being processed', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than fileSize, so expected to throw
|
||||
})
|
||||
)
|
||||
expect(thrownError).to.be.instanceOf(UserInputError)
|
||||
|
||||
const secondAttempt = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: fileSize + 100 // an increased size, greater than the fileSize
|
||||
})
|
||||
)
|
||||
|
||||
expect(secondAttempt).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(secondAttempt.message).to.contain('[FILE_SIZE_EXCEEDED]')
|
||||
})
|
||||
it('re-registering with decreased maximum file size does not change anything', async () => {
|
||||
const fileId = cryptoRandomString({ length: 10 })
|
||||
const fileSize = 10
|
||||
const fileName = `test-file-${cryptoRandomString({ length: 10 })}.stl`
|
||||
const expiryDuration = 1 * TIME.minute
|
||||
const url = await generatePresignedUrl({
|
||||
blobId: fileId,
|
||||
fileName,
|
||||
projectId: ownedProject.id,
|
||||
userId: serverAdmin.id,
|
||||
urlExpiryDurationSeconds: expiryDuration
|
||||
})
|
||||
|
||||
const response = await axios.put(url, cryptoRandomString({ length: fileSize }))
|
||||
expect(
|
||||
response.status,
|
||||
JSON.stringify({ statusText: response.statusText, body: response.data })
|
||||
).to.equal(200)
|
||||
expect(response.headers['etag'], JSON.stringify(response.headers)).to.exist
|
||||
|
||||
const expectedETag = response.headers['etag']
|
||||
const storedFile = await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: fileSize + 100
|
||||
})
|
||||
|
||||
expect(storedFile).to.exist
|
||||
expect(storedFile.fileType).to.equal('stl')
|
||||
expect(storedFile.fileSize).to.equal(fileSize)
|
||||
expect(storedFile.uploadComplete).to.be.true
|
||||
|
||||
const thrownError = await expectToThrow(
|
||||
async () =>
|
||||
await SUT({
|
||||
fileId,
|
||||
modelId: model.id,
|
||||
userId: serverAdmin.id,
|
||||
projectId: ownedProject.id,
|
||||
expectedETag,
|
||||
maximumFileSize: 1 // smaller than our fileSize, but it is already registered so should throw
|
||||
})
|
||||
)
|
||||
|
||||
expect(thrownError).to.be.instanceOf(AlreadyRegisteredBlobError)
|
||||
expect(thrownError.message).to.include('Blob already registered and completed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,8 +104,7 @@ export const parseFeatureFlags = (
|
||||
},
|
||||
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: {
|
||||
schema: z.boolean(),
|
||||
description:
|
||||
'Enables the new file importer. Requires FF_LARGE_FILE_IMPORTS_ENABLED to be true.',
|
||||
description: 'Enables the new file importer.',
|
||||
defaults: { _: false }
|
||||
},
|
||||
FF_RHINO_FILE_IMPORTER_ENABLED: {
|
||||
@@ -118,10 +117,10 @@ export const parseFeatureFlags = (
|
||||
description: 'Enables the postgres based background job mechanism',
|
||||
defaults: { _: false }
|
||||
},
|
||||
FF_LARGE_FILE_IMPORTS_ENABLED: {
|
||||
FF_LEGACY_FILE_IMPORTS_ENABLED: {
|
||||
schema: z.boolean(),
|
||||
description:
|
||||
'Enables the new file importer to handle large files via pre-signed URLs.',
|
||||
'Enables the legacy file importer. This proxies file uploads via REST API on the server instead of directly PUTing files to S3 via pre-signed urls.',
|
||||
defaults: { _: false }
|
||||
},
|
||||
FF_LEGACY_IFC_IMPORTER_ENABLED: {
|
||||
@@ -170,7 +169,7 @@ export type FeatureFlags = {
|
||||
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: boolean
|
||||
FF_RHINO_FILE_IMPORTER_ENABLED: boolean
|
||||
FF_BACKGROUND_JOBS_ENABLED: boolean
|
||||
FF_LARGE_FILE_IMPORTS_ENABLED: boolean
|
||||
FF_LEGACY_FILE_IMPORTS_ENABLED: boolean
|
||||
FF_LEGACY_IFC_IMPORTER_ENABLED: boolean
|
||||
FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED: boolean
|
||||
}
|
||||
|
||||
@@ -1155,10 +1155,6 @@ Generate the environment variables for Speckle server and Speckle objects deploy
|
||||
name: {{ default .Values.secretName .Values.ifc_import_service.db.connectionString.secretName }}
|
||||
key: {{ default "fileimport_queue_postgres_url" .Values.ifc_import_service.db.connectionString.secretKey }}
|
||||
{{- end }}
|
||||
{{- if .Values.featureFlags.largeFileUploadsEnabled }}
|
||||
- name: FF_LARGE_FILE_IMPORTS_ENABLED
|
||||
value: {{ .Values.featureFlags.largeFileUploadsEnabled | quote }}
|
||||
- name: FILE_UPLOAD_URL_EXPIRY_MINUTES
|
||||
value: {{ .Values.file_upload_url_expiry_minutes | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -143,8 +143,8 @@ spec:
|
||||
value: {{ .Values.featureFlags.workspacesNewPlanEnabled | quote }}
|
||||
- name: NUXT_PUBLIC_FF_NEXT_GEN_FILE_IMPORTER_ENABLED
|
||||
value: {{ .Values.featureFlags.nextGenFileImporterEnabled | quote }}
|
||||
- name: NUXT_PUBLIC_FF_LARGE_FILE_IMPORTS_ENABLED
|
||||
value: {{ .Values.featureFlags.largeFileUploadsEnabled | quote }}
|
||||
- name: NUXT_PUBLIC_FF_LEGACY_FILE_IMPORTS_ENABLED
|
||||
value: {{ .Values.featureFlags.legacyFileImportsEnabled | quote }}
|
||||
{{- if .Values.analytics.intercom_app_id }}
|
||||
- name: NUXT_PUBLIC_INTERCOM_APP_ID
|
||||
value: {{ .Values.analytics.intercom_app_id | quote }}
|
||||
|
||||
@@ -110,9 +110,9 @@
|
||||
"description": "Enables the next generation file importer",
|
||||
"default": false
|
||||
},
|
||||
"largeFileUploadsEnabled": {
|
||||
"legacyFileImportsEnabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enables the ability to upload large files to Speckle",
|
||||
"description": "Enables the legacy file upload mechanism, using REST API to proxy file uploads via the server",
|
||||
"default": false
|
||||
},
|
||||
"experimentalIfcImporterEnabled": {
|
||||
|
||||
@@ -65,8 +65,8 @@ featureFlags:
|
||||
retryErroredPreviewsEnabled: false
|
||||
## @param featureFlags.nextGenFileImporterEnabled Enables the next generation file importer
|
||||
nextGenFileImporterEnabled: false
|
||||
## @param featureFlags.largeFileUploadsEnabled Enables the ability to upload large files to Speckle
|
||||
largeFileUploadsEnabled: false
|
||||
## @param featureFlags.legacyFileImportsEnabled Enables the legacy file upload mechanism, using REST API to proxy file uploads via the server
|
||||
legacyFileImportsEnabled: false
|
||||
## @param featureFlags.experimentalIfcImporterEnabled Enables the ability to parse IFC files using the experimental IFC importer
|
||||
experimentalIfcImporterEnabled: false
|
||||
## @param featureFlags.legacyIfcImporterEnabled Enables the ability to parse IFC files using the legacy IFC importer.
|
||||
|
||||
Reference in New Issue
Block a user