test: update service specs

This commit is contained in:
izzy
2025-11-24 16:18:33 +00:00
parent 1ad2282166
commit 9f5f90b2ff
7 changed files with 265 additions and 38 deletions

View File

@@ -29,7 +29,6 @@ import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middlewa
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { LoginDetails } from 'src/services/auth.service'; import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service'; import { MaintenanceService } from 'src/services/maintenance.service';
import { integrityCheck } from 'src/utils/maintenance';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
import { FilenameParamDto } from 'src/validation'; import { FilenameParamDto } from 'src/validation';
@@ -60,7 +59,7 @@ export class MaintenanceController {
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
}) })
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository); return this.service.integrityCheck();
} }
@Post('login') @Post('login')

View File

@@ -16,7 +16,6 @@ import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.ser
import { GetLoginDetails } from 'src/middleware/auth.guard'; import { GetLoginDetails } from 'src/middleware/auth.guard';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { LoginDetails } from 'src/services/auth.service'; import { LoginDetails } from 'src/services/auth.service';
import { integrityCheck } from 'src/utils/maintenance';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
import { FilenameParamDto } from 'src/validation'; import { FilenameParamDto } from 'src/validation';
@@ -39,7 +38,7 @@ export class MaintenanceWorkerController {
@Get('admin/maintenance/integrity') @Get('admin/maintenance/integrity')
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> { integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository); return this.service.integrityCheck();
} }
@Post('admin/maintenance/login') @Post('admin/maintenance/login')

View File

@@ -1,4 +1,4 @@
import { UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { PassThrough, Readable } from 'node:stream'; import { PassThrough, Readable } from 'node:stream';
@@ -59,6 +59,32 @@ describe(MaintenanceWorkerService.name, () => {
}); });
}); });
describe.skip('ssr');
describe.skip('detectMediaLocation');
describe('setStatus', () => {
it('should broadcast status', async () => {
maintenanceEphemeralStateRepositoryMock.getPublicStatus.mockReturnValue({
action: MaintenanceAction.Start,
error: 'mock',
});
sut.setStatus({
action: MaintenanceAction.Start,
task: 'abc',
error: 'def',
});
expect(maintenanceEphemeralStateRepositoryMock.setStatus).toHaveBeenCalled();
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled();
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2);
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
action: 'start',
error: 'mock',
});
});
});
describe('logSecret', () => { describe('logSecret', () => {
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/; const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
@@ -107,6 +133,95 @@ describe(MaintenanceWorkerService.name, () => {
}); });
}); });
describe('status', () => {
beforeEach(() => {
maintenanceEphemeralStateRepositoryMock.getStatus.mockResolvedValue({
action: MaintenanceAction.Start,
error: 'secret value!',
});
maintenanceEphemeralStateRepositoryMock.getPublicStatus.mockResolvedValue({
action: MaintenanceAction.Start,
error: 'public mock',
});
});
it('generates private status', async () => {
maintenanceEphemeralStateRepositoryMock.getSecret.mockReturnValue('secret');
const jwt = await new SignJWT({ _mockValue: true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode('secret'));
await expect(sut.status(jwt)).resolves.toEqual(
expect.objectContaining({
error: 'secret value!',
}),
);
});
it('generates public status', async () => {
await expect(sut.status()).resolves.toEqual(
expect.objectContaining({
error: 'public mock',
}),
);
});
});
describe('integrityCheck', () => {
it('generate integrity report', async () => {
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
mocks.storage.readFile.mockResolvedValue(undefined as never);
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(`
{
"storage": [
{
"files": 2,
"folder": "encoded-video",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "library",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "upload",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "profile",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "thumbs",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "backups",
"readable": true,
"writable": false,
},
],
}
`);
});
});
describe('login', () => { describe('login', () => {
it('should fail without token', async () => { it('should fail without token', async () => {
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token')); await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
@@ -155,7 +270,23 @@ describe(MaintenanceWorkerService.name, () => {
}); });
}); });
describe('endMaintenance', () => { describe.skip('setAction'); // just calls setStatus+runAction
/**
* Actions
*/
describe('action: start', () => {
it('should not do anything', async () => {
await sut.runAction({
action: MaintenanceAction.Start,
});
expect(mocks.logger.log).toHaveBeenCalledTimes(0);
});
});
describe('action: end', () => {
it('should set maintenance mode', async () => { it('should set maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await sut.runAction({ await sut.runAction({
@@ -176,20 +307,6 @@ describe(MaintenanceWorkerService.name, () => {
}); });
}); });
/**
* Actions
*/
describe('action: start', () => {
it('should not do anything', async () => {
await sut.runAction({
action: MaintenanceAction.Start,
});
expect(mocks.logger.log).toHaveBeenCalledTimes(0);
});
});
describe('action: restore database', () => { describe('action: restore database', () => {
beforeEach(() => { beforeEach(() => {
mocks.database.tryLock.mockResolvedValueOnce(true); mocks.database.tryLock.mockResolvedValueOnce(true);
@@ -319,4 +436,27 @@ describe(MaintenanceWorkerService.name, () => {
expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`);
}); });
}); });
describe('uploadBackup', () => {
it('should reject invalid file names', async () => {
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
new BadRequestException('Not a valid backup name!'),
);
});
it('should write file', async () => {
await sut.uploadBackup({ originalname: 'path.sql.gz' } as never);
expect(mocks.storage.overwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', undefined);
});
});
describe('getBackupPath', () => {
it('should reject invalid file names', () => {
expect(() => sut.getBackupPath('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
});
it('should get backup path', () => {
expect(sut.getBackupPath('hello.sql.gz')).toEqual('/data/backups/hello.sql.gz');
});
});
}); });

View File

@@ -6,7 +6,12 @@ import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { join } from 'node:path'; import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import {
MaintenanceAuthDto,
MaintenanceIntegrityResponseDto,
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto'; import { ServerConfigDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, ImmichCookie, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository'; import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
@@ -24,7 +29,7 @@ import { type ServerService as _ServerService } from 'src/services/server.servic
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups'; import { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups';
import { getConfig } from 'src/utils/config'; import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, integrityCheck } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
/** /**
@@ -172,6 +177,10 @@ export class MaintenanceWorkerService {
} }
} }
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> { async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) { if (!jwt) {
throw new UnauthorizedException('Missing JWT Token'); throw new UnauthorizedException('Missing JWT Token');
@@ -287,7 +296,7 @@ export class MaintenanceWorkerService {
} }
async uploadBackup(file: Express.Multer.File): Promise<void> { async uploadBackup(file: Express.Multer.File): Promise<void> {
return uploadBackup(file); return uploadBackup(this.backupRepos, file);
} }
getBackupPath(filename: string): string { getBackupPath(filename: string): string {

View File

@@ -1,3 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
@@ -56,6 +57,57 @@ describe(MaintenanceService.name, () => {
}); });
}); });
describe('integrityCheck', () => {
it('generate integrity report', async () => {
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
mocks.storage.readFile.mockResolvedValue(undefined as never);
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
await expect(sut.integrityCheck()).resolves.toMatchInlineSnapshot(`
{
"storage": [
{
"files": 2,
"folder": "encoded-video",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "library",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "upload",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "profile",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "thumbs",
"readable": true,
"writable": false,
},
{
"files": 2,
"folder": "backups",
"readable": true,
"writable": false,
},
],
}
`);
});
});
describe('startMaintenance', () => { describe('startMaintenance', () => {
it('should set maintenance mode and return a secret', async () => { it('should set maintenance mode and return a secret', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false }); mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
@@ -137,7 +189,7 @@ describe(MaintenanceService.name, () => {
*/ */
describe('listBackups', () => { describe('listBackups', () => {
it('should give us all valid and failed backups', async () => { it('should give us all backups', async () => {
mocks.storage.readdir.mockResolvedValue([ mocks.storage.readdir.mockResolvedValue([
`immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`, `immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`,
`immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`, `immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
@@ -162,4 +214,27 @@ describe(MaintenanceService.name, () => {
expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`); expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`);
}); });
}); });
describe('uploadBackup', () => {
it('should reject invalid file names', async () => {
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
new BadRequestException('Not a valid backup name!'),
);
});
it('should write file', async () => {
await sut.uploadBackup({ originalname: 'path.sql.gz' } as never);
expect(mocks.storage.overwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', undefined);
});
});
describe('getBackupPath', () => {
it('should reject invalid file names', () => {
expect(() => sut.getBackupPath('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
});
it('should get backup path', () => {
expect(sut.getBackupPath('hello.sql.gz')).toEqual('/data/backups/hello.sql.gz');
});
});
}); });

View File

@@ -2,12 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto, MaintenanceIntegrityResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum'; import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups'; import { deleteBackup, isValidBackupName, listBackups, uploadBackup } from 'src/utils/backups';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import {
createMaintenanceLoginUrl,
generateMaintenanceSecret,
integrityCheck,
signMaintenanceJwt,
} from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
/** /**
@@ -21,6 +26,10 @@ export class MaintenanceService extends BaseService {
.then((state) => state ?? { isMaintenanceMode: false }); .then((state) => state ?? { isMaintenanceMode: false });
} }
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
return integrityCheck(this.storageRepository);
}
async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> { async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> {
const secret = generateMaintenanceSecret(); const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
@@ -86,7 +95,7 @@ export class MaintenanceService extends BaseService {
} }
async uploadBackup(file: Express.Multer.File): Promise<void> { async uploadBackup(file: Express.Multer.File): Promise<void> {
return uploadBackup(file); return uploadBackup(this.backupRepos, file);
} }
getBackupPath(filename: string): string { getBackupPath(filename: string): string {

View File

@@ -1,8 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { stat, writeFile } from 'node:fs/promises'; import path, { basename, join } from 'node:path';
import path, { join } from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream'; import { PassThrough, Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import semver from 'semver'; import semver from 'semver';
@@ -273,21 +272,18 @@ export async function listBackups({ storage }: Pick<BackupRepos, 'storage'>): Pr
.toReversed(); .toReversed();
} }
export async function uploadBackup(file: Express.Multer.File): Promise<void> { export async function uploadBackup(
{ storage }: Pick<BackupRepos, 'storage'>,
file: Express.Multer.File,
): Promise<void> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const fn = file.originalname; const fn = basename(file.originalname);
if (!isValidBackupName(fn)) { if (!isValidBackupName(fn)) {
throw new BadRequestException('Not a valid backup name!'); throw new BadRequestException('Not a valid backup name!');
} }
const path = join(backupsFolder, `uploaded-${fn}`); const path = join(backupsFolder, `uploaded-${fn}`);
await storage.overwriteFile(path, file.buffer);
try {
await stat(path);
throw new BadRequestException('File already exists!');
} catch {
await writeFile(path, file.buffer);
}
} }
function createSqlProgressStreams(cb: (progress: number) => void) { function createSqlProgressStreams(cb: (progress: number) => void) {