diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index dbfd2fb112..c10a858ed9 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -45,7 +45,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | -| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api | +| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` and `/admin/database-backups/start-restore` endpoints | `true` | server | api | \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 5fd887c44b..1b3447767e 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -38,9 +38,6 @@ export const errorDto = { incorrectLogin: { message: 'Incorrect email or password', }, - alreadyHasAdmin: { - message: 'The server already has an admin', - }, }; export const signupResponseDto = { diff --git a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts index cf6d752561..e757c721d6 100644 --- a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts @@ -108,7 +108,7 @@ describe('/admin/database-backups', () => { const { status, body } = await request(app).post('/admin/database-backups/start-restore').send(); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('The server already has an admin')); + expect(body).toEqual(errorDto.badRequest('Admin setup is not available')); }); it.sequential('should enter maintenance mode in "database restore mode"', async () => { diff --git a/server/src/controllers/app.controller.ts b/server/src/controllers/app.controller.ts index 3fe9b49368..eca7e5bcb5 100644 --- a/server/src/controllers/app.controller.ts +++ b/server/src/controllers/app.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Header } from '@nestjs/common'; import { ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Authenticated } from 'src/middleware/auth.guard'; import { SystemConfigService } from 'src/services/system-config.service'; @Controller() @@ -8,6 +9,7 @@ export class AppController { @ApiExcludeEndpoint() @Get('.well-known/immich') + @Authenticated({ public: true }) getImmichWellKnown() { return { api: { @@ -18,6 +20,7 @@ export class AppController { @ApiExcludeEndpoint() @Get('custom.css') + @Authenticated({ public: true }) @Header('Content-Type', 'text/css') getCustomCss() { return this.service.getCustomCss(); diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index d105dd90b9..3bf1d59f8b 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { AuthController } from 'src/controllers/auth.controller'; import { LoginResponseDto } from 'src/dtos/auth.dto'; import { AuthService } from 'src/services/auth.service'; @@ -76,6 +77,18 @@ describe(AuthController.name, () => { .send({ name: 'admin', password: 'password', email: 'admin@local' }); expect(status).toEqual(201); }); + + it('should not sign up an admin when setup is unavailable', async () => { + ctx.requireSetupAvailable.mockRejectedValue(new BadRequestException('Admin setup is not available')); + + const { status, body } = await request(ctx.getHttpServer()) + .post('/auth/admin-sign-up') + .send({ name, email, password }); + + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest('Admin setup is not available')); + expect(service.adminSignUp).not.toHaveBeenCalled(); + }); }); describe('POST /auth/login', () => { diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 63cdce4f32..b96ba4dee3 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -33,6 +33,7 @@ export class AuthController { description: 'Login with username and password and receive a session token.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) + @Authenticated({ public: true }) async login( @Res({ passthrough: true }) res: Response, @Body() loginCredential: LoginCredentialDto, @@ -55,6 +56,7 @@ export class AuthController { description: 'Create the first admin user in the system.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) + @Authenticated({ public: true, setup: true }) signUpAdmin(@Body() dto: SignUpDto): Promise { return this.service.adminSignUp(dto); } diff --git a/server/src/controllers/database-backup.controller.spec.ts b/server/src/controllers/database-backup.controller.spec.ts new file mode 100644 index 0000000000..43fa779e8d --- /dev/null +++ b/server/src/controllers/database-backup.controller.spec.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; +import { DatabaseBackupController } from 'src/controllers/database-backup.controller'; +import { DatabaseBackupService } from 'src/services/database-backup.service'; +import { MaintenanceService } from 'src/services/maintenance.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(DatabaseBackupController.name, () => { + let ctx: ControllerContext; + const service = automock(DatabaseBackupService, { args: [{ setContext: () => {} }], strict: false }); + const maintenanceService = mockBaseService(MaintenanceService); + + beforeAll(async () => { + ctx = await controllerSetup(DatabaseBackupController, [ + { provide: DatabaseBackupService, useValue: service }, + { provide: MaintenanceService, useValue: maintenanceService }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + maintenanceService.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /admin/database-backups', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/admin/database-backups').send(); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /admin/database-backups/start-restore', () => { + it('should not be an authenticated route', async () => { + maintenanceService.startRestoreFlow.mockResolvedValue({ jwt: 'jwt' }); + + await request(ctx.getHttpServer()).post('/admin/database-backups/start-restore').send(); + + expect(ctx.authenticate).not.toHaveBeenCalled(); + expect(ctx.requireSetupAvailable).toHaveBeenCalled(); + }); + + it('should not start a restore when setup is unavailable', async () => { + ctx.requireSetupAvailable.mockRejectedValue(new BadRequestException('Admin setup is not available')); + + const { status, body } = await request(ctx.getHttpServer()).post('/admin/database-backups/start-restore').send(); + + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest('Admin setup is not available')); + expect(maintenanceService.startRestoreFlow).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/database-backup.controller.ts b/server/src/controllers/database-backup.controller.ts index 737c8f3958..4cb2092d1b 100644 --- a/server/src/controllers/database-backup.controller.ts +++ b/server/src/controllers/database-backup.controller.ts @@ -71,6 +71,7 @@ export class DatabaseBackupController { description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)', history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'), }) + @Authenticated({ public: true, setup: true }) async startDatabaseRestoreFlow( @GetLoginDetails() loginDetails: LoginDetails, @Res({ passthrough: true }) res: Response, diff --git a/server/src/controllers/index.spec.ts b/server/src/controllers/index.spec.ts index 67962e8b3c..3d39a4dd3d 100644 --- a/server/src/controllers/index.spec.ts +++ b/server/src/controllers/index.spec.ts @@ -53,12 +53,10 @@ describe('controllers', () => { expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES); }); - it('should not authenticate the bootstrap routes under admin/', () => { - const authenticated = routes - .filter((route) => UNAUTHENTICATED_ADMIN_ROUTES.has(route.id) && route.auth !== undefined) - .map((route) => route.label); + it('should declare authentication on every route', () => { + const undeclared = routes.filter((route) => route.auth === undefined).map((route) => route.label); - expect(authenticated).toEqual([]); + expect(undeclared).toEqual([]); }); it('should require admin access for routes with an admin permission', () => { diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index d5f13e341c..3d8c6c1194 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -27,6 +27,7 @@ export class MaintenanceController { description: 'Fetch information about the currently running maintenance action.', history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'), }) + @Authenticated({ public: true }) getMaintenanceStatus(): MaintenanceStatusResponseDto { return this.service.getMaintenanceStatus(); } @@ -48,6 +49,7 @@ export class MaintenanceController { description: 'Login with maintenance token or cookie to receive current information and perform further actions.', history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), }) + @Authenticated({ public: true }) maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto { throw new BadRequestException('Not in maintenance mode'); } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 7f2313a058..54f5c1f10b 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -22,6 +22,7 @@ export class OAuthController { constructor(private service: AuthService) {} @Get('mobile-redirect') + @Authenticated({ public: true }) @Redirect() @Endpoint({ summary: 'Redirect OAuth to mobile', @@ -37,6 +38,7 @@ export class OAuthController { } @Post('authorize') + @Authenticated({ public: true }) @Endpoint({ summary: 'Start OAuth', description: 'Initiate the OAuth authorization process.', @@ -62,6 +64,7 @@ export class OAuthController { } @Post('callback') + @Authenticated({ public: true }) @Endpoint({ summary: 'Finish OAuth', description: 'Complete the OAuth authorization process by exchanging the authorization code for a session token.', @@ -115,6 +118,7 @@ export class OAuthController { } @Post('backchannel-logout') + @Authenticated({ public: true }) @HttpCode(HttpStatus.OK) @ApiConsumes('application/x-www-form-urlencoded') @Endpoint({ diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index f5ce4b851c..6407155492 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -64,6 +64,7 @@ export class ServerController { } @Get('ping') + @Authenticated({ public: true }) @Endpoint({ summary: 'Ping', description: 'Pong', @@ -74,6 +75,7 @@ export class ServerController { } @Get('version') + @Authenticated({ public: true }) @Endpoint({ summary: 'Get server version', description: 'Retrieve the current server version in semantic versioning (semver) format.', @@ -84,6 +86,7 @@ export class ServerController { } @Get('version-history') + @Authenticated({ public: true }) @Endpoint({ summary: 'Get version history', description: 'Retrieve a list of past versions the server has been on.', @@ -94,6 +97,7 @@ export class ServerController { } @Get('features') + @Authenticated({ public: true }) @Endpoint({ summary: 'Get features', description: 'Retrieve available features supported by this server.', @@ -104,6 +108,7 @@ export class ServerController { } @Get('config') + @Authenticated({ public: true }) @Endpoint({ summary: 'Get config', description: 'Retrieve the current server configuration.', @@ -125,6 +130,7 @@ export class ServerController { } @Get('media-types') + @Authenticated({ public: true }) @Endpoint({ summary: 'Get supported media types', description: 'Retrieve all media types supported by the server.', diff --git a/server/src/middleware/auth.guard.spec.ts b/server/src/middleware/auth.guard.spec.ts new file mode 100644 index 0000000000..5b094c03c1 --- /dev/null +++ b/server/src/middleware/auth.guard.spec.ts @@ -0,0 +1,82 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Authenticated, AuthGuard } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AuthService } from 'src/services/auth.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService, ServiceMocks } from 'test/utils'; + +class TestController { + @Authenticated({ public: true, setup: true }) + setupRoute() {} + + @Authenticated({ public: true }) + publicRoute() {} + + undecoratedRoute() {} +} + +const contextFor = (handler: () => void) => + ({ + getHandler: () => handler, + switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }), + }) as unknown as ExecutionContext; + +describe(AuthGuard.name, () => { + let sut: AuthGuard; + let authService: AuthService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut: authService, mocks } = newTestService(AuthService)); + sut = new AuthGuard(mocks.logger as unknown as LoggingRepository, new Reflector(), authService); + }); + + describe('setup routes', () => { + it('should allow access while the server is awaiting its first admin', async () => { + mocks.user.hasAdmin.mockResolvedValue(false); + const authenticate = vitest.spyOn(authService, 'authenticate'); + + await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).resolves.toBe(true); + + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('should reject when setup is disabled', async () => { + mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } })); + mocks.user.hasAdmin.mockResolvedValue(false); + + await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).rejects.toThrowError( + 'Admin setup is not available', + ); + }); + + it('should reject when the server already has an admin', async () => { + mocks.user.hasAdmin.mockResolvedValue(true); + + await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).rejects.toThrowError( + 'Admin setup is not available', + ); + }); + }); + + describe('public routes', () => { + it('should not require setup availability', async () => { + mocks.user.hasAdmin.mockResolvedValue(true); + + await expect(sut.canActivate(contextFor(TestController.prototype.publicRoute))).resolves.toBe(true); + }); + }); + + describe('undecorated routes', () => { + it('should be rejected', async () => { + const authenticate = vitest.spyOn(authService, 'authenticate'); + + await expect(sut.canActivate(contextFor(TestController.prototype.undecoratedRoute))).rejects.toThrowError( + 'does not declare @Authenticated()', + ); + + expect(authenticate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index d9870ec7b9..93bcfe26e7 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -17,23 +17,26 @@ import { getUserAgentDetails } from 'src/utils/request'; type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; -export type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute); +type AuthorizedRoute = { permission?: Permission | false; public?: never; setup?: never } & ( + AdminRoute | SharedLinkRoute +); +type PublicRoute = { public: true; setup?: true; permission?: never; admin?: never; sharedLink?: never }; +export type AuthenticatedOptions = AuthorizedRoute | PublicRoute; type ReflectorTarget = Parameters[1]; /** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */ export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => { const options = reflector.getAllAndOverride(MetadataKey.AuthRoute, [target]); - return options && { sharedLink: false, admin: false, ...options }; + return options && { sharedLink: false, admin: false, public: false, setup: false, ...options }; }; export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { - const decorators: MethodDecorator[] = [ - ApiBearerAuth(), - ApiCookieAuth(), - ApiSecurity(MetadataKey.ApiKeySecurity), - SetMetadata(MetadataKey.AuthRoute, options), - ]; + const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)]; + + if (!options.public) { + decorators.push(ApiBearerAuth(), ApiCookieAuth(), ApiSecurity(MetadataKey.ApiKeySecurity)); + } if ((options as AdminRoute).admin) { decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true)); @@ -96,6 +99,14 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const options = getAuthenticatedOptions(this.reflector, context.getHandler()); if (!options) { + throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`); + } + + if (options.setup) { + await this.authService.requireSetupAvailable(); + } + + if (options.public) { return true; } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index dad13ef5f5..48dcf5a509 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -307,16 +307,7 @@ describe(AuthService.name, () => { describe('adminSignUp', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; - it('should only allow one admin', async () => { - mocks.user.getAdmin.mockResolvedValue({} as UserAdmin); - - await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.user.getAdmin).toHaveBeenCalled(); - }); - it('should sign up the admin', async () => { - mocks.user.getAdmin.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue({ ...userStub.admin, ...dto, @@ -334,7 +325,6 @@ describe(AuthService.name, () => { name: 'immich admin', }); - expect(mocks.user.getAdmin).toHaveBeenCalled(); expect(mocks.user.create).toHaveBeenCalled(); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f3be40f7dd..59e20276af 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -197,16 +197,6 @@ export class AuthService extends BaseService { } async adminSignUp(dto: SignUpDto): Promise { - const { setup } = this.configRepository.getEnv(); - if (!setup.allow) { - throw new BadRequestException('Admin setup is disabled'); - } - - const adminUser = await this.userRepository.getAdmin(); - if (adminUser) { - throw new BadRequestException('The server already has an admin'); - } - const admin = await this.createUser({ isAdmin: true, email: dto.email, diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6d17410441..2195c66a22 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -280,6 +280,17 @@ export class BaseService { return checkAccess(this.accessRepository, request); } + async isSetupAvailable(): Promise { + const { setup } = this.configRepository.getEnv(); + return setup.allow && !(await this.userRepository.hasAdmin()); + } + + async requireSetupAvailable(): Promise { + if (!(await this.isSetupAvailable())) { + throw new BadRequestException('Admin setup is not available'); + } + } + async createUser(dto: Insertable & { email: string }): Promise { const exists = await this.userRepository.getByEmail(dto.email); if (exists) { diff --git a/server/src/services/maintenance.service.spec.ts b/server/src/services/maintenance.service.spec.ts index e598f1c71d..1eaefb689d 100644 --- a/server/src/services/maintenance.service.spec.ts +++ b/server/src/services/maintenance.service.spec.ts @@ -134,6 +134,22 @@ describe(MaintenanceService.name, () => { }); }); + describe('startRestoreFlow', () => { + it('should start maintenance mode and return a jwt', async () => { + mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); + + await expect(sut.startRestoreFlow()).resolves.toMatchObject({ jwt: expect.any(String) }); + + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { + isMaintenanceMode: true, + secret: expect.stringMatching(/^\w{128}$/), + action: { + action: MaintenanceAction.SelectDatabaseRestore, + }, + }); + }); + }); + describe('createLoginUrl', () => { it('should fail outside of maintenance mode without secret', async () => { mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index ca1e05d93f..bcd6f2e834 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { OnEvent } from 'src/decorators'; import { MaintenanceAuthDto, @@ -59,11 +59,6 @@ export class MaintenanceService extends BaseService { } async startRestoreFlow(): Promise<{ jwt: string }> { - const adminUser = await this.userRepository.getAdmin(); - if (adminUser) { - throw new BadRequestException('The server already has an admin'); - } - return this.startMaintenance( { action: MaintenanceAction.SelectDatabaseRestore, diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index e1575a496a..b4b1af35d6 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,5 +1,6 @@ import { SystemMetadataKey } from 'src/enum'; import { ServerService } from 'src/services/server.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ServerService.name, () => { @@ -161,7 +162,7 @@ describe(ServerService.name, () => { oauthButtonText: 'Login with OAuth', trashDays: 30, userDeleteDelay: 7, - isInitialized: undefined, + isInitialized: false, isOnboarded: false, externalDomain: '', publicUsers: true, @@ -172,6 +173,19 @@ describe(ServerService.name, () => { }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); + + it('should be initialized once an admin exists', async () => { + mocks.user.hasAdmin.mockResolvedValue(true); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ isInitialized: true }); + }); + + it('should be initialized when setup is disabled', async () => { + mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } })); + mocks.user.hasAdmin.mockResolvedValue(false); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ isInitialized: true }); + }); }); describe('getStats', () => { diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index ad212292ba..57342f9509 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -111,9 +111,8 @@ export class ServerService extends BaseService { } async getSystemConfig(): Promise { - const { setup } = this.configRepository.getEnv(); const config = await this.getConfig({ withCache: false }); - const isInitialized = !setup.allow || (await this.userRepository.hasAdmin()); + const isInitialized = !(await this.isSetupAvailable()); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); return { diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts index b416b3b904..9b75bc85ec 100644 --- a/server/test/medium/responses.ts +++ b/server/test/medium/responses.ts @@ -35,7 +35,4 @@ export const errorDto = { incorrectLogin: { message: 'Incorrect email or password', }, - alreadyHasAdmin: { - message: 'The server already has an admin', - }, }; diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index 1fc306f790..5e5c880955 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -57,16 +57,6 @@ describe(AuthService.name, () => { }), ); }); - - it('should not allow a second admin to sign up', async () => { - const { sut, ctx } = setup(); - await ctx.newUser({ isAdmin: true }); - const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' }; - - const response = sut.adminSignUp(dto); - await expect(response).rejects.toThrow(BadRequestException); - await expect(response).rejects.toThrow('The server already has an admin'); - }); }); describe('login', () => { diff --git a/server/test/utils.ts b/server/test/utils.ts index b633cbc4de..2f32f3b412 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -89,6 +89,7 @@ import { assert, Mock, Mocked, vitest } from 'vitest'; export type ControllerContext = { authenticate: Mock; + requireSetupAvailable: Mock; getHttpServer: () => any; reset: () => void; close: () => Promise; @@ -124,7 +125,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow { provide: APP_GUARD, useClass: AuthGuard }, { provide: LoggingRepository, useValue: LoggingRepository.create() }, { provide: ClsService, useValue: { getId: vi.fn() } }, - { provide: AuthService, useValue: { authenticate: vi.fn() } }, + { provide: AuthService, useValue: { authenticate: vi.fn(), requireSetupAvailable: vi.fn() } }, ...providers, ], }) @@ -137,13 +138,17 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow await app.init(); // allow the AuthController to override the AuthService itself - const authenticate = app.get>(AuthService).authenticate as Mock; + const resolvedAuthService = app.get>(AuthService); + const authenticate = resolvedAuthService.authenticate as Mock; + const requireSetupAvailable = resolvedAuthService.requireSetupAvailable as Mock; return { authenticate, + requireSetupAvailable, getHttpServer: () => app.getHttpServer(), reset: () => { authenticate.mockReset(); + requireSetupAvailable.mockReset(); }, close: async () => { await app.close(); @@ -184,10 +189,12 @@ export const automock = ( const mocks: Mock[] = []; const instance = new Dependency(...args); - const propertyNames = new Set([ - ...Object.getOwnPropertyNames(Dependency.prototype), - ...Object.getOwnPropertyNames(instance), - ]); + const propertyNames = new Set(Object.getOwnPropertyNames(instance)); + for (let proto = Dependency.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) { + for (const property of Object.getOwnPropertyNames(proto)) { + propertyNames.add(property); + } + } for (const property of propertyNames) { if (property === 'constructor') { continue;