From ed4a850a01022b66325b9a79f9ca92cbb47b31d7 Mon Sep 17 00:00:00 2001 From: izzy Date: Thu, 20 Nov 2025 16:22:30 +0000 Subject: [PATCH] test: e2e maintenance spec --- e2e/src/api/specs/maintenance.e2e-spec.ts | 280 +++++++++++++++++++++- 1 file changed, 276 insertions(+), 4 deletions(-) diff --git a/e2e/src/api/specs/maintenance.e2e-spec.ts b/e2e/src/api/specs/maintenance.e2e-spec.ts index b6c7540bc5..651e8e1ec5 100644 --- a/e2e/src/api/specs/maintenance.e2e-spec.ts +++ b/e2e/src/api/specs/maintenance.e2e-spec.ts @@ -1,9 +1,9 @@ -import { LoginResponseDto } from '@immich/sdk'; +import { LoginResponseDto, ManualJobName } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/admin/maintenance', () => { let cookie: string | undefined; @@ -14,6 +14,7 @@ describe('/admin/maintenance', () => { await utils.resetDatabase(); admin = await utils.adminSetup(); nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + await utils.resetBackups(admin.accessToken); }); // => outside of maintenance mode @@ -26,6 +27,16 @@ describe('/admin/maintenance', () => { }); }); + describe('GET /status', async () => { + it('to always indicate we are not in maintenance mode', async () => { + const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); + expect(status).toBe(200); + expect(body).toEqual({ + action: 'end', + }); + }); + }); + describe('POST /login', async () => { it('should not work out of maintenance mode', async () => { const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' }); @@ -34,6 +45,69 @@ describe('/admin/maintenance', () => { }); }); + describe('GET /backups/list', async () => { + it('should succeed and be empty', async () => { + const { status, body } = await request(app) + .get('/admin/maintenance/backups/list') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + backups: [], + failedBackups: [], + }); + }); + + it('should contain a created backup', async () => { + await utils.createJob(admin.accessToken, { + name: ManualJobName.BackupDatabase, + }); + + await expect + .poll( + async () => { + const { status, body } = await request(app) + .get('/admin/maintenance/backups/list') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + return body; + }, + { + interval: 5e2, + timeout: 1e4, + }, + ) + .toEqual( + expect.objectContaining({ + backups: [expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/)], + }), + ); + }); + }); + + describe('DELETE /backups/:filename', async () => { + it('should delete backup', async () => { + const filename = await utils.createBackup(admin.accessToken); + + const { status } = await request(app) + .delete(`/admin/maintenance/backups/${filename}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + + const { status: listStatus, body } = await request(app) + .get('/admin/maintenance/backups/list') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(listStatus).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + backups: [], + }), + ); + }); + }); + // => enter maintenance mode describe.sequential('POST /', () => { @@ -69,6 +143,7 @@ describe('/admin/maintenance', () => { .send({ action: 'start', }); + expect(status).toBe(201); cookie = headers['set-cookie'][0].split(';')[0]; @@ -79,7 +154,8 @@ describe('/admin/maintenance', () => { await expect .poll( async () => { - const { body } = await request(app).get('/server/config'); + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); return body.maintenanceMode; }, { @@ -102,6 +178,16 @@ describe('/admin/maintenance', () => { }); }); + describe('GET /status', async () => { + it('to indicate we are in maintenance mode', async () => { + const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); + expect(status).toBe(200); + expect(body).toEqual({ + action: 'start', + }); + }); + }); + describe('POST /login', async () => { it('should fail without cookie or token in body', async () => { const { status, body } = await request(app).post('/admin/maintenance/login').send({}); @@ -158,7 +244,8 @@ describe('/admin/maintenance', () => { await expect .poll( async () => { - const { body } = await request(app).get('/server/config'); + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); return body.maintenanceMode; }, { @@ -169,4 +256,189 @@ describe('/admin/maintenance', () => { .toBeFalsy(); }); }); + + // => action: restore database flow + + describe.sequential('POST /start/restore', () => { + afterAll(async () => { + await request(app).post('/admin/maintenance/end').set('cookie', cookie!).send(); + await utils.poll( + () => request(app).get('/server/config'), + ({ status, body }) => status === 200 && !body.maintenanceMode, + ); + + admin = await utils.adminSetup(); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + it.sequential('should not work when the server is configured', async () => { + const { status, body } = await request(app).post('/admin/maintenance/start/restore').send(); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('The server already has an admin')); + }); + + it.sequential('should enter maintenance mode in "database restore mode"', async () => { + await utils.resetDatabase(); // reset database before running this test + + const { status, headers } = await request(app) + .post('/admin/maintenance') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + action: 'restore_database', + }); + + expect(status).toBe(201); + + cookie = headers['set-cookie'][0].split(';')[0]; + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 5e2, + timeout: 1e4, + }, + ) + .toBeTruthy(); + + const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); + expect(status2).toBe(200); + expect(body).toEqual({ + operation: 'restore-database-flow', + }); + }); + }); + + // => action: restore database + + describe.sequential('POST /backups/restore', () => { + beforeAll(async () => { + await utils.disconnectDatabase(); + }); + + afterAll(async () => { + await utils.connectDatabase(); + }); + + it.sequential('should restore a backup', { timeout: 6e4 }, async () => { + const filename = await utils.createBackup(admin.accessToken); + + const { status } = await request(app) + .post('/admin/maintenance') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + action: 'restore_database', + restoreBackupFilename: filename, + }); + + expect(status).toBe(201); + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 5e2, + timeout: 1e4, + }, + ) + .toBeTruthy(); + + const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); + expect(status2).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + operation: 'restore-database', + }), + ); + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 5e2, + timeout: 6e4, + }, + ) + .toBeFalsy(); + }); + + it.sequential('fail to restore a corrupted backup', { timeout: 6e4 }, async () => { + await utils.prepareTestBackup('corrupted.sql'); + + const { status, headers } = await request(app) + .post('/admin/maintenance') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + action: 'restore_database', + restoreBackupFilename: 'development-corrupted.sql.gz', + }); + + expect(status).toBe(201); + cookie = headers['set-cookie'][0].split(';')[0]; + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + return body.maintenanceMode; + }, + { + interval: 5e2, + timeout: 1e4, + }, + ) + .toBeTruthy(); + + await expect + .poll( + async () => { + const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' }); + expect(status).toBe(200); + return body; + }, + { + interval: 5e2, + timeout: 1e4, + }, + ) + .toEqual( + expect.objectContaining({ + operation: 'restore-database', + error: 'Something went wrong, see logs!', + }), + ); + + const { status: status2, body: body2 } = await request(app) + .get('/admin/maintenance/status') + .set('cookie', cookie!) + .send({ token: 'token' }); + expect(status2).toBe(200); + expect(body2).toEqual( + expect.objectContaining({ + operation: 'restore-database', + error: expect.stringContaining('IM CORRUPTED'), + }), + ); + + await request(app).post('/admin/maintenance/end').set('cookie', cookie!).send(); + await utils.poll( + () => request(app).get('/server/config'), + ({ status, body }) => status === 200 && !body.maintenanceMode, + ); + }); + }); });