mirror of
https://github.com/immich-app/immich.git
synced 2026-02-14 21:08:15 +03:00
* feat: ProcessRepository#createSpawnDuplexStream
* test: write tests for ProcessRepository#createSpawnDuplexStream
* feat: StorageRepository#createGzip,createGunzip,createPlainReadStream
* feat: backups util (args, create, restore, progress)
* feat: wait on maintenance operation lock on boot
* chore: use backup util from backup.service.ts
test: update backup.service.ts tests with new util
* feat: list/delete backups (maintenance services)
* chore: open api
fix: missing action in cli.service.ts
* chore: add missing repositories to MaintenanceModule
* refactor: move logSecret into module init
* feat: initialise StorageCore in maintenance mode
* feat: authenticate websocket requests in maintenance mode
* test: add mock for new storage fns
* feat: add MaintenanceEphemeralStateRepository
refactor: cache the secret in memory
* test: update service worker tests
* feat: add external maintenance mode status
* feat: synchronised status, restore db action
* test: backup restore service tests
* refactor: DRY end maintenance
* feat: list and delete backup routes
* feat: start action on boot
* fix: should set status on restore end
* refactor: add maintenanceStore to hold writables
* feat: sync status to web app
* feat: web impl.
* test: various utils for testings
* test: web e2e tests
* test: e2e maintenance spec
* test: update cli spec
* chore: e2e lint
* chore: lint fixes
* chore: lint fixes
* feat: start restore flow route
* test: update e2e tests
* chore: remove neon lights on maintenance action pages
* fix: use 'startRestoreFlow' on onboarding page
* chore: ignore any library folder in `docker/`
* fix: load status on boot
* feat: upload backups
* refactor: permit any .sql(.gz) to be listed/restored
* feat: download backups from list
* fix: permit uploading just .sql files
* feat: restore just .sql files
* fix: don't show backups list if logged out
* feat: system integrity check in restore flow
* test: not providing failed backups in API anymore
* test: util should also not try to use failedBackups
* fix: actually assign inputStream
* test: correct test backup prep.
* fix: ensure task is defined to show error
* test: fix docker cp command
* test: update e2e web spec to select next button
* test: update e2e api tests
* test: refactor timeouts
* chore: remove `showDelete` from maint. settings
* chore: lint
* chore: lint
* fix: make sure backups are correctly sorted for clean up
* test: update service spec
* test: adjust e2e timeout
* test: increase web timeouts for ci
* chore: move gitignore changes
* chore: additional filename validation
* refactor: better typings for integrity API
* feat: higher accuracy progress tracking
* chore: delay lock retry
* refactor: remove old maintenance settings
* refactor: clean up tailwind classes
* refactor: use while loop rather than recursive calls
* test: update service specs
* chore: check canParse too
* chore: lint
* fix: logic error causing infinite loop
* refactor: use <ProgressBar /> from ui library
* fix: create or overwrite file
* chore: i18n pass, update progress bar
* fix: wrong translation string
* chore: update colour variables
* test: update web test for new maint. page
* chore: format, fix key
* test: update tests to be more linter complaint & use new routines
* chore: update onClick -> onAction, title -> breadcrumbs
* fix: use wrench icon in admin settings sidebar
* chore: add translation strings to accordion
* chore: lint
* refactor: move maintenance worker init into service
* refactor: `maintenanceStatus` -> `getMaintenanceStatus`
refactor: `integrityCheck` -> `detectPriorInstall`
chore: add `v2.4.0` version
refactor: `/backups/list` -> `/backups`
refactor: use sendFile in download route
refactor: use separate backups permissions
chore: correct descriptions
refactor: permit handler that doesn't return promise for sendfile
* refactor: move status impl into service
refactor: add active flag to maintenance status
* refactor: split into database backup controller
* test: split api e2e tests and passing
* fix: move end button into authed default maint page
* fix: also show in restore flow
* fix: import getMaintenanceStatus
* test: split web e2e tests
* refactor: ensure detect install is consistently named
* chore: ensure admin for detect install while out of maint.
* refactor: remove state repository
* test: update maint. worker service spec
* test: split backup service spec
* refactor: rename db backup routes
* refactor: instead of param, allow bulk backup deletion
* test: update sdk use in e2e test
* test: correct deleteBackup call
* fix: correct type for serverinstall response dto
* chore: validate filename for deletion
* test: wip
* test: backups no longer take path param
* refactor: scope util to database-backups instead of backups
* fix: update worker controller with new route
* chore: use new admin page actions
* chore: remove stray comment
* test: rename outdated test
* refactor: getter pattern for maintenance secret
* refactor: `createSpawnDuplexStream` -> `spawnDuplexStream`
* refactor: prefer `Object.assign`
* refactor: remove useless try {} block
* refactor: prefer `type Props`
refactor: prefer arrow function
* refactor: use luxon API for minutesAgo
* chore: remove change to gitignore
* refactor: prefer `type Props`
* refactor: remove async from onMount
* refactor: use luxon toRelative for relative time
* refactor: duplicate logic check
* chore: open api
* refactor: begin moving code into web//services
* refactor: don't use template string with $t
* test: use dialog role to match prompt
* refactor: split actions into flow/restore
* test: fix action value
* refactor: move more service calls into web//services
* chore: should void fn return
* chore: bump 2.4.0 to 2.5.0 in controller
* chore: bump 2.4.0 to 2.5.0 in controller
* refactor: use events for web//services
* chore: open api
* chore: open api
* refactor: don't await returned promise
* refactor: remove redundant check
* refactor: add `type: command` to actions
* refactor: split backup entries into own component
* refactor: split restore flow into separate components
* refactor(web): split BackupDelete event
* chore: stylings
* chore: stylings
* fix: don't log query failure on first boot
* feat: support pg_dumpall backups
* feat: display information about each backup
* chore: i18n
* feat: rollback to restore point on migrations failure
* feat: health check after restore
* chore: format
* refactor: split health check into separate function
* refactor: split health into repository
test: write tests covering rollbacks
* fix: omit 'health' requirement from createDbBackup
* test(e2e): rollback test
* fix: wrap text in backup entry
* fix: don't shrink context menu button
* fix: correct CREATE DB syntax for postgres
* test: rename backups generated by test
* feat: add filesize to backup response dto
* feat: restore list
* feat: ui work
* fix: e2e test
* fix: e2e test
* pr feedback
* pr feedback
---------
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
import { SignJWT } from 'jose';
|
|
import { DateTime } from 'luxon';
|
|
import { PassThrough, Readable } from 'node:stream';
|
|
import { StorageCore } from 'src/cores/storage.core';
|
|
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
|
|
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
|
import { automock, AutoMocked, getMocks, mockDuplex, mockSpawn, ServiceMocks } from 'test/utils';
|
|
|
|
function* mockData() {
|
|
yield '';
|
|
}
|
|
|
|
describe(MaintenanceWorkerService.name, () => {
|
|
let sut: MaintenanceWorkerService;
|
|
let mocks: ServiceMocks;
|
|
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
|
let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>;
|
|
|
|
beforeEach(() => {
|
|
mocks = getMocks();
|
|
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
|
|
args: [mocks.logger],
|
|
strict: false,
|
|
});
|
|
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
|
args: [mocks.logger],
|
|
strict: false,
|
|
});
|
|
|
|
sut = new MaintenanceWorkerService(
|
|
mocks.logger as never,
|
|
mocks.app,
|
|
mocks.config,
|
|
mocks.systemMetadata as never,
|
|
maintenanceWebsocketRepositoryMock,
|
|
maintenanceHealthRepositoryMock,
|
|
mocks.storage as never,
|
|
mocks.process,
|
|
mocks.database as never,
|
|
);
|
|
|
|
sut.mock({
|
|
active: true,
|
|
action: MaintenanceAction.Start,
|
|
});
|
|
});
|
|
|
|
it('should work', () => {
|
|
expect(sut).toBeDefined();
|
|
});
|
|
|
|
describe('getSystemConfig', () => {
|
|
it('should respond the server is in maintenance mode', () => {
|
|
expect(sut.getSystemConfig()).toMatchObject(
|
|
expect.objectContaining({
|
|
maintenanceMode: true,
|
|
}),
|
|
);
|
|
|
|
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(0);
|
|
});
|
|
});
|
|
|
|
describe.skip('ssr');
|
|
describe.skip('detectMediaLocation');
|
|
|
|
describe('setStatus', () => {
|
|
it('should broadcast status', () => {
|
|
sut.setStatus({
|
|
active: true,
|
|
action: MaintenanceAction.Start,
|
|
task: 'abc',
|
|
error: 'def',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled();
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2);
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: 'start',
|
|
task: 'abc',
|
|
error: 'def',
|
|
});
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
|
|
active: true,
|
|
action: 'start',
|
|
task: 'abc',
|
|
error: 'Something went wrong, see logs!',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('logSecret', () => {
|
|
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
|
|
|
|
it('should log a valid login URL', async () => {
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
isMaintenanceMode: true,
|
|
secret: 'secret',
|
|
action: {
|
|
action: MaintenanceAction.Start,
|
|
},
|
|
});
|
|
|
|
await expect(sut.logSecret()).resolves.toBeUndefined();
|
|
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
|
|
|
|
const [url] = mocks.logger.log.mock.lastCall!;
|
|
const token = RE_LOGIN_URL.exec(url)![1];
|
|
|
|
await expect(sut.login(token)).resolves.toEqual(
|
|
expect.objectContaining({
|
|
username: 'immich-admin',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('authenticate', () => {
|
|
it('should fail without a cookie', async () => {
|
|
await expect(sut.authenticate({})).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
|
|
});
|
|
|
|
it('should parse cookie properly', async () => {
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
isMaintenanceMode: true,
|
|
secret: 'secret',
|
|
action: {
|
|
action: MaintenanceAction.Start,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
sut.authenticate({
|
|
cookie: 'immich_maintenance_token=invalid-jwt',
|
|
}),
|
|
).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
|
|
});
|
|
});
|
|
|
|
describe('status', () => {
|
|
beforeEach(() => {
|
|
sut.mock({
|
|
active: true,
|
|
action: MaintenanceAction.Start,
|
|
error: 'secret value!',
|
|
});
|
|
});
|
|
|
|
it('generates private status', async () => {
|
|
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: 'Something went wrong, see logs!',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('detectPriorInstall', () => {
|
|
it('generate report about prior installation', 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.detectPriorInstall()).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'));
|
|
});
|
|
|
|
it('should fail with expired JWT', async () => {
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
isMaintenanceMode: true,
|
|
secret: 'secret',
|
|
action: {
|
|
action: MaintenanceAction.Start,
|
|
},
|
|
});
|
|
|
|
const jwt = await new SignJWT({})
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setExpirationTime('0s')
|
|
.sign(new TextEncoder().encode('secret'));
|
|
|
|
await expect(sut.login(jwt)).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
|
|
});
|
|
|
|
it('should succeed with valid JWT', async () => {
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
isMaintenanceMode: true,
|
|
secret: 'secret',
|
|
action: {
|
|
action: MaintenanceAction.Start,
|
|
},
|
|
});
|
|
|
|
const jwt = await new SignJWT({ _mockValue: true })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setExpirationTime('4h')
|
|
.sign(new TextEncoder().encode('secret'));
|
|
|
|
await expect(sut.login(jwt)).resolves.toEqual(
|
|
expect.objectContaining({
|
|
_mockValue: true,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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({
|
|
action: MaintenanceAction.End,
|
|
});
|
|
|
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
|
isMaintenanceMode: false,
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
|
isMaintenanceMode: false,
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
|
isMaintenanceMode: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('action: restore database', () => {
|
|
beforeEach(() => {
|
|
mocks.database.tryLock.mockResolvedValueOnce(true);
|
|
|
|
mocks.storage.readdir.mockResolvedValue([]);
|
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
|
mocks.process.spawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
|
mocks.process.fork.mockImplementation(() => mockSpawn(0, 'Immich Server is listening', ''));
|
|
mocks.storage.rename.mockResolvedValue();
|
|
mocks.storage.unlink.mockResolvedValue();
|
|
mocks.storage.createPlainReadStream.mockReturnValue(Readable.from(mockData()));
|
|
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
|
|
mocks.storage.createGzip.mockReturnValue(new PassThrough());
|
|
mocks.storage.createGunzip.mockReturnValue(new PassThrough());
|
|
});
|
|
|
|
it('should update maintenance mode state', async () => {
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'filename',
|
|
});
|
|
|
|
expect(mocks.database.tryLock).toHaveBeenCalled();
|
|
expect(mocks.logger.log).toHaveBeenCalledWith('Running maintenance action restore_database');
|
|
|
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
|
isMaintenanceMode: true,
|
|
secret: 'secret',
|
|
action: {
|
|
action: 'start',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should fail to restore invalid backup', async () => {
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'filename',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
error: 'Error: Invalid backup file format!',
|
|
task: 'error',
|
|
});
|
|
});
|
|
|
|
it('should successfully run a backup', async () => {
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'development-filename.sql',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith(
|
|
'MaintenanceStatusV1',
|
|
expect.any(String),
|
|
{
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
task: 'ready',
|
|
progress: expect.any(Number),
|
|
},
|
|
);
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
|
'MaintenanceStatusV1',
|
|
expect.any(String),
|
|
{
|
|
active: true,
|
|
action: 'end',
|
|
},
|
|
);
|
|
|
|
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
|
|
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should fail if backup creation fails', async () => {
|
|
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
|
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'development-filename.sql',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
error: 'Error: pg_dump non-zero exit code (1)\nerror',
|
|
task: 'error',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
|
'MaintenanceStatusV1',
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
task: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should fail if restore itself fails', async () => {
|
|
mocks.process.spawnDuplexStream
|
|
.mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', ''))
|
|
.mockReturnValueOnce(mockDuplex('gzip', 0, 'data', ''))
|
|
.mockReturnValueOnce(mockDuplex('psql', 1, '', 'error'));
|
|
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'development-filename.sql',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
error: 'Error: psql non-zero exit code (1)\nerror',
|
|
task: 'error',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
|
'MaintenanceStatusV1',
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
task: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should rollback if database migrations fail', async () => {
|
|
mocks.database.runMigrations.mockRejectedValue(new Error('Migrations Error'));
|
|
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'development-filename.sql',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
error: 'Error: Migrations Error',
|
|
task: 'error',
|
|
});
|
|
|
|
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalledTimes(0);
|
|
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('should rollback if API healthcheck fails', async () => {
|
|
maintenanceHealthRepositoryMock.checkApiHealth.mockRejectedValue(new Error('Health Error'));
|
|
|
|
await sut.runAction({
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
restoreBackupFilename: 'development-filename.sql',
|
|
});
|
|
|
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
|
active: true,
|
|
action: MaintenanceAction.RestoreDatabase,
|
|
error: 'Error: Health Error',
|
|
task: 'error',
|
|
});
|
|
|
|
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
|
|
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Backups
|
|
*/
|
|
|
|
describe('listBackups', () => {
|
|
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`,
|
|
'immich-db-backup-1753789649000.sql.gz',
|
|
`immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
|
]);
|
|
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
|
|
|
|
await expect(sut.listBackups()).resolves.toMatchObject({
|
|
backups: [
|
|
{ filename: 'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
|
{ filename: 'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
|
{ filename: 'immich-db-backup-1753789649000.sql.gz', filesize: 1024 },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('deleteBackup', () => {
|
|
it('should reject invalid file names', async () => {
|
|
await expect(sut.deleteBackup(['filename'])).rejects.toThrowError(
|
|
new BadRequestException('Invalid backup name!'),
|
|
);
|
|
});
|
|
|
|
it('should unlink the target file', async () => {
|
|
await sut.deleteBackup(['filename.sql']);
|
|
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
|
`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('uploadBackup', () => {
|
|
it('should reject invalid file names', async () => {
|
|
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
|
|
new BadRequestException('Invalid backup name!'),
|
|
);
|
|
});
|
|
|
|
it('should write file', async () => {
|
|
await sut.uploadBackup({ originalname: 'path.sql.gz', buffer: 'buffer' } as never);
|
|
expect(mocks.storage.createOrOverwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', 'buffer');
|
|
});
|
|
});
|
|
|
|
describe('downloadBackup', () => {
|
|
it('should reject invalid file names', () => {
|
|
expect(() => sut.downloadBackup('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
|
|
});
|
|
|
|
it('should get backup path', () => {
|
|
expect(sut.downloadBackup('hello.sql.gz')).toEqual(
|
|
expect.objectContaining({
|
|
path: '/data/backups/hello.sql.gz',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|