mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
test: update service specs
This commit is contained in:
@@ -29,7 +29,6 @@ import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middlewa
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { integrityCheck } from 'src/utils/maintenance';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
@@ -60,7 +59,7 @@ export class MaintenanceController {
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||
return integrityCheck(this.storageRepository);
|
||||
return this.service.integrityCheck();
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
|
||||
@@ -16,7 +16,6 @@ import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.ser
|
||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { integrityCheck } from 'src/utils/maintenance';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
@@ -39,7 +38,7 @@ export class MaintenanceWorkerController {
|
||||
|
||||
@Get('admin/maintenance/integrity')
|
||||
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||
return integrityCheck(this.storageRepository);
|
||||
return this.service.integrityCheck();
|
||||
}
|
||||
|
||||
@Post('admin/maintenance/login')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { SignJWT } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('should fail without token', async () => {
|
||||
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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
mocks.database.tryLock.mockResolvedValueOnce(true);
|
||||
@@ -319,4 +436,27 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,12 @@ import { readFileSync } from 'node:fs';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
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 { DatabaseLock, ImmichCookie, MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||
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 { deleteBackup, isValidBackupName, listBackups, restoreBackup, uploadBackup } from 'src/utils/backups';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -172,6 +177,10 @@ export class MaintenanceWorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||
return integrityCheck(this.storageRepository);
|
||||
}
|
||||
|
||||
async login(jwt?: string): Promise<MaintenanceAuthDto> {
|
||||
if (!jwt) {
|
||||
throw new UnauthorizedException('Missing JWT Token');
|
||||
@@ -287,7 +296,7 @@ export class MaintenanceWorkerService {
|
||||
}
|
||||
|
||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||
return uploadBackup(file);
|
||||
return uploadBackup(this.backupRepos, file);
|
||||
}
|
||||
|
||||
getBackupPath(filename: string): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
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', () => {
|
||||
it('should set maintenance mode and return a secret', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||
@@ -137,7 +189,7 @@ describe(MaintenanceService.name, () => {
|
||||
*/
|
||||
|
||||
describe('listBackups', () => {
|
||||
it('should give us all valid and failed backups', async () => {
|
||||
it('should give us all backups', async () => {
|
||||
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-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`);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { basename, join } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
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 { BaseService } from 'src/services/base.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -21,6 +26,10 @@ export class MaintenanceService extends BaseService {
|
||||
.then((state) => state ?? { isMaintenanceMode: false });
|
||||
}
|
||||
|
||||
integrityCheck(): Promise<MaintenanceIntegrityResponseDto> {
|
||||
return integrityCheck(this.storageRepository);
|
||||
}
|
||||
|
||||
async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> {
|
||||
const secret = generateMaintenanceSecret();
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||
@@ -86,7 +95,7 @@ export class MaintenanceService extends BaseService {
|
||||
}
|
||||
|
||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||
return uploadBackup(file);
|
||||
return uploadBackup(this.backupRepos, file);
|
||||
}
|
||||
|
||||
getBackupPath(filename: string): string {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { debounce } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
import { stat, writeFile } from 'node:fs/promises';
|
||||
import path, { join } from 'node:path';
|
||||
import path, { basename, join } from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import semver from 'semver';
|
||||
@@ -273,21 +272,18 @@ export async function listBackups({ storage }: Pick<BackupRepos, 'storage'>): Pr
|
||||
.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 fn = file.originalname;
|
||||
const fn = basename(file.originalname);
|
||||
if (!isValidBackupName(fn)) {
|
||||
throw new BadRequestException('Not a valid backup name!');
|
||||
}
|
||||
|
||||
const path = join(backupsFolder, `uploaded-${fn}`);
|
||||
|
||||
try {
|
||||
await stat(path);
|
||||
throw new BadRequestException('File already exists!');
|
||||
} catch {
|
||||
await writeFile(path, file.buffer);
|
||||
}
|
||||
await storage.overwriteFile(path, file.buffer);
|
||||
}
|
||||
|
||||
function createSqlProgressStreams(cb: (progress: number) => void) {
|
||||
|
||||
Reference in New Issue
Block a user