Files
Immich/server/test/medium/specs/repositories/file-vault.repository.spec.ts
T

237 lines
11 KiB
TypeScript

import { type Kysely } from 'kysely';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { StorageCore } from 'src/cores/storage.core.js';
import { FileVaultRepository } from 'src/repositories/file-vault.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { DB } from 'src/schema/index.js';
import { BaseService } from 'src/services/base.service.js';
import { newMediumService } from 'test/medium.factory.js';
import { getKyselyDB } from 'test/utils.js';
describe(FileVaultRepository.name, () => {
let db: Kysely<DB>;
let repository: FileVaultRepository;
let root: string;
let owner: string;
let other: string;
beforeAll(async () => {
db = await getKyselyDB();
repository = new FileVaultRepository(db);
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'immich-files-'));
StorageCore.setMediaLocation(root);
const { ctx } = newMediumService(BaseService, { database: db, real: [], mock: [LoggingRepository] });
const first = await ctx.newUser();
const second = await ctx.newUser();
owner = first.user.id;
other = second.user.id;
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const list = (id = owner, parentId?: string, trash = false) =>
repository.list(id, { parentId, trash: trash ? 'true' : 'false', offset: 0 });
const upload = (name: string, parentId: string | null = null, content = Buffer.from('private\0document\u{FF}')) =>
repository.upload(owner, name, parentId, Readable.from(content));
const quota = async () => {
const user = await db
.selectFrom('user')
.select('quotaUsageInBytes')
.where('id', '=', owner)
.executeTakeFirstOrThrow();
return Number(user.quotaUsageInBytes);
};
it('preserves arbitrary and empty original bytes with SHA-256', async () => {
const content = Buffer.from([0, 1, 255, 13, 10, 0, 200]);
const file = await upload('Рабочий проект.dwg', null, content);
expect(file.checksum).toBe(createHash('sha256').update(content).digest('hex'));
expect(await readFile(join(root, 'files', owner, file.id))).toEqual(content);
const downloaded = await repository.download(owner, file.id);
expect(await downloaded.stream.toArray()).toEqual([content]);
const empty = await upload('empty.zip', null, Buffer.alloc(0));
expect(Number(empty.size)).toBe(0);
});
it('isolates every folder and file operation by owner', async () => {
const folder = await repository.createFolder(owner, 'Work', null);
const file = await upload('plan.pdf', folder.id);
await expect(list(other)).resolves.toMatchObject({ entries: [] });
for (const action of [
() => repository.download(other, file.id),
() => repository.update(other, file.id, { name: 'stolen' }),
() => repository.trash(other, folder.id),
() => repository.restore(other, folder.id),
() => repository.purge(other, folder.id),
() => repository.createFolder(other, 'Nested', folder.id),
]) {
await expect(action()).rejects.toMatchObject({ code: 'missing' });
}
});
it('streams folder downloads as ZIP including empty directories and excluding trashed contents', async () => {
const folder = await repository.createFolder(owner, 'Work', null);
await repository.createFolder(owner, 'Empty', folder.id);
const content = Buffer.from('unmodified work document');
await upload('report.pdf', folder.id, content);
const trashed = await upload('hidden.zip', folder.id, Buffer.from('excluded private content'));
await repository.trash(owner, trashed.id);
const download = await repository.download(owner, folder.id);
const archive = Buffer.concat(await download.stream.toArray());
expect(archive.subarray(0, 2).toString()).toBe('PK');
expect(archive.includes(Buffer.from('Work/Empty/'))).toBe(true);
expect(archive.includes(Buffer.from('Work/report.pdf'))).toBe(true);
expect(archive.includes(content)).toBe(true);
expect(archive.includes(Buffer.from('hidden.zip'))).toBe(false);
});
it('keeps file data unchanged when renaming or moving a folder tree', async () => {
const folder = await repository.createFolder(owner, 'Project', null);
const nested = await repository.createFolder(owner, 'Drawings', folder.id);
const target = await repository.createFolder(owner, 'Archive', null);
const file = await upload('part.step', nested.id);
await repository.update(owner, folder.id, { name: 'Project 2', parentId: target.id });
const listing = await list(owner, nested.id);
expect(listing.breadcrumbs.map((row) => row.name)).toEqual(['Archive', 'Project 2', 'Drawings']);
expect(listing.entries[0].id).toBe(file.id);
const downloaded = await repository.download(owner, file.id);
expect(downloaded.entry.checksum).toBe(file.checksum);
});
it('rejects recursive folder moves and unavailable destinations', async () => {
const folder = await repository.createFolder(owner, 'Project', null);
const nested = await repository.createFolder(owner, 'Nested', folder.id);
await expect(repository.update(owner, folder.id, { parentId: nested.id })).rejects.toMatchObject({
code: 'invalid',
});
await expect(repository.update(owner, folder.id, { parentId: folder.id.toUpperCase() })).rejects.toMatchObject({
code: 'invalid',
});
await repository.trash(owner, nested.id);
await expect(upload('plan.pdf', nested.id)).rejects.toMatchObject({ code: 'missing' });
});
it('serializes simultaneous duplicate uploads without overwriting bytes', async () => {
const results = await Promise.allSettled([upload('Report.pdf'), upload('report.pdf')]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
await expect(list()).resolves.toHaveProperty('entries.length', 1);
expect(await readdir(join(root, 'files', owner))).toHaveLength(1);
});
it('restores a folder without restoring items that were trashed earlier', async () => {
const folder = await repository.createFolder(owner, 'Work', null);
const keep = await upload('keep.pdf', folder.id);
const previous = await upload('previous.pdf', folder.id);
await repository.trash(owner, previous.id);
await repository.trash(owner, folder.id);
await expect(repository.download(owner, keep.id)).rejects.toMatchObject({ code: 'missing' });
await repository.restore(owner, folder.id);
await expect(list(owner, folder.id)).resolves.toMatchObject({ entries: [{ id: keep.id }] });
await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [{ id: previous.id }] });
});
it('restores to root when the original parent is unavailable', async () => {
const folder = await repository.createFolder(owner, 'Work', null);
const file = await upload('report.pdf', folder.id);
await repository.trash(owner, file.id);
await repository.trash(owner, folder.id);
await repository.restore(owner, file.id);
await expect(list()).resolves.toMatchObject({ entries: [{ id: file.id }] });
});
it('requires resolving a restore name conflict and retains the original file', async () => {
const file = await upload('report.pdf');
await repository.trash(owner, file.id);
await upload('report.pdf');
await expect(repository.restore(owner, file.id)).rejects.toMatchObject({ code: 'conflict' });
await repository.update(owner, file.id, { name: 'report restored.pdf' });
await repository.restore(owner, file.id);
await expect(list()).resolves.toHaveProperty('entries.length', 2);
});
it('permanently deletes nested data and releases quota only after purge', async () => {
const folder = await repository.createFolder(owner, 'Project', null);
const file = await upload('plan.pdf', folder.id, Buffer.alloc(123));
await expect(repository.purge(owner, folder.id)).rejects.toMatchObject({ code: 'invalid' });
await repository.trash(owner, folder.id);
await expect(list()).resolves.toHaveProperty('usedBytes', 123);
await repository.purge(owner, folder.id);
await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [] });
await expect(readFile(join(root, 'files', owner, file.id))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(quota()).resolves.toBe(0);
});
it('persists failed physical cleanup and retries it after deletion is committed', async () => {
const file = await upload('plan.pdf');
const path = join(root, 'files', owner, file.id);
await rm(path);
await mkdir(path);
await repository.trash(owner, file.id);
expect(await repository.purge(owner, file.id)).toEqual([file.id]);
await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [] });
expect(
await db.selectFrom('file_vault_delete').select('id').where('id', '=', file.id).executeTakeFirst(),
).toBeDefined();
await rm(path, { recursive: true });
expect(await repository.flushDeletes(owner)).toEqual([]);
expect(
await db.selectFrom('file_vault_delete').select('id').where('id', '=', file.id).executeTakeFirst(),
).toBeUndefined();
});
it('cleans incomplete uploads without publishing catalog entries', async () => {
const input = Readable.from(
(function* () {
yield Buffer.from('partial');
throw new Error('connection interrupted');
})(),
);
await expect(repository.upload(owner, 'interrupted.zip', null, input)).rejects.toThrow('connection interrupted');
await expect(list()).resolves.toMatchObject({ entries: [] });
expect(await readdir(join(root, 'files', owner))).toEqual([]);
});
it('enforces quota and includes files when recalculating account usage', async () => {
await db.updateTable('user').set({ quotaSizeInBytes: 10 }).where('id', '=', owner).execute();
await expect(upload('large.zip', null, Buffer.alloc(11))).rejects.toMatchObject({ code: 'quota' });
await upload('small.zip', null, Buffer.alloc(8));
const { ctx } = newMediumService(BaseService, { database: db, real: [], mock: [LoggingRepository] });
await ctx.get(UserRepository).syncUsage(owner);
await expect(quota()).resolves.toBe(8);
await expect(upload('another.zip', null, Buffer.alloc(3))).rejects.toMatchObject({ code: 'quota' });
});
it('does not create or move folders beyond the supported nesting depth', async () => {
const ids = Array.from({ length: 127 }, () => randomUUID());
await db
.insertInto('file_vault')
.values(
ids.map((id, index) => ({
id,
ownerId: owner,
parentId: index === 0 ? null : ids[index - 1],
name: `Level ${index}`,
isFolder: true,
checksum: null,
deletedAt: null,
trashRootId: null,
})),
)
.execute();
const boundary = await repository.createFolder(owner, 'Boundary', ids.at(-1)!);
await expect(repository.createFolder(owner, 'Too deep', boundary.id)).rejects.toMatchObject({ code: 'invalid' });
const folder = await repository.createFolder(owner, 'Root project', null);
await repository.createFolder(owner, 'Child', folder.id);
await expect(repository.update(owner, folder.id, { parentId: ids.at(-1)! })).rejects.toMatchObject({
code: 'invalid',
});
});
});