diff --git a/lib/core/bucketEntries/MongoDBBucketEntriesRepository.ts b/lib/core/bucketEntries/MongoDBBucketEntriesRepository.ts index db9f8134..fa9a7586 100644 --- a/lib/core/bucketEntries/MongoDBBucketEntriesRepository.ts +++ b/lib/core/bucketEntries/MongoDBBucketEntriesRepository.ts @@ -3,6 +3,21 @@ import { BucketEntry, BucketEntryWithFrame } from './BucketEntry'; import { BucketEntriesRepository } from './Repository'; import { ObjectId } from 'mongodb'; +/** + * The fields that mark an entry as backed by storage: `frame` on v1 entries, + * `index` and `hmac` on v2 uploads. Gateway entries (createEntry) set none of + * them, which is what makes them safe to drop wholesale. + */ +const SHARD_MARKERS = ['frame', 'index', 'hmac.value']; + +const METADATA_ONLY = { + $and: SHARD_MARKERS.map((field) => ({ [field]: { $exists: false } })), +}; + +const SHARD_BACKED = { + $or: SHARD_MARKERS.map((field) => ({ [field]: { $exists: true } })), +}; + interface BucketEntryModel extends Omit { _id: string; created: Date; @@ -90,6 +105,40 @@ export class MongoDBBucketEntriesRepository implements BucketEntriesRepository { return bucketEntries.map(formatFromMongoToBucketEntry); } + async hasEntriesByBucket(bucketId: string): Promise { + const found = await this.model + .countDocuments({ bucket: bucketId }, { limit: 1 }) + .read('primary') + .exec(); + + return found > 0; + } + + async hasShardBackedEntriesByBucket(bucketId: string): Promise { + const found = await this.model + .countDocuments({ bucket: bucketId, ...SHARD_BACKED }, { limit: 1 }) + .read('primary') + .exec(); + + return found > 0; + } + + async sumMetadataOnlyBytesByBucket(bucketId: string): Promise { + const [result] = await this.model + .aggregate([ + { $match: { bucket: new ObjectId(bucketId), ...METADATA_ONLY } }, + { $group: { _id: null, bytes: { $sum: '$size' } } }, + ]) + .read('primary') + .exec(); + + return result?.bytes ?? 0; + } + + async deleteMetadataOnlyByBucket(bucketId: string): Promise { + await this.model.deleteMany({ bucket: bucketId, ...METADATA_ONLY }); + } + async findByIds(ids: string[]): Promise { const bucketEntries = await this.model.find({ _id: { $in: ids } }); diff --git a/lib/core/bucketEntries/Repository.ts b/lib/core/bucketEntries/Repository.ts index 4cc7c926..fd847ad7 100644 --- a/lib/core/bucketEntries/Repository.ts +++ b/lib/core/bucketEntries/Repository.ts @@ -6,6 +6,10 @@ export interface BucketEntriesRepository { count(where: Partial): Promise; findOne(where: Partial): Promise; findByBucket(bucketId: Bucket['id'], limit: number, offset: number): Promise; + hasEntriesByBucket(bucketId: Bucket['id']): Promise; + hasShardBackedEntriesByBucket(bucketId: Bucket['id']): Promise; + sumMetadataOnlyBytesByBucket(bucketId: Bucket['id']): Promise; + deleteMetadataOnlyByBucket(bucketId: Bucket['id']): Promise; findByIds(ids: BucketEntry['id'][]): Promise; findOneWithFrame(where: Partial): Promise & { frame?: Frame } | null>; findByIdsWithFrames(ids: BucketEntry['id'][]): Promise<(Omit & { frame?: Frame })[]>; diff --git a/lib/core/bucketEntries/usecase.ts b/lib/core/bucketEntries/usecase.ts index f1ec0c93..01254f41 100644 --- a/lib/core/bucketEntries/usecase.ts +++ b/lib/core/bucketEntries/usecase.ts @@ -1,5 +1,6 @@ import lodash from 'lodash'; +import log from '../../logger'; import { BucketsRepository } from '../buckets/Repository'; import { BucketEntriesRepository } from './Repository'; import { BucketNotFoundError, BucketForbiddenError, BucketEntryNotFoundError } from '../buckets/usecase'; @@ -16,6 +17,20 @@ import { User } from '../users/User'; import { Bucket } from '../buckets/Bucket'; import { FileStateRepository } from '../fileState/Repository'; +/** + * Raised when a bucket turns out to hold entries backed by shards. + * + * Dropping those entries wholesale would strand their shards, mirrors and the + * bytes on the farmers with nothing left pointing at them. + */ +export class ShardBackedBucketError extends Error { + constructor() { + super('Bucket holds shard-backed entries and cannot be purged by this route'); + + Object.setPrototypeOf(this, ShardBackedBucketError.prototype); + } +} + export class BucketEntryVersionNotFoundError extends Error { constructor() { super('BucketEntryVersion not found'); @@ -179,8 +194,8 @@ export class BucketEntriesUsecase { await this.bucketEntryShardsRepository.deleteByIds(bucketEntryShardsIds); } - await this.bucketEntriesRepository.deleteByIds(fileIds); await this.fileStateRepository.deleteByBucketEntryIds(fileIds); + await this.bucketEntriesRepository.deleteByIds(fileIds); } private async findBucketOwner( @@ -254,4 +269,84 @@ export class BucketEntriesUsecase { totalUsedSpaceBytes, }; } + + /** + * Removes a bucket and every entry in it. + * + * The entries this purges are metadata only: createEntry() writes a row with + * a bucket, a size and a version, and nothing else. Nothing downstream of a + * bucket entry exists for them, which is what makes a wholesale delete + * possible instead of walking each entry and its shards. + * + * A Drive bucket reaching this method would lose its files and strand their + * shards with nothing left to enumerate them by. The guards below read + * themselves, but note that the last of them - the delete only ever matching + * metadata-only entries - holds even if every check above it is wrong. + * + * BucketsUsecase.deleteBucketByIdAndUser is the legacy-stack equivalent; it + * drops the bucket document alone and does no usage accounting. + */ + async removeBucketAndEntries( + userUuid: User['uuid'], + bucketId: Bucket['id'], + bucketName: Bucket['name'] + ): Promise { + const [user, bucket] = await Promise.all([ + this.usersRepository.findByUuid(userUuid), + this.bucketsRepository.findOne({ id: bucketId }), + ]); + + if (!user) { + throw new UserNotFoundError(userUuid); + } + + if (!bucket) { + throw new BucketNotFoundError(); + } + + if (bucket.userId !== userUuid) { + throw new BucketForbiddenError(); + } + + if (bucket.name !== bucketName) { + throw new BucketNotFoundError(); + } + + if (await this.bucketEntriesRepository.hasShardBackedEntriesByBucket(bucketId)) { + throw new ShardBackedBucketError(); + } + + const metadataOnlyBytes = + await this.bucketEntriesRepository.sumMetadataOnlyBytesByBucket(bucketId); + + let totalUsedSpaceBytes = user.totalUsedSpaceBytes; + + log.info( + `[removeBucketAndEntries] purging bucket - userUuid: ${userUuid}, bucketId: ${bucketId}, metadataOnlyBytes: ${metadataOnlyBytes}` + ); + + await this.bucketEntriesRepository.deleteMetadataOnlyByBucket(bucketId); + + if (metadataOnlyBytes > 0) { + totalUsedSpaceBytes = await this.usersRepository.addTotalUsedSpaceBytes( + userUuid, + -metadataOnlyBytes + ); + } + + log.info( + `[removeBucketAndEntries] usage credited - userUuid: ${userUuid}, bucketId: ${bucketId}, releasedBytes: ${metadataOnlyBytes}, totalUsedSpaceBytes: ${totalUsedSpaceBytes}` + ); + + if (await this.bucketEntriesRepository.hasEntriesByBucket(bucketId)) { + throw new ShardBackedBucketError(); + } + + await this.bucketsRepository.removeByIdAndUser(bucketId, userUuid); + + return { + maxSpaceBytes: user.maxSpaceBytes, + totalUsedSpaceBytes, + }; + } } diff --git a/lib/server/http/gateway/controller.ts b/lib/server/http/gateway/controller.ts index 5c1a180d..cd3cc826 100644 --- a/lib/server/http/gateway/controller.ts +++ b/lib/server/http/gateway/controller.ts @@ -2,8 +2,8 @@ import { Request, Response } from 'express'; import { validate as uuidValidate } from 'uuid'; import { Logger } from 'winston'; import { EmailIsAlreadyInUseError, InvalidDataFormatError, UserAlreadyExistsError, UserNotFoundError, UserSpaceSnapshot, UsersUsecase } from '../../../core'; -import { BucketEntriesUsecase } from '../../../core/bucketEntries/usecase'; -import { BucketNotFoundError } from '../../../core/buckets/usecase'; +import { BucketEntriesUsecase, ShardBackedBucketError } from '../../../core/bucketEntries/usecase'; +import { BucketForbiddenError, BucketNotFoundError } from '../../../core/buckets/usecase'; import { GatewayUsecase } from '../../../core/gateway/Usecase'; import { EventBus, EventBusEvents, UserStorageChangedPayload } from '../../eventBus'; @@ -205,6 +205,56 @@ export class HTTPGatewayController { } } + async deleteUserBucket( + req: Request<{ uuid: string; id: string }, {}, {}, { name?: unknown }>, + res: Response + ) { + const { uuid, id } = req.params; + const { name } = req.query; + + if (!uuid || !uuidValidate(uuid) || !id || !OBJECT_ID_PATTERN.test(id)) { + return res.status(400).send({ message: 'Invalid params' }); + } + + if (typeof name !== 'string' || name.length === 0) { + return res.status(400).send({ message: 'name is required' }); + } + + try { + const snapshot = await this.bucketEntriesUsecase.removeBucketAndEntries(uuid, id, name); + + return res.status(200).send(snapshot); + } catch (err) { + if (err instanceof UserNotFoundError || err instanceof BucketNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + if (err instanceof BucketForbiddenError) { + return res.status(403).send({ message: err.message }); + } + + if (err instanceof ShardBackedBucketError) { + this.logger.warn( + '[GATEWAY/DELETE_BUCKET] Refused to purge shard-backed bucket %s of user %s', + id, + uuid + ); + + return res.status(409).send({ message: err.message }); + } + + this.logger.error( + '[GATEWAY/DELETE_BUCKET] Error deleting bucket %s of user %s: %s. %s', + id, + uuid, + (err as Error).message, + (err as Error).stack || 'NO STACK' + ); + + return res.status(500).send({ message: 'Internal server error' }); + } + } + async createBucketEntry( req: Request<{ uuid: string; id: string }, {}, Partial, {}>, res: Response diff --git a/lib/server/http/gateway/index.ts b/lib/server/http/gateway/index.ts index 52acc390..f971873b 100644 --- a/lib/server/http/gateway/index.ts +++ b/lib/server/http/gateway/index.ts @@ -12,6 +12,7 @@ export const createGatewayHTTPRouter = ( router.patch('/users/:uuid', jwtMiddleware, controller.updateUserEmail.bind(controller)); router.put('/storage/users/:uuid', jwtMiddleware, controller.changeStorage.bind(controller)); router.post('/users/:uuid/buckets', jwtMiddleware, controller.createUserBucket.bind(controller)); + router.delete('/users/:uuid/buckets/:id', jwtMiddleware, controller.deleteUserBucket.bind(controller)); router.post('/users/:uuid/buckets/:id/entries', jwtMiddleware, controller.createBucketEntry.bind(controller)); router.delete('/users/:uuid/buckets/:id/entries/:entryId', jwtMiddleware, controller.deleteBucketEntry.bind(controller)); router.delete('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller)); diff --git a/tests/lib/core/bucketentries/usecase.test.ts b/tests/lib/core/bucketentries/usecase.test.ts index 6cac4614..c0e30bfc 100644 --- a/tests/lib/core/bucketentries/usecase.test.ts +++ b/tests/lib/core/bucketentries/usecase.test.ts @@ -12,7 +12,7 @@ import { UsersRepository } from '../../../../lib/core/users/Repository'; import { MongoDBBucketsRepository } from '../../../../lib/core/buckets/MongoDBBucketsRepository'; import { MongoDBBucketEntriesRepository } from '../../../../lib/core/bucketEntries/MongoDBBucketEntriesRepository'; -import { BucketEntriesUsecase, BucketEntryVersionNotFoundError } from '../../../../lib/core/bucketEntries/usecase'; +import { BucketEntriesUsecase, BucketEntryVersionNotFoundError, ShardBackedBucketError } from '../../../../lib/core/bucketEntries/usecase'; import { BucketEntryNotFoundError, BucketForbiddenError, BucketNotFoundError } from '../../../../lib/core/buckets/usecase'; import { MongoDBFramesRepository } from '../../../../lib/core/frames/MongoDBFramesRepository'; import { MongoDBMirrorsRepository } from '../../../../lib/core/mirrors/MongoDBMirrorsRepository'; @@ -25,6 +25,7 @@ import { ShardsUsecase } from '../../../../lib/core/shards/usecase'; import fixtures from '../fixtures'; import { BucketEntry } from '../../../../lib/core/bucketEntries/BucketEntry'; import { Bucket } from '../../../../lib/core/buckets/Bucket'; +import { User } from '../../../../lib/core/users/User'; import { ContactsRepository } from '../../../../lib/core/contacts/Repository'; import { MongoDBContactsRepository } from '../../../../lib/core/contacts/MongoDBContactsRepository'; import { FileStateRepository } from '../../../../lib/core/fileState/Repository'; @@ -753,4 +754,186 @@ describe('BucketEntriesUsecase', function () { expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 3500 }); }); }); + + describe('removeBucketAndEntries()', () => { + const stubRepositories = ( + user: User | null, + bucket: Bucket | null, + { + shardBacked = false, + metadataOnlyBytes = 0, + remaining = false, + newTotal = 0, + } = {} + ) => { + stub(usersRepository, 'findByUuid').resolves(user); + stub(bucketsRepository, 'findOne').resolves(bucket); + + return { + hasShardBacked: stub(bucketEntriesRepository, 'hasShardBackedEntriesByBucket').resolves( + shardBacked + ), + sumBytes: stub(bucketEntriesRepository, 'sumMetadataOnlyBytesByBucket').resolves( + metadataOnlyBytes + ), + hasRemaining: stub(bucketEntriesRepository, 'hasEntriesByBucket').resolves(remaining), + deleteEntries: stub(bucketEntriesRepository, 'deleteMetadataOnlyByBucket').resolves(), + addUsage: stub(usersRepository, 'addTotalUsedSpaceBytes').resolves(newTotal), + removeBucket: stub(bucketsRepository, 'removeByIdAndUser').resolves(), + }; + }; + + const getOwner = () => fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 }); + const getMailBucket = (user: User) => + fixtures.getBucket({ userId: user.uuid, name: 'address-uuid' }); + + it('When the user does not exist, then it throws UserNotFoundError', async () => { + const { deleteEntries, removeBucket, addUsage } = stubRepositories(null, null); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries('unknown-uuid', 'bucket-id', 'address-uuid') + ).rejects.toBeInstanceOf(UserNotFoundError); + + expect(deleteEntries.called).toBeFalsy(); + expect(removeBucket.called).toBeFalsy(); + expect(addUsage.called).toBeFalsy(); + }); + + it('When the bucket document does not exist, then it throws BucketNotFoundError and purges nothing', async () => { + const user = getOwner(); + const { deleteEntries, removeBucket, addUsage } = stubRepositories(user, null); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries(user.uuid, 'bucket-id', 'address-uuid') + ).rejects.toBeInstanceOf(BucketNotFoundError); + + expect(deleteEntries.called).toBeFalsy(); + expect(removeBucket.called).toBeFalsy(); + expect(addUsage.called).toBeFalsy(); + }); + + it('When the bucket belongs to another user, then it throws BucketForbiddenError and removes nothing', async () => { + const user = getOwner(); + const { deleteEntries, removeBucket, addUsage } = stubRepositories( + user, + fixtures.getBucket({ name: 'address-uuid' }) + ); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries(user.uuid, 'bucket-id', 'address-uuid') + ).rejects.toBeInstanceOf(BucketForbiddenError); + + expect(deleteEntries.called).toBeFalsy(); + expect(removeBucket.called).toBeFalsy(); + expect(addUsage.called).toBeFalsy(); + }); + + it('When the name does not match the bucket, then it throws BucketNotFoundError and removes nothing', async () => { + const user = getOwner(); + const driveBucket = fixtures.getBucket({ userId: user.uuid, name: 'Bucket-a1b2c3' }); + const { deleteEntries, removeBucket, addUsage } = stubRepositories(user, driveBucket); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries(user.uuid, driveBucket.id, 'address-uuid') + ).rejects.toBeInstanceOf(BucketNotFoundError); + + expect(deleteEntries.called).toBeFalsy(); + expect(removeBucket.called).toBeFalsy(); + expect(addUsage.called).toBeFalsy(); + }); + + it('When a single shard-backed entry exists among many, then it refuses without scanning the bucket', async () => { + const user = getOwner(); + const bucket = getMailBucket(user); + const { sumBytes, deleteEntries, removeBucket, addUsage } = stubRepositories(user, bucket, { + shardBacked: true, + metadataOnlyBytes: 500, + }); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries(user.uuid, bucket.id, bucket.name) + ).rejects.toBeInstanceOf(ShardBackedBucketError); + + expect(sumBytes.called).toBeFalsy(); + expect(deleteEntries.called).toBeFalsy(); + expect(removeBucket.called).toBeFalsy(); + expect(addUsage.called).toBeFalsy(); + }); + + it('When entries survive the purge, then the bucket document is kept', async () => { + const user = getOwner(); + const bucket = getMailBucket(user); + const { removeBucket } = stubRepositories(user, bucket, { + metadataOnlyBytes: 500, + remaining: true, + newTotal: 3500, + }); + + await expect( + bucketEntriesUsecase.removeBucketAndEntries(user.uuid, bucket.id, bucket.name) + ).rejects.toBeInstanceOf(ShardBackedBucketError); + + expect(removeBucket.called).toBeFalsy(); + }); + + it('When the bucket is empty, then it is removed and the total is untouched', async () => { + const user = getOwner(); + const bucket = getMailBucket(user); + const { addUsage, removeBucket } = stubRepositories(user, bucket); + + const snapshot = await bucketEntriesUsecase.removeBucketAndEntries( + user.uuid, + bucket.id, + bucket.name + ); + + expect(addUsage.called).toBeFalsy(); + expect(removeBucket.calledOnceWithExactly(bucket.id, user.uuid)).toBeTruthy(); + expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 }); + }); + + it('When the entries carry no size, then they are deleted and nothing is charged back', async () => { + const user = getOwner(); + const bucket = getMailBucket(user); + const { deleteEntries, addUsage, removeBucket } = stubRepositories(user, bucket, { + metadataOnlyBytes: 0, + }); + + const snapshot = await bucketEntriesUsecase.removeBucketAndEntries( + user.uuid, + bucket.id, + bucket.name + ); + + expect(deleteEntries.calledOnceWithExactly(bucket.id)).toBeTruthy(); + expect(addUsage.called).toBeFalsy(); + expect(removeBucket.called).toBeTruthy(); + expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 }); + }); + + it('When the bucket holds many entries, then one delete and one charge cover all of them', async () => { + const user = getOwner(); + const bucket = getMailBucket(user); + + const { sumBytes, deleteEntries, addUsage, removeBucket } = stubRepositories(user, bucket, { + metadataOnlyBytes: 1000, + newTotal: 3000, + }); + + const snapshot = await bucketEntriesUsecase.removeBucketAndEntries( + user.uuid, + bucket.id, + bucket.name + ); + + expect(sumBytes.calledOnceWithExactly(bucket.id)).toBeTruthy(); + expect(deleteEntries.calledOnceWithExactly(bucket.id)).toBeTruthy(); + expect(addUsage.calledOnceWithExactly(user.uuid, -1000)).toBeTruthy(); + + expect(removeBucket.calledOnceWithExactly(bucket.id, user.uuid)).toBeTruthy(); + expect(deleteEntries.firstCall.calledBefore(removeBucket.firstCall)).toBeTruthy(); + + expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 3000 }); + }); + }); }); diff --git a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts index 171188db..ac2adced 100644 --- a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts +++ b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts @@ -12,6 +12,17 @@ const MB100 = 100 * 1024 * 1024; describe('Gateway V2 e2e tests', () => { const databaseConnection = new StorageDbManager(); + const randomObjectId = () => crypto.randomBytes(12).toString('hex') + + const createBucketForUser = async (userUuid: string, jwt: string) => { + const { body } = await testServer + .post(`/v2/gateway/users/${userUuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: `mail-account-${crypto.randomUUID()}` }) + + return body as { id: string; name: string } + } + beforeEach(async () => { await databaseConnection.connect(); jest.clearAllMocks() @@ -170,17 +181,6 @@ describe('Gateway V2 e2e tests', () => { }) describe('Bucket entries', () => { - const createBucketForUser = async (userUuid: string, jwt: string) => { - const { body } = await testServer - .post(`/v2/gateway/users/${userUuid}/buckets`) - .set('Authorization', `Bearer ${jwt}`) - .send({ name: `mail-account-${crypto.randomUUID()}` }) - - return body as { id: string; name: string } - } - - const randomObjectId = () => crypto.randomBytes(12).toString('hex') - it('When creating an entry, then it is persisted as a shard-less v2 entry and the user total grows by its size', async () => { const testUser = await createTestUser() const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) @@ -368,6 +368,238 @@ describe('Gateway V2 e2e tests', () => { }) }) + describe('Deleting a user bucket', () => { + const VALID_OBJECT_ID = 'a'.repeat(24) + + const deleteBucket = (userUuid: string, bucketId: string, jwt: string, name: string) => + testServer + .delete(`/v2/gateway/users/${userUuid}/buckets/${bucketId}?name=${encodeURIComponent(name)}`) + .set('Authorization', `Bearer ${jwt}`) + + const usedSpaceOf = async (uuid: string): Promise => + (await databaseConnection.models.User.findOne({ uuid })).totalUsedSpaceBytes + + const createEntryForBucket = async (userUuid: string, bucketId: string, jwt: string, size: number) => { + const { body } = await testServer + .post(`/v2/gateway/users/${userUuid}/buckets/${bucketId}/entries`) + .set('Authorization', `Bearer ${jwt}`) + .send({ size }) + + return body as { id: string } + } + + it('When deleting a bucket, then its entries go with it and the user total shrinks by their sum', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + const usedBefore = await usedSpaceOf(testUser.uuid) + + await createEntryForBucket(testUser.uuid, bucket.id, jwt, 5000) + await createEntryForBucket(testUser.uuid, bucket.id, jwt, 3000) + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedBefore + 8000) + + const response = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + expect(response.status).toBe(200) + + const entriesInDatabase = await databaseConnection.models.BucketEntry.find({ bucket: bucket.id }) + expect(entriesInDatabase.length).toBe(0) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).toBeNull() + + const userAfter = await databaseConnection.models.User.findOne({ uuid: testUser.uuid }) + expect(userAfter.totalUsedSpaceBytes).toBe(usedBefore) + expect(response.body).toEqual({ + maxSpaceBytes: userAfter.maxSpaceBytes, + totalUsedSpaceBytes: userAfter.totalUsedSpaceBytes, + }) + }) + + it('When deleting an empty bucket, then it is removed and the user total is unchanged', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + const usedBefore = await usedSpaceOf(testUser.uuid) + + const response = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + expect(response.status).toBe(200) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).toBeNull() + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedBefore) + }) + + it('When deleting the same bucket twice, then the second call reports it gone and the total does not move again', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + await createEntryForBucket(testUser.uuid, bucket.id, jwt, 5000) + + const first = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + const usedAfterFirst = await usedSpaceOf(testUser.uuid) + + const second = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + expect(first.status).toBe(200) + expect(second.status).toBe(404) + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedAfterFirst) + }) + + it('When deleting a bucket of another user, then it returns 403 and the bucket is left alone', async () => { + const owner = await createTestUser() + const otherUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(owner.uuid, jwt) + + await createEntryForBucket(owner.uuid, bucket.id, jwt, 5000) + + const ownerUsedBefore = await usedSpaceOf(owner.uuid) + + const response = await deleteBucket(otherUser.uuid, bucket.id, jwt, bucket.name) + + expect(response.status).toBe(403) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).not.toBeNull() + + const entriesInDatabase = await databaseConnection.models.BucketEntry.find({ bucket: bucket.id }) + expect(entriesInDatabase.length).toBe(1) + + expect(await usedSpaceOf(owner.uuid)).toBe(ownerUsedBefore) + }) + + it('When deleting a bucket of an unknown user, then it returns 404', async () => { + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await deleteBucket(crypto.randomUUID(), VALID_OBJECT_ID, jwt, 'any-name') + + expect(response.status).toBe(404) + }) + + it('When entries are left behind by a bucket that no longer exists, then they are left alone', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + await createEntryForBucket(testUser.uuid, bucket.id, jwt, 5000) + + const usedBefore = await usedSpaceOf(testUser.uuid) + + await databaseConnection.models.Bucket.deleteOne({ _id: bucket.id }) + + const response = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + expect(response.status).toBe(404) + + const entriesInDatabase = await databaseConnection.models.BucketEntry.find({ bucket: bucket.id }) + expect(entriesInDatabase.length).toBe(1) + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedBefore) + }) + + it('When the name does not match the bucket, then it refuses and touches nothing', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + await createEntryForBucket(testUser.uuid, bucket.id, jwt, 5000) + + const usedBefore = await usedSpaceOf(testUser.uuid) + + const response = await deleteBucket(testUser.uuid, bucket.id, jwt, 'Bucket-a1b2c3') + + expect(response.status).toBe(404) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).not.toBeNull() + + const entriesInDatabase = await databaseConnection.models.BucketEntry.find({ bucket: bucket.id }) + expect(entriesInDatabase.length).toBe(1) + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedBefore) + }) + + it.each([ + ['an index', { index: 'a'.repeat(64) }], + ['a frame', { frame: randomObjectId() }], + ['an hmac', { hmac: { type: 'sha512', value: 'a'.repeat(128) } }], + ])( + 'When one entry among several carries %s, then it refuses and every entry survives', + async (_case, shardBackedFields) => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + const metadataOnly = await createEntryForBucket(testUser.uuid, bucket.id, jwt, 5000) + const shardBacked = await createEntryForBucket(testUser.uuid, bucket.id, jwt, 3000) + + await databaseConnection.models.BucketEntry.updateOne( + { _id: shardBacked.id }, + { $set: shardBackedFields } + ) + + const usedBefore = await usedSpaceOf(testUser.uuid) + + const response = await deleteBucket(testUser.uuid, bucket.id, jwt, bucket.name) + + expect(response.status).toBe(409) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).not.toBeNull() + + const entriesInDatabase = await databaseConnection.models.BucketEntry.find({ bucket: bucket.id }) + expect(entriesInDatabase.map((e: { _id: unknown }) => String(e._id)).sort()) + .toEqual([metadataOnly.id, shardBacked.id].sort()) + + expect(await usedSpaceOf(testUser.uuid)).toBe(usedBefore) + } + ) + + it.each([ + ['the user uuid is malformed', 'not-a-uuid', VALID_OBJECT_ID], + ['the bucket id is malformed', crypto.randomUUID(), 'not-an-object-id'], + ])('When %s, then it returns 400', async (_case, userUuid, bucketId) => { + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await deleteBucket(userUuid, bucketId, jwt, 'any-name') + + expect(response.status).toBe(400) + }) + + it('When the name is missing, then it returns 400', async () => { + const testUser = await createTestUser() + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + const bucket = await createBucketForUser(testUser.uuid, jwt) + + const response = await testServer + .delete(`/v2/gateway/users/${testUser.uuid}/buckets/${bucket.id}`) + .set('Authorization', `Bearer ${jwt}`) + + expect(response.status).toBe(400) + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: bucket.id }) + expect(bucketInDatabase).not.toBeNull() + }) + + it('When no auth token is provided, then it returns 401', async () => { + const testUser = await createTestUser() + + const response = await testServer + .delete(`/v2/gateway/users/${testUser.uuid}/buckets/${VALID_OBJECT_ID}?name=any-name`) + + expect(response.status).toBe(401) + }) + }) + describe('Deleting user files', () => { let axiosGetStub: sinon.SinonStub const FAKE_UPLOAD_URL = 'http://fake-upload-url'