import { AssetMediaResponseDto, IntegrityReportResponseDto, LoginResponseDto, ManualJobName, QueueCommand, QueueName, } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const asset1Filepath = `${testAssetDir}/albums/nature/el_torcal_rocks.jpg`; const asset2Filepath = `${testAssetDir}/albums/nature/wood_anemones.jpg`; describe('/admin/integrity', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; let user1: LoginResponseDto; let asset1: AssetMediaResponseDto; let user2: LoginResponseDto; let asset2: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); user1 = await utils.userSetup(admin.accessToken, { email: '1@example.com', name: '1', password: '1', }); user2 = await utils.userSetup(admin.accessToken, { email: '2@example.com', name: '2', password: '2', }); for (const queue of Object.values(QueueName)) { if (queue === QueueName.IntegrityCheck) { continue; } await utils.queueCommand(admin.accessToken, queue, { command: QueueCommand.Pause, }); } asset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'asset.jpg', bytes: await readFile(assetFilepath), }, }); asset1 = await utils.createAsset(user1.accessToken, { assetData: { filename: 'asset.jpg', bytes: await readFile(asset1Filepath), }, }); asset2 = await utils.createAsset(user2.accessToken, { assetData: { filename: 'asset.jpg', bytes: await readFile(asset2Filepath), }, }); await utils.mkFolder('/data/bak'); await utils.copyFolder(`/data/upload/${admin.userId}`, `/data/bak/${admin.userId}`); for (const queue of Object.values(QueueName)) { if (queue === QueueName.IntegrityCheck) { continue; } await utils.queueCommand(admin.accessToken, queue, { command: QueueCommand.Empty, }); await utils.queueCommand(admin.accessToken, queue, { command: QueueCommand.Resume, }); } }); afterEach(async () => { await utils.deleteFolder(`/data/upload/${admin.userId}`); await utils.copyFolder(`/data/bak/${admin.userId}`, `/data/upload/${admin.userId}`); }); describe('POST /summary (& jobs)', async () => { it.sequential('reports no issues', async () => { await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFilesDeleteAll, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual({ missing_file: 0, untracked_file: 0, checksum_mismatch: 0, }); }); it.sequential('should detect an untracked file (job: check untracked files)', async () => { await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ untracked_file: 1, }), ); }); it.sequential('should detect outdated untracked file reports (job: refresh untracked files)', async () => { // these should not be detected: await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked2.png`); await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked3.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFilesRefresh, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ untracked_file: 0, }), ); }); it.sequential('should delete untracked files (job: delete all untracked file reports)', async () => { await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFilesDeleteAll, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ untracked_file: 0, }), ); }); it.sequential('should detect a missing file and not a checksum mismatch (job: check missing files)', async () => { await utils.deleteFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ missing_file: 1, checksum_mismatch: 0, }), ); }); it.sequential('should detect outdated missing file reports (job: refresh missing files)', async () => { await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFilesRefresh, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ missing_file: 0, checksum_mismatch: 0, }), ); }); it.sequential('should delete assets with missing files (job: delete all missing file reports)', async () => { await utils.deleteFolder(`/data/upload/${user1.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus, body: listBody } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus).toBe(200); expect(listBody).toEqual( expect.objectContaining({ missing_file: 1, }), ); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFilesDeleteAll, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ missing_file: 0, }), ); await expect(utils.getAssetInfo(user1.accessToken, asset1.id)).resolves.toEqual( expect.objectContaining({ isTrashed: true, }), ); }); it.sequential('should detect a checksum mismatch (job: check file checksums)', async () => { await utils.truncateFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ checksum_mismatch: 1, }), ); }); it.sequential('should detect outdated checksum mismatch reports (job: refresh file checksums)', async () => { await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatchRefresh, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ checksum_mismatch: 0, }), ); }); it.sequential( 'should delete assets with mismatched checksum (job: delete all checksum mismatch reports)', async () => { await utils.truncateFolder(`/data/upload/${user2.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus, body: listBody } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus).toBe(200); expect(listBody).toEqual( expect.objectContaining({ checksum_mismatch: 1, }), ); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatchDeleteAll, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ checksum_mismatch: 0, }), ); await expect(utils.getAssetInfo(user2.accessToken, asset2.id)).resolves.toEqual( expect.objectContaining({ isTrashed: true, }), ); }, ); }); describe('POST /report', async () => { it.sequential('reports untracked files', async () => { await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/report?type=untracked_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual({ nextCursor: undefined, items: expect.arrayContaining([ { id: expect.any(String), type: 'untracked_file', path: `/data/upload/${admin.userId}/untracked1.png`, assetId: null, fileAssetId: null, createdAt: expect.any(String), }, ]), }); }); it.sequential('reports missing files', async () => { await utils.deleteFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/report?type=missing_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual({ nextCursor: undefined, items: expect.arrayContaining([ { id: expect.any(String), type: 'missing_file', path: expect.any(String), assetId: asset.id, fileAssetId: null, createdAt: expect.any(String), }, ]), }); }); it.sequential('reports checksum mismatched files', async () => { await utils.truncateFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) .get('/admin/integrity/report?type=checksum_mismatch') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(body).toEqual({ nextCursor: undefined, items: expect.arrayContaining([ { id: expect.any(String), type: 'checksum_mismatch', path: expect.any(String), assetId: asset.id, fileAssetId: null, createdAt: expect.any(String), }, ]), }); }); }); describe('DELETE /report/:id', async () => { it.sequential('delete untracked files', async () => { await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus, body: listBody } = await request(app) .get('/admin/integrity/report?type=untracked_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus).toBe(200); const report = (listBody as IntegrityReportResponseDto).items.find( (item) => item.path === `/data/upload/${admin.userId}/untracked1.png`, )!; const { status } = await request(app) .delete(`/admin/integrity/report/${report.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus2, body: listBody2 } = await request(app) .get('/admin/integrity/report?type=untracked_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus2).toBe(200); expect(listBody2).not.toBe( expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ id: report.id, }), ]), }), ); }); it.sequential('delete assets missing files', async () => { await utils.deleteFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus, body: listBody } = await request(app) .get('/admin/integrity/report?type=missing_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus).toBe(200); expect(listBody.items.length).toBe(1); const report = (listBody as IntegrityReportResponseDto).items[0]; const { status } = await request(app) .delete(`/admin/integrity/report/${report.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityMissingFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus2, body: listBody2 } = await request(app) .get('/admin/integrity/report?type=missing_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus2).toBe(200); expect(listBody2.items.length).toBe(0); }); it.sequential('delete assets with failing checksum', async () => { await utils.truncateFolder(`/data/upload/${admin.userId}`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus, body: listBody } = await request(app) .get('/admin/integrity/report?type=checksum_mismatch') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus).toBe(200); expect(listBody.items.length).toBe(1); const report = (listBody as IntegrityReportResponseDto).items[0]; const { status } = await request(app) .delete(`/admin/integrity/report/${report.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityChecksumMismatch, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status: listStatus2, body: listBody2 } = await request(app) .get('/admin/integrity/report?type=checksum_mismatch') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(listStatus2).toBe(200); expect(listBody2.items.length).toBe(0); }); }); describe('GET /report/:type/csv', () => { it.sequential('exports untracked files as csv', async () => { await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, headers, text } = await request(app) .get('/admin/integrity/report/untracked_file/csv') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(200); expect(headers['content-type']).toContain('text/csv'); expect(headers['content-disposition']).toContain('.csv'); expect(text).toContain('id,type,assetId,fileAssetId,path'); expect(text).toContain(`untracked_file`); expect(text).toContain(`/data/upload/${admin.userId}/untracked1.png`); }); }); describe('GET /report/:id/file', () => { it.sequential('downloads untracked file', async () => { await utils.putTextFile('untracked-content', `/data/upload/${admin.userId}/untracked1.png`); await utils.createJob(admin.accessToken, { name: ManualJobName.IntegrityUntrackedFiles, }); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { body: listBody } = await request(app) .get('/admin/integrity/report?type=untracked_file') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); const report = (listBody as IntegrityReportResponseDto).items.find( (item) => item.path === `/data/upload/${admin.userId}/untracked1.png`, )!; const { status, headers, body } = await request(app) .get(`/admin/integrity/report/${report.id}/file`) .set('Authorization', `Bearer ${admin.accessToken}`) .buffer(true) .send(); expect(status).toBe(200); expect(headers['content-type']).toContain('application/octet-stream'); expect(body.toString()).toBe('untracked-content'); }); }); });