fix: shared check for server setup availability

This commit is contained in:
bo0tzz
2026-07-28 15:45:28 +02:00
parent b468459b1a
commit 6f71c86157
12 changed files with 71 additions and 30 deletions
+1 -1
View File
@@ -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.
-3
View File
@@ -38,9 +38,6 @@ export const errorDto = {
incorrectLogin: {
message: 'Incorrect email or password',
},
alreadyHasAdmin: {
message: 'The server already has an admin',
},
};
export const signupResponseDto = {
@@ -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 () => {
+15 -4
View File
@@ -14,6 +14,7 @@ import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -308,15 +309,25 @@ describe(AuthService.name, () => {
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);
mocks.user.hasAdmin.mockResolvedValue(true);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.getAdmin).toHaveBeenCalled();
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should not allow signup when setup is disabled', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } }));
mocks.user.hasAdmin.mockResolvedValue(false);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should sign up the admin', async () => {
mocks.user.getAdmin.mockResolvedValue(void 0);
mocks.user.hasAdmin.mockResolvedValue(false);
mocks.user.create.mockResolvedValue({
...userStub.admin,
...dto,
@@ -334,7 +345,7 @@ describe(AuthService.name, () => {
name: 'immich admin',
});
expect(mocks.user.getAdmin).toHaveBeenCalled();
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(mocks.user.create).toHaveBeenCalled();
});
});
+1 -9
View File
@@ -197,15 +197,7 @@ export class AuthService extends BaseService {
}
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
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');
}
await this.requireSetupAvailable();
const admin = await this.createUser({
isAdmin: true,
+11
View File
@@ -280,6 +280,17 @@ export class BaseService {
return checkAccess(this.accessRepository, request);
}
async isSetupAvailable(): Promise<boolean> {
const { setup } = this.configRepository.getEnv();
return setup.allow && !(await this.userRepository.hasAdmin());
}
async requireSetupAvailable(): Promise<void> {
if (!(await this.isSetupAvailable())) {
throw new BadRequestException('Admin setup is not available');
}
}
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
const exists = await this.userRepository.getByEmail(dto.email);
if (exists) {
@@ -1,5 +1,6 @@
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceService } from 'src/services/maintenance.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MaintenanceService.name, () => {
@@ -134,6 +135,42 @@ describe(MaintenanceService.name, () => {
});
});
describe('startRestoreFlow', () => {
it('should fail if setup is disabled', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } }));
mocks.user.hasAdmin.mockResolvedValue(false);
await expect(sut.startRestoreFlow()).rejects.toThrowError('Admin setup is not available');
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should fail if the server already has an admin', async () => {
mocks.user.hasAdmin.mockResolvedValue(true);
await expect(sut.startRestoreFlow()).rejects.toThrowError('Admin setup is not available');
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should start maintenance mode and return a jwt', async () => {
mocks.user.hasAdmin.mockResolvedValue(false);
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 });
+2 -5
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import {
MaintenanceAuthDto,
@@ -59,10 +59,7 @@ 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');
}
await this.requireSetupAvailable();
return this.startMaintenance(
{
+1 -1
View File
@@ -161,7 +161,7 @@ describe(ServerService.name, () => {
oauthButtonText: 'Login with OAuth',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: undefined,
isInitialized: false,
isOnboarded: false,
externalDomain: '',
publicUsers: true,
+1 -2
View File
@@ -111,9 +111,8 @@ export class ServerService extends BaseService {
}
async getSystemConfig(): Promise<ServerConfigDto> {
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 {
-3
View File
@@ -35,7 +35,4 @@ export const errorDto = {
incorrectLogin: {
message: 'Incorrect email or password',
},
alreadyHasAdmin: {
message: 'The server already has an admin',
},
};
@@ -65,7 +65,7 @@ describe(AuthService.name, () => {
const response = sut.adminSignUp(dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('The server already has an admin');
await expect(response).rejects.toThrow('Admin setup is not available');
});
});