Files
immich-folio/app/api/download/[album]/[id]/__tests__/route.test.ts
2026-10-02 18:14:23 +02:00

186 lines
6.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import {
LAT,
cameraHeic,
cameraJpeg,
chunked,
contains,
exifOf,
heicExif,
latin1,
parseTiff,
asciiOf,
} from '@/lib/__tests__/fixtures/location';
/**
* The single-original download serves the file without its location: GPS is
* removed from JPEG/HEIC/AVIF on the way through, camera data stays, and the
* length (and so Content-Length) does not change.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn() }));
vi.mock('@/lib/tokens', () => ({ decodeAssetId: vi.fn((t: string) => t.replace('tok-', '')) }));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumReachable: vi.fn(() => true),
}));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET } from '../route';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ALBUM = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_0001.JPG' }],
};
function original(data: Uint8Array, contentType: string) {
return {
stream: chunked(data, 1000),
contentType,
contentLength: String(data.length),
};
}
const download = () =>
GET(new NextRequest('http://localhost/api/download/tok-album/tok-asset'), {
params: Promise.resolve({ album: 'tok-album', id: 'tok-asset' }),
});
beforeEach(() => {
vi.clearAllMocks();
mockConfig.mockReturnValue({ albums: ['album'], albumDownloads: { album: true } });
mockGetAlbum.mockResolvedValue(ALBUM);
});
describe('GET /api/download/[album]/[id]', () => {
it('serves a JPEG without GPS, camera data and length intact', async () => {
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(res.headers.get('content-length')).toBe(String(file.length));
expect(res.headers.get('content-disposition')).toContain('IMG_0001.JPG');
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(exifOf(body)!);
expect(tiff.gps).toBeNull();
expect(tiff.ifd0.has(0x8825)).toBe(false);
expect(asciiOf(tiff.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(contains(body, LAT.value(true))).toBe(false);
expect(latin1(body)).not.toMatch(/exif:GPS/);
expect(latin1(body)).toContain('photoshop:City="Berlin"');
});
it('serves a HEIC without GPS', async () => {
const { file } = cameraHeic();
mockStream.mockResolvedValue(original(file, 'image/heic'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(heicExif(body)!);
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x010f))).toBe('Canon');
});
it('passes a format it does not edit through unchanged', async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
mockStream.mockResolvedValue(original(png, 'image/png'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body).toEqual(png);
});
it('refuses a JPEG whose metadata it cannot locate', async () => {
mockStream.mockResolvedValue({
stream: new ReadableStream({
start(controller) {
// SOI, then an APP1 claiming more bytes than the file has.
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]));
controller.close();
},
}),
contentType: 'image/jpeg',
contentLength: '8',
});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
describe('a photo edited in Immich (#831)', () => {
const EDITED = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true }],
};
it('serves the edited rendition, named as the JPEG it is, without GPS', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
// Immich's edited rendition carries no EXIF; one that did must still
// lose its GPS on the way through.
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith('asset', 'original', true);
expect(res.headers.get('content-type')).toBe('image/jpeg');
expect(res.headers.get('content-disposition')).toContain('IMG_9262.jpg');
expect(res.headers.get('content-disposition')).not.toContain('HEIC');
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
expect(contains(body, LAT.value(true))).toBe(false);
});
it('keeps the name of an edited JPEG', async () => {
mockGetAlbum.mockResolvedValue({
...EDITED,
assets: [{ ...EDITED.assets[0], originalFileName: 'scan.jpeg' }],
});
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
const res = await download();
expect(res.headers.get('content-disposition')).toContain('scan.jpeg');
});
it('refuses an edited rendition the scrubber would pass through (review of #832)', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 ');
mockStream.mockResolvedValue(original(webp, 'image/webp'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(warn.mock.calls.some((args) => String(args[0]).includes('Refused edited asset'))).toBe(
true,
);
warn.mockRestore();
});
it('asks for the original of a photo that is not edited exactly as before', async () => {
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
await download();
expect(mockStream).toHaveBeenCalledWith('asset', 'original', false);
});
});
});