immich/server/src/services/system-config.service.spec.ts
Clement Martin 95889a69c9
feat(server): Option to configure SMTPS transport (#22833)
* feat(server): Option to configure SMTPS transport

This commit adds a boolean option in the SMTP transport configuration to
enable the so-called "secure" mode. What it does is use SMTP over TLS
instead of relying on the more common STARTTLS option over plain SMTP.

* Add missing field in dto

* Add missing field

* Use a switch instead of text field

* Add field in spec

* chore: regen open-api

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-10-17 10:21:27 +00:00

439 lines
14 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
import {
AudioCodec,
Colorspace,
CQMode,
ImageFormat,
LogLevel,
OAuthTokenEndpointAuthMethod,
QueueName,
ToneMapping,
TranscodeHardwareAcceleration,
TranscodePolicy,
VideoCodec,
VideoContainer,
} from 'src/enum';
import { SystemConfigService } from 'src/services/system-config.service';
import { DeepPartial } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils';
const partialConfig = {
ffmpeg: { crf: 30 },
oauth: { autoLaunch: true },
trash: { days: 10 },
user: { deleteDelay: 15 },
} satisfies DeepPartial<SystemConfig>;
const updatedConfig = Object.freeze<SystemConfig>({
job: {
[QueueName.BackgroundTask]: { concurrency: 5 },
[QueueName.SmartSearch]: { concurrency: 2 },
[QueueName.MetadataExtraction]: { concurrency: 5 },
[QueueName.FaceDetection]: { concurrency: 2 },
[QueueName.Search]: { concurrency: 5 },
[QueueName.Sidecar]: { concurrency: 5 },
[QueueName.Library]: { concurrency: 5 },
[QueueName.Migration]: { concurrency: 5 },
[QueueName.ThumbnailGeneration]: { concurrency: 3 },
[QueueName.VideoConversion]: { concurrency: 1 },
[QueueName.Notification]: { concurrency: 5 },
},
backup: {
database: {
enabled: true,
cronExpression: '0 02 * * *',
keepLastAmount: 14,
},
},
ffmpeg: {
crf: 30,
threads: 0,
preset: 'ultrafast',
targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
targetResolution: '720',
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
maxBitrate: '0',
bframes: -1,
refs: 0,
gopSize: 0,
temporalAQ: false,
cqMode: CQMode.Auto,
twoPass: false,
preferredHwDevice: 'auto',
transcode: TranscodePolicy.Required,
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: false,
tonemap: ToneMapping.Hable,
},
logging: {
enabled: true,
level: LogLevel.Log,
},
metadata: {
faces: {
import: false,
},
},
machineLearning: {
enabled: true,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
interval: 30_000,
timeout: 2000,
},
clip: {
enabled: true,
modelName: 'ViT-B-32__openai',
},
duplicateDetection: {
enabled: true,
maxDistance: 0.01,
},
facialRecognition: {
enabled: true,
modelName: 'buffalo_l',
minScore: 0.7,
maxDistance: 0.5,
minFaces: 3,
},
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
nightlyTasks: {
startTime: '00:00',
databaseCleanup: true,
clusterNewFaces: true,
missingThumbnails: true,
generateMemories: true,
syncQuotaUsage: true,
},
reverseGeocoding: {
enabled: true,
},
oauth: {
autoLaunch: true,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
timeout: 30_000,
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
},
passwordLogin: {
enabled: true,
},
server: {
externalDomain: '',
loginPageMessage: '',
publicUsers: true,
},
storageTemplate: {
enabled: false,
hashVerificationEnabled: true,
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnail: {
size: 250,
format: ImageFormat.Webp,
quality: 80,
},
preview: {
size: 1440,
format: ImageFormat.Jpeg,
quality: 80,
},
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 },
colorspace: Colorspace.P3,
extractEmbedded: false,
},
newVersionCheck: {
enabled: true,
},
trash: {
enabled: true,
days: 10,
},
theme: {
customCss: '',
},
library: {
scan: {
enabled: true,
cronExpression: '0 0 * * *',
},
watch: {
enabled: false,
},
},
user: {
deleteDelay: 15,
},
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
ignoreCert: false,
},
},
},
templates: {
email: {
albumInviteTemplate: '',
welcomeTemplate: '',
albumUpdateTemplate: '',
},
},
});
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(SystemConfigService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getDefaults', () => {
it('should return the default config', () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
expect(sut.getDefaults()).toEqual(defaults);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
});
});
describe('getConfig', () => {
it('should return the default config', async () => {
mocks.systemMetadata.get.mockResolvedValue({});
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
});
it('should merge the overrides', async () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { crf: 30 },
oauth: { autoLaunch: true },
trash: { days: 10 },
user: { deleteDelay: 15 },
});
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
});
it('should load the config from a json file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
});
it('should transform booleans', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ twoPass: false }),
});
});
it('should transform numbers', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ threads: 42 }),
});
});
it('should accept valid cron expressions', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(
JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }),
);
await expect(sut.getSystemConfig()).resolves.toMatchObject({
library: {
scan: {
enabled: true,
cronExpression: '0 0 * * *',
},
},
});
});
it('should reject invalid cron expressions', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
await expect(sut.getSystemConfig()).rejects.toThrow(
'library.scan.cronExpression has failed the following constraints: cronValidator',
);
});
it('should log errors with the config file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
expect(mocks.logger.error).toHaveBeenCalledTimes(2);
expect(mocks.logger.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json');
expect(mocks.logger.error.mock.calls[1][0].toString()).toEqual(
expect.stringContaining('YAMLException: duplicated mapping key (1:20)'),
);
});
it('should load the config from a yaml file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
ffmpeg:
crf: 30
oauth:
autoLaunch: true
trash:
days: 10
user:
deleteDelay: 15
`;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml');
});
it('should accept an empty configuration file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
});
it('should allow underscores in the machine learning url', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig();
expect(config.machineLearning.urls).toEqual(['immich_machine_learning']);
});
const externalDomainTests = [
{ should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' },
{ should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' },
{ should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' },
{
should: 'with basic auth',
externalDomain: 'https://user:password@example.com:123',
result: 'https://user:password@example.com:123',
},
];
for (const { should, externalDomain, result } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { server: { externalDomain } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig();
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
});
}
it('should warn for unknown options in yaml', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
unknownOption: true
`;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
});
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
{ should: 'warn for top level unknown options', warn: true, config: { unknownOption: true } },
{ should: 'warn for nested unknown options', warn: true, config: { ffmpeg: { unknownOption: true } } },
];
for (const test of tests) {
it(`should ${test.should}`, async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) {
await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
} else {
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
}
});
}
});
describe('updateConfig', () => {
it('should update the config and emit an event', async () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(mocks.event.emit).toHaveBeenCalledWith(
'ConfigUpdate',
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
);
});
it('should throw an error if a config file is in use', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
});
});
describe('getCustomCss', () => {
it('should return the default theme', async () => {
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
});
});
});