From 70e59c00d589c9137f30873edaa7136ff600bc56 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 26 Aug 2025 14:46:29 -0400 Subject: [PATCH] fix: invalid storage quota with decimals (#21271) --- .../controllers/user-admin.controller.spec.ts | 79 +++++++++++++++++++ server/src/dtos/user.dto.ts | 6 +- web/src/lib/modals/UserCreateModal.svelte | 2 +- web/src/lib/modals/UserEditModal.svelte | 1 + 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 server/src/controllers/user-admin.controller.spec.ts diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts new file mode 100644 index 0000000000..bd9c966d42 --- /dev/null +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -0,0 +1,79 @@ +import { UserAdminController } from 'src/controllers/user-admin.controller'; +import { UserAdminCreateDto } from 'src/dtos/user.dto'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserAdminService } from 'src/services/user-admin.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(UserAdminController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(UserAdminService); + + beforeAll(async () => { + ctx = await controllerSetup(UserAdminController, [ + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + { provide: UserAdminService, useValue: service }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /admin/users', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/admin/users'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /admin/users', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/admin/users'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should not allow decimal quota`, async () => { + const dto: UserAdminCreateDto = { + email: 'user@immich.app', + password: 'test', + name: 'Test User', + quotaSizeInBytes: 1.2, + }; + + const { status, body } = await request(ctx.getHttpServer()) + .post(`/admin/users`) + .set('Authorization', `Bearer token`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); + }); + }); + + describe('GET /admin/users/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /admin/users/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should not allow decimal quota`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/admin/users/${factory.uuid()}`) + .set('Authorization', `Bearer token`) + .send({ quotaSizeInBytes: 1.2 }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); + }); + }); +}); diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 0da86bfcb5..443178aa10 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; @@ -91,7 +91,7 @@ export class UserAdminCreateDto { storageLabel?: string | null; @Optional({ nullable: true }) - @IsNumber() + @IsInt() @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; @@ -137,7 +137,7 @@ export class UserAdminUpdateDto { shouldChangePassword?: boolean; @Optional({ nullable: true }) - @IsNumber() + @IsInt() @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 3d2085f7ca..a47ae7e94b 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -122,7 +122,7 @@ - + {#if quotaSizeWarning} {$t('errors.quota_higher_than_disk_size')} {/if} diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 20c473f0d3..8238c6c5d8 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -83,6 +83,7 @@ name="quotaSize" placeholder={$t('unlimited')} type="number" + step="1" min="0" bind:value={quotaSize} />