diff --git a/lib/core/users/usecase.ts b/lib/core/users/usecase.ts index e15f0b75..4ffd06a8 100644 --- a/lib/core/users/usecase.ts +++ b/lib/core/users/usecase.ts @@ -262,4 +262,33 @@ export class UsersUsecase { await this.usersRepository.removeById(user.id); } + + async findOrCreateBucket( + uuid: User['uuid'], + name: string + ): Promise<{ id: string; name: string }> { + const user = await this.usersRepository.findByUuid(uuid); + + if (!user) { + throw new UserNotFoundError(uuid); + } + + const existing = await this.bucketsRepository.findOne({ userId: uuid, name }); + + if (existing) { + return { id: existing.id, name: existing.name }; + } + + const created = await this.bucketsRepository.create({ + name, + user: user.id, + userId: user.uuid, + status: 'Active', + transfer: 0, + storage: 0, + encryptionKey: '', + }); + + return { id: created.id, name: created.name }; + } } diff --git a/lib/server/http/gateway/controller.ts b/lib/server/http/gateway/controller.ts index 3ac32bb5..37b0d088 100644 --- a/lib/server/http/gateway/controller.ts +++ b/lib/server/http/gateway/controller.ts @@ -13,6 +13,9 @@ type DeleteFilesInBulkResponse = { } | string }; +type CreateBucketBody = { name: string }; +type CreateBucketResponse = { id: string; name: string }; + export class HTTPGatewayController { constructor( private gatewayUsecase: GatewayUsecase, @@ -130,4 +133,35 @@ export class HTTPGatewayController { return res.status(500).send({ message: 'Internal server error' }); } } + + async createUserBucket( + req: Request<{ uuid: string }, {}, Partial, {}>, + res: Response + ) { + const { uuid } = req.params; + const { name } = req.body; + + if (!uuid || typeof name !== 'string' || name.length === 0) { + return res.status(400).send({ message: 'name is required' }); + } + + try { + const bucket = await this.usersUsecase.findOrCreateBucket(uuid, name); + + return res.status(200).send(bucket); + } catch (err) { + if (err instanceof UserNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + this.logger.error( + '[GATEWAY/CREATE_BUCKET] Error creating bucket for user %s: %s. %s', + uuid, + (err as Error).message, + (err as Error).stack || 'NO STACK' + ); + + return res.status(500).send({ message: 'Internal server error' }); + } + } } diff --git a/lib/server/http/gateway/index.ts b/lib/server/http/gateway/index.ts index a2483cd6..ad9155e3 100644 --- a/lib/server/http/gateway/index.ts +++ b/lib/server/http/gateway/index.ts @@ -10,6 +10,7 @@ export const createGatewayHTTPRouter = ( router.post('/users', jwtMiddleware, controller.findOrCreateUser.bind(controller)); 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('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller)); return router; diff --git a/tests/lib/core/users/usecase.test.ts b/tests/lib/core/users/usecase.test.ts index 64e6f9e7..e49d28fb 100644 --- a/tests/lib/core/users/usecase.test.ts +++ b/tests/lib/core/users/usecase.test.ts @@ -416,6 +416,63 @@ describe('Users usecases', () => { }); }); + describe('Finding or creating a bucket', () => { + it('When the user does not exist, then it throws UserNotFoundError', async () => { + const findUser = stub(usersRepository, 'findByUuid').resolves(null); + + try { + await usecase.findOrCreateBucket('unknown-uuid', 'bucket-name'); + expect(true).toBeFalsy(); + } catch (err) { + expect(err).toBeInstanceOf(UserNotFoundError); + } + + expect(findUser.calledOnce).toBeTruthy(); + }); + + it('When no bucket with that name and for that user exists, then it creates one', async () => { + const user = fixtures.getUser(); + const name = 'mail-account-id'; + const createdBucket = { id: 'new-bucket-id', name } as any; + + stub(usersRepository, 'findByUuid').resolves(user); + const findBucket = stub(bucketsRepository, 'findOne').resolves(null); + const createBucket = stub(bucketsRepository, 'create').resolves(createdBucket); + + const result = await usecase.findOrCreateBucket(user.uuid, name); + + expect(findBucket.calledOnce).toBeTruthy(); + expect(findBucket.firstCall.args).toStrictEqual([{ userId: user.uuid, name }]); + expect(createBucket.calledOnce).toBeTruthy(); + expect(createBucket.firstCall.args).toStrictEqual([{ + name, + user: user.id, + userId: user.uuid, + status: 'Active', + transfer: 0, + storage: 0, + encryptionKey: '', + }]); + expect(result).toStrictEqual({ id: createdBucket.id, name: createdBucket.name }); + }); + + it('When a bucket with that name and for that user already exists, then it returns the existing one without creating', async () => { + const user = fixtures.getUser(); + const name = 'mail-account-id'; + const existingBucket = { id: 'existing-bucket-id', name } as any; + + stub(usersRepository, 'findByUuid').resolves(user); + const findBucket = stub(bucketsRepository, 'findOne').resolves(existingBucket); + const createBucket = stub(bucketsRepository, 'create'); + + const result = await usecase.findOrCreateBucket(user.uuid, name); + + expect(findBucket.calledOnce).toBeTruthy(); + expect(createBucket.called).toBeFalsy(); + expect(result).toStrictEqual({ id: existingBucket.id, name: existingBucket.name }); + }); + }); + describe('Confirming user destruction', () => { it('When confirming a destruction of a user that exists, then it works', async () => { const user = fixtures.getUser(); diff --git a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts index e7bf1978..6b0c4aa4 100644 --- a/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts +++ b/tests/lib/e2e/gateway/gateway-v2.e2e-spec.ts @@ -44,6 +44,77 @@ describe('Gateway V2 e2e tests', () => { }) }) + describe('Creating a user bucket', () => { + it('When creating a bucket for a user, then it is persisted with the user uuid and given name', async () => { + const testUser = await createTestUser() + const bucketName = `mail-account-${crypto.randomUUID()}` + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + expect(response.status).toBe(200) + expect(response.body.name).toBe(bucketName) + expect(response.body.id).toBeDefined() + + const bucketInDatabase = await databaseConnection.models.Bucket.findOne({ _id: response.body.id }) + expect(bucketInDatabase).not.toBeNull() + expect(bucketInDatabase.userId).toBe(testUser.uuid) + expect(bucketInDatabase.name).toBe(bucketName) + }) + + it('When creating a bucket with a name that already exists, then it returns the existing bucket', async () => { + const testUser = await createTestUser() + const bucketName = `mail-account-${crypto.randomUUID()}` + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const firstResponse = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + const secondResponse = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: bucketName }) + + expect(firstResponse.status).toBe(200) + expect(secondResponse.status).toBe(200) + expect(secondResponse.body.id).toBe(firstResponse.body.id) + + const buckets = await databaseConnection.models.Bucket.find({ userId: testUser.uuid, name: bucketName }) + expect(buckets.length).toBe(1) + }) + + it('When creating a bucket without a name, then it returns 400', async () => { + const testUser = await createTestUser() + + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${testUser.uuid}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({}) + + expect(response.status).toBe(400) + }) + + it('When creating a bucket for an unknown user, then it returns 404', async () => { + const jwt = signRS256JWT('5m', engine._config.gateway.SIGN_JWT_SECRET) + + const response = await testServer + .post(`/v2/gateway/users/${crypto.randomUUID()}/buckets`) + .set('Authorization', `Bearer ${jwt}`) + .send({ name: 'mail-account' }) + + expect(response.status).toBe(404) + }) + }) + describe('Deleting user files', () => { let axiosGetStub: sinon.SinonStub const FAKE_UPLOAD_URL = 'http://fake-upload-url'