Skip to content
Draft
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f77f43a
stash: integrity checks
insertish Nov 26, 2025
4a7120c
refactor: batched integrity checks
insertish Nov 26, 2025
3414210
feat: checksum job
insertish Nov 27, 2025
15503b1
chore: open api
insertish Nov 27, 2025
1e941f3
feat: write integrity report to database
insertish Nov 27, 2025
929ad52
feat: add createdAt to integrity report table
insertish Nov 27, 2025
cc31b9c
feat: clean up old reports of checksum or missing files
insertish Nov 27, 2025
ef7d8e9
feat: check orphaned file reports are not out of date
insertish Nov 27, 2025
1744237
chore: open api
insertish Nov 27, 2025
9386023
feat: add config options & cron entries for checks
insertish Nov 27, 2025
2516319
fix: mock the new repository
insertish Nov 27, 2025
919eb83
revert: override migration db url
insertish Nov 27, 2025
4462683
chore: generate SQL queries
insertish Nov 27, 2025
03276de
fix: add integrity report repository to service depends.
insertish Nov 27, 2025
8db6132
fix: add mock for asset repo.
insertish Nov 27, 2025
0fdc7b4
feat: draft controller entry
insertish Nov 27, 2025
d3abed3
feat: view integrity report in maintenance page (cherry picked)
insertish Nov 27, 2025
ca358f4
feat: sub-pages for integrity reports
insertish Nov 28, 2025
c50118e
chore: remove old table comment
insertish Nov 28, 2025
13e9cf0
stash: moving computers because pnpm is cooked
insertish Nov 28, 2025
2779fce
feat: manually trigger integrity jobs
insertish Nov 28, 2025
e447ba8
chore: sort i18n
insertish Nov 28, 2025
4d7f7b8
feat: refresh missing & checksum
insertish Nov 28, 2025
0362d21
test: take baseline, check for each issue, check refreshes work
insertish Nov 28, 2025
c4ac8d9
stash: incomplete checksum outdated test
insertish Nov 28, 2025
01f96de
test: serialise the buffer over events
insertish Dec 1, 2025
1daf1b4
chore: lint
insertish Dec 1, 2025
db690bc
chore: generate SQL
insertish Dec 1, 2025
fec8923
test: increase timeouts
insertish Dec 1, 2025
06fcd54
feat: download csv report, download file, delete file
insertish Dec 1, 2025
042af30
chore: use checksum configuration
insertish Dec 1, 2025
806a288
feat: assetId, fileAssetId columns on integrity reports
insertish Dec 1, 2025
6cfd199
feat: ability to delete all reports (and corresponding objects)
insertish Dec 2, 2025
64cc64d
refactor: move all new queries into integrity repository
insertish Dec 2, 2025
6e752be
fix: don't process trashed/deleted assets for integrity
insertish Dec 2, 2025
e1a1662
chore: more compliant csv
insertish Dec 2, 2025
73a17bb
chore: generate SQL
insertish Dec 2, 2025
ae653f9
chore: lint
insertish Dec 2, 2025
7a215c1
fix: flip deletedAt filter
insertish Dec 2, 2025
5d5d421
fix: `path` -> `reportId` as `reportId`
insertish Dec 3, 2025
6e7854b
chore: sync SQL
insertish Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 192 additions & 7 deletions e2e/src/api/specs/maintenance.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { LoginResponseDto } from '@immich/sdk';
import { IntegrityReportType, LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';

const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;

describe('/admin/maintenance', () => {
let cookie: string | undefined;
Expand Down Expand Up @@ -34,6 +37,188 @@
});
});

describe('POST /integrity/summary (& jobs)', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to separate test file

let baseline: Record<IntegrityReportType, number>;

beforeAll(async () => {
await utils.createAsset(admin.accessToken, {
assetData: {
filename: 'asset.jpg',
bytes: await readFile(assetFilepath),
},
});

await utils.copyFolder(`/data/upload/${admin.userId}`, `/data/upload/${admin.userId}-bak`);
});

afterEach(async () => {
await utils.deleteFolder(`/data/upload/${admin.userId}`);
await utils.copyFolder(`/data/upload/${admin.userId}-bak`, `/data/upload/${admin.userId}`);
});

it.sequential('may report issues', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityOrphanFiles,
});

await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityMissingFiles,
});

await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityChecksumMismatch,
});

await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();

expect(status).toBe(200);
expect(body).toEqual({
missing_file: 0,
orphan_file: expect.any(Number),
checksum_mismatch: 0,
});

baseline = body;
});

it.sequential('should detect an orphan file (job: check orphan files)', async () => {
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan1.png`);

await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityOrphanFiles,
});

await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();

expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
orphan_file: baseline.orphan_file + 1,
}),
);
});

it.sequential('should detect outdated orphan file reports (job: refresh orphan files)', async () => {
// these should not be detected:
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan2.png`);
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan3.png`);

await utils.createJob(admin.accessToken, {
name: ManualJobName.IntegrityOrphanFilesRefresh,
});

await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();

expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
orphan_file: baseline.orphan_file,
}),
);
});

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.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/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 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.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);

const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();

expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
checksum_mismatch: 0,
}),
);
});
});

// => enter maintenance mode

describe.sequential('POST /', () => {
Expand Down Expand Up @@ -83,8 +268,8 @@
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 60_000,
},
)
.toBeTruthy();
Expand Down Expand Up @@ -148,7 +333,7 @@
// => exit maintenance mode

describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {

Check failure on line 336 in e2e/src/api/specs/maintenance.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Tests (Server & CLI) (ubuntu-latest)

src/api/specs/maintenance.e2e-spec.ts > /admin/maintenance > POST / > should exit maintenance mode

Error: Test timed out in 15000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ src/api/specs/maintenance.e2e-spec.ts:336:5
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
Expand All @@ -162,8 +347,8 @@
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
Expand Down
57 changes: 55 additions & 2 deletions e2e/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCreateDto,
MaintenanceAction,
MetadataSearchDto,
Permission,
Expand All @@ -21,6 +22,7 @@ import {
checkExistingAssets,
createAlbum,
createApiKey,
createJob,
createLibrary,
createPartner,
createPerson,
Expand Down Expand Up @@ -52,9 +54,12 @@ import {
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import pg from 'pg';
Expand Down Expand Up @@ -171,6 +176,7 @@ export const utils = {
'user',
'system_metadata',
'tag',
'integrity_report',
];

const sql: string[] = [];
Expand Down Expand Up @@ -481,6 +487,9 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),

createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),

queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),

Expand Down Expand Up @@ -559,6 +568,50 @@ export const utils = {
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
},

putFile(source: string, dest: string) {
return executeCommand('docker', ['cp', source, `immich-e2e-server:${dest}`]).promise;
},

async putTextFile(contents: string, dest: string) {
const dir = await mkdtemp(join(tmpdir(), 'test-'));
const fn = join(dir, 'file');
await pipeline(Readable.from(contents), createWriteStream(fn));
return executeCommand('docker', ['cp', fn, `immich-e2e-server:${dest}`]).promise;
},

async move(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
},

async copyFolder(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'cp', '-r', source, dest]).promise;
},

async deleteFile(path: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', path]).promise;
},

async deleteFolder(path: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', '-r', path]).promise;
},

async truncateFolder(path: string) {
return executeCommand('docker', [
'exec',
'immich-e2e-server',
'find',
path,
'-type',
'f',
'-exec',
'truncate',
'-s',
'1',
'{}',
';',
]).promise;
},

resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
Expand Down
10 changes: 10 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
"maintenance_integrity_missing_file": "Missing Files",
"maintenance_integrity_missing_file_job": "Check for missing files",
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
"maintenance_integrity_orphan_file": "Orphan Files",
"maintenance_integrity_orphan_file_job": "Check for orphaned files",
"maintenance_integrity_orphan_file_refresh_job": "Refresh orphan file reports",
"maintenance_integrity_report": "Integrity Report",
"maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode",
Expand Down
13 changes: 13 additions & 0 deletions mobile/openapi/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading