Files
immich-folio/lib/__tests__/immich.test.ts
Ralksta d4d8c8b42f 🛡️ Sentinel: [HIGH] Fix timingSafeEqual DoS Vulnerability (#423)
* Fix timingSafeEqual DoS Vulnerability in Webhook

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

* Fix memory exhaustion DoS in token decoding

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-14 11:10:42 +02:00

722 lines
27 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
import type { AlbumSortMode } from '../albumSort';
// Per-album sort is read on every getAlbum(), so the tests below need to be
// able to change it between calls without rebuilding the whole mock.
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: ['album-1', 'album-2'],
standaloneAlbums: ['album-2', 'album-1'],
subpages: [],
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
albumSortModes,
albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL.
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
}),
};
});
// Mock fetch globally
global.fetch = vi.fn();
describe('ImmichClient', () => {
const mockFetch = global.fetch as any;
beforeEach(() => {
vi.clearAllMocks();
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
});
describe('request()', () => {
it('returns data on successful JSON response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ foo: 'bar' }),
});
// Access private method via any casting for testing
const result = await (immich as any).request('/test');
expect(result).toEqual({ foo: 'bar' });
});
it('returns null on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
const result = await (immich as any).request('/test');
expect(result).toBeNull();
});
// A null return means "Immich answered: this does not exist" and the page
// turns it into notFound(). Every other failure must be distinguishable,
// or an outage renders a hard 404 for albums that do exist.
describe('distinguishes "gone" from "unavailable"', () => {
it('returns null for 410 Gone', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 410, statusText: 'Gone' });
await expect((immich as any).request('/test')).resolves.toBeNull();
});
it('throws on a 5xx instead of reporting the resource as missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
});
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws on a rejected API key rather than 404-ing every album', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws when the network is unreachable', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws when a gateway returns an HTML error page instead of JSON', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'text/html' },
json: async () => ({}),
});
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('carries the upstream status for logging', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 502, statusText: 'Bad Gateway' });
await expect((immich as any).request('/test')).rejects.toMatchObject({ status: 502 });
});
});
});
// Without a budget the only ceiling is undici's default, measured in minutes.
// Every page is force-dynamic, so an Immich that accepts connections but never
// answers would hold each visitor's render open for that whole window.
describe('timeouts', () => {
const timeoutError = () => {
const err = new Error('The operation was aborted due to timeout');
err.name = 'TimeoutError';
return err;
};
it('sends an abort signal with every JSON request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({}),
});
await (immich as any).request('/test');
expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('reports a timed-out request as unavailable, not as a missing resource', async () => {
mockFetch.mockRejectedValueOnce(timeoutError());
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('names the budget in the error so a misconfigured timeout is diagnosable', async () => {
mockFetch.mockRejectedValueOnce(timeoutError());
await expect((immich as any).request('/test')).rejects.toThrow(/15000ms/);
});
// The stream methods deliberately use a headers-only timeout. A whole-request
// timeout would truncate a large original photo or a long video mid-transfer.
it('stops the clock once stream headers arrive, so a slow body is not truncated', async () => {
vi.useFakeTimers();
try {
let captured: AbortSignal | undefined;
mockFetch.mockImplementation(async (_url: string, init: { signal?: AbortSignal }) => {
captured = init.signal;
return {
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: () => null },
};
});
const result = await immich.streamAsset('asset-1');
expect(result).not.toBeNull();
// Far beyond the 15s budget: a whole-request timeout would abort here
// and kill an in-progress download.
vi.advanceTimersByTime(60 * 60 * 1000);
expect(captured?.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('still aborts a stream that never returns headers', async () => {
vi.useFakeTimers();
try {
let captured: AbortSignal | undefined;
mockFetch.mockImplementation(
(_url: string, init: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
captured = init.signal;
init.signal?.addEventListener('abort', () => reject(timeoutError()));
}),
);
const pending = immich.streamAsset('asset-1');
// Attach the rejection handler *before* advancing timers: the abort
// fires during advanceTimersByTimeAsync, and a promise that rejects
// with no handler yet attached is reported as an unhandled rejection,
// which fails the whole vitest run.
const rejects = expect(pending).rejects.toBeInstanceOf(ImmichUnavailableError);
await vi.advanceTimersByTimeAsync(15001);
await rejects;
expect(captured?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
// The image/video routes serve these URLs with `immutable` on success and turn
// a null into a 404. A bare 404 is heuristically cacheable, so an outage that
// looked like "missing" could pin a broken image in the browser cache.
describe('stream failures distinguish gone from unavailable', () => {
const streamRes = (status: number) => ({
ok: status >= 200 && status < 300,
status,
body: null,
headers: { get: () => null },
});
it('streamAsset returns null for a genuinely missing asset', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
});
it('streamAsset throws when Immich errors rather than reporting it missing', async () => {
mockFetch.mockResolvedValueOnce(streamRes(500));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamAsset throws when the network is unreachable', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamAsset throws on a 200 with no body', async () => {
mockFetch.mockResolvedValueOnce(streamRes(200));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamVideo returns null for a genuinely missing video', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
});
it('streamVideo throws when Immich errors', async () => {
mockFetch.mockResolvedValueOnce(streamRes(502));
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamVideo still accepts a 206 partial response for seeking', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 206,
body: new ReadableStream(),
headers: { get: () => null },
});
const result = await immich.streamVideo('asset-1', 'bytes=0-1023');
expect(result?.status).toBe(206);
});
});
// Measured before the fix: 5 sequential lookups of a missing asset produced 5
// upstream fetches. The homepage resolves every gallery.yaml hero ID on each
// render and is force-dynamic, so a hero photo deleted from Immich cost one
// 404 round-trip per page view, indefinitely.
describe('negative caching', () => {
const notFound = { ok: false, status: 404, statusText: 'Not Found' };
it('queries Immich once for a repeatedly-requested missing asset', async () => {
mockFetch.mockResolvedValue(notFound);
for (let i = 0; i < 5; i++) {
await expect(immich.getAssetInfo('missing-asset')).resolves.toBeNull();
}
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('queries Immich once for a repeatedly-requested missing album', async () => {
mockFetch.mockResolvedValue(notFound);
for (let i = 0; i < 3; i++) {
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
}
// One album request + one metadata-search request, from the first call only.
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('does not cache an outage, which would outlive the recovery', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 503, statusText: 'Unavailable' });
await expect(immich.getAssetInfo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
// Immich recovers: the very next call must reach it, not replay the failure.
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ id: 'asset-1' }),
});
await expect(immich.getAssetInfo('asset-1')).resolves.toMatchObject({ id: 'asset-1' });
});
it('a cached absence is cleared by invalidation, so a fix takes effect at once', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAssetInfo('asset-1')).resolves.toBeNull();
immich.invalidateAll();
mockFetch.mockResolvedValue({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ id: 'asset-1' }),
});
await expect(immich.getAssetInfo('asset-1')).resolves.toMatchObject({ id: 'asset-1' });
});
});
// The regression this whole error type exists to prevent.
describe('outage handling', () => {
it('getAlbum propagates an outage instead of returning null', async () => {
// Not mockResolvedValueOnce: getAlbum fires the album and metadata-search
// requests concurrently, so both need the failing response.
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbum('album-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('getAlbums propagates an outage instead of returning an empty list', async () => {
// Returning [] here would render the homepage as "this gallery has no
// albums" while Immich is merely down.
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbums()).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('a failed album does not leave a stale entry in the in-flight map', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbum('album-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
// A stranded pending promise would make every later call replay the
// failure forever, even after Immich recovers.
expect((immich as any).pendingAlbumPromises.has('album-1')).toBe(false);
expect((immich as any).pendingAlbumsPromise).toBeNull();
});
it('ping reports unreachable as false rather than throwing', async () => {
// /api/health and /api/admin/status render this as a status field.
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.ping()).resolves.toBe(false);
});
});
describe('getAlbums()', () => {
it('filters and overrides albums based on config', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{ id: 'album-1', albumName: 'Original Name', assetCount: 10 },
{ id: 'album-3', albumName: 'Not Allowed', assetCount: 5 },
],
});
const albums = await immich.getAlbums();
expect(albums).toHaveLength(1);
expect(albums[0].id).toBe('album-1');
expect(albums[0].albumName).toBe('Override Name');
expect(albums[0].slug).toBe('override-name');
});
});
describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order;
// the config lists album-2 first, so that must win on the homepage.
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{ id: 'album-1', albumName: 'First in API', assetCount: 1 },
{ id: 'album-2', albumName: 'Second in API', assetCount: 1 },
],
});
const albums = await immich.getStandaloneAlbums();
expect(albums.map((a) => a.id)).toEqual(['album-2', 'album-1']);
});
});
describe('getAlbum()', () => {
it('returns null for unallowed album IDs', async () => {
const album = await immich.getAlbum('album-3');
expect(album).toBeNull();
});
/**
* Immich 3.x returns an empty `assets` array from GET /albums/:id, so
* assets come from a separate POST /search/metadata call. Route the mock by
* URL rather than by call order — the two run concurrently.
*/
function mockAlbumApi(options: {
order?: 'asc' | 'desc';
pages?: Array<{ items: unknown[]; nextPage: string | null }>;
}) {
const pages = options.pages ?? [];
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.includes('/search/metadata')) {
const page = JSON.parse(init?.body ?? '{}').page ?? 1;
const p = pages[page - 1] ?? { items: [], nextPage: null };
return json({ assets: { items: p.items, nextPage: p.nextPage, total: p.items.length } });
}
// Mirrors the real 3.x response: assetCount set, assets empty.
return json({
id: 'album-1',
albumName: 'Name',
assetCount: 2,
assets: [],
order: options.order ?? 'desc',
});
});
}
const asset = (id: string, date: string, isTrashed = false) => ({
id,
isTrashed,
fileCreatedAt: date,
});
it('loads assets via metadata search and filters trashed ones', async () => {
mockAlbumApi({
pages: [
{
items: [
asset('asset-1', '2024-01-02T00:00:00Z'),
asset('asset-2', '2024-01-03T00:00:00Z', true),
],
nextPage: null,
},
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets).toHaveLength(1);
expect(album?.assets[0].id).toBe('asset-1');
});
it('requests EXIF data — without it the grid, lightbox and map go blank', async () => {
mockAlbumApi({ pages: [{ items: [], nextPage: null }] });
await immich.getAlbum('album-1');
const search = mockFetch.mock.calls.find((c: unknown[]) =>
String(c[0]).includes('/search/metadata'),
);
expect(search).toBeDefined();
const body = JSON.parse((search[1] as { body: string }).body);
expect(body.withExif).toBe(true);
expect(body.albumIds).toEqual(['album-1']);
expect(search[1].method).toBe('POST');
});
it('follows pagination until nextPage is null', async () => {
mockAlbumApi({
pages: [
{ items: [asset('a', '2024-01-01T00:00:00Z')], nextPage: '2' },
{ items: [asset('b', '2024-01-02T00:00:00Z')], nextPage: '3' },
{ items: [asset('c', '2024-01-03T00:00:00Z')], nextPage: null },
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']); // desc by fileCreatedAt
});
it('honours the album sort order', async () => {
const items = [
asset('old', '2024-01-01T00:00:00Z'),
asset('new', '2024-06-01T00:00:00Z'),
asset('mid', '2024-03-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
let album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['old', 'mid', 'new']);
cache.clear(); // same album id — otherwise the asc result is reused
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['new', 'mid', 'old']);
});
/**
* Immich's timeline sorts on localDateTime, not on the UTC instant. A
* 09:00 shot in Tokyo and a 09:00 shot in Berlin sit next to each other in
* the Immich UI even though they are eight hours apart in UTC.
*/
it('sorts by local capture time, matching the Immich timeline', async () => {
const items = [
{
id: 'berlin',
isTrashed: false,
localDateTime: '2024-05-01T09:00:00.000Z',
fileCreatedAt: '2024-05-01T07:00:00.000Z',
},
{
id: 'tokyo',
isTrashed: false,
localDateTime: '2024-05-01T08:00:00.000Z',
fileCreatedAt: '2024-04-30T23:00:00.000Z',
},
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
// UTC order would be tokyo → berlin; local capture order is the reverse.
expect(album?.assets.map((a) => a.id)).toEqual(['tokyo', 'berlin']);
});
it('falls back to fileCreatedAt when localDateTime is missing', async () => {
const items = [
asset('no-local-late', '2024-01-03T00:00:00Z'),
{
id: 'has-local',
isTrashed: false,
localDateTime: '2024-01-02T00:00:00Z',
fileCreatedAt: '2024-01-02T00:00:00Z',
},
asset('no-local-early', '2024-01-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
// Missing localDateTime sorts as 0, so those two lead — but they stay
// ordered among themselves by fileCreatedAt rather than at random.
expect(album?.assets.map((a) => a.id)).toEqual([
'no-local-early',
'no-local-late',
'has-local',
]);
});
/**
* Date.parse on an unusable value yields NaN, and a comparator returning
* NaN leaves sort() free to produce any permutation — including dropping
* the valid assets out of order.
*/
it('keeps a coherent order when a date is unparseable', async () => {
const items = [
asset('broken', 'not-a-date'),
asset('late', '2024-01-03T00:00:00Z'),
asset('early', '2024-01-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
const ids = album?.assets.map((a) => a.id) ?? [];
expect(ids).toHaveLength(3);
expect(ids.indexOf('early')).toBeLessThan(ids.indexOf('late'));
});
it('preserves sub-second precision within a burst', async () => {
const items = [
asset('frame-2', '2024-01-01T14:36:53.500Z'),
asset('frame-3', '2024-01-01T14:36:53.900Z'),
asset('frame-1', '2024-01-01T14:36:53.000Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
});
/**
* The configured sort is applied on the way out of getAlbum(), so the LRU
* keeps one canonical Immich-ordered copy per album. Sorting the cached
* array in place would reorder the shared entry, and the next reader with a
* different mode would then be sorting an already-sorted array.
*/
describe('per-album sort', () => {
const items = [
{ ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' },
{ ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' },
{ ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' },
];
it('overrides the Immich direction', async () => {
albumSortModes['album-1'] = 'oldest';
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']);
});
it('applies the manual pin order', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['c'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']);
});
it('leaves the cached album in canonical order', async () => {
albumSortModes['album-1'] = 'filename';
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const first = await immich.getAlbum('album-1');
expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10
// Same cache entry, different mode: if the first call had sorted the
// stored array, the album would still be in filename order here.
albumSortModes['album-1'] = 'newest';
const second = await immich.getAlbum('album-1');
expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']);
expect(second?.assets).not.toBe(first?.assets);
});
it('gives concurrent callers independent arrays', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['b'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
// Coalesced through the same pending promise, but each caller applies
// the sort itself — so neither can be handed the other's array.
const [one, two] = await Promise.all([
immich.getAlbum('album-1'),
immich.getAlbum('album-1'),
]);
expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(one?.assets).not.toBe(two?.assets);
});
});
});
});
describe('stale fallback when Immich is unavailable', () => {
const mockFetch = global.fetch as any;
beforeEach(() => {
vi.clearAllMocks();
cache.clear();
});
it('serves the previously cached album list instead of throwing', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{
id: 'album-1',
albumName: 'Original',
description: '',
albumThumbnailAssetId: null,
assetCount: 1,
assets: [],
createdAt: '',
updatedAt: '',
order: 'desc',
},
],
});
const fresh = await immich.getAlbums();
expect(fresh).toHaveLength(1);
vi.useFakeTimers();
// Strictly past cacheTtl (60_000ms) so cache.get()'s fresh-only check
// misses and the fallback path — not a lucky cache hit — is what's tested.
vi.advanceTimersByTime(60_001);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const stale = await immich.getAlbums();
vi.useRealTimers();
expect(stale).toHaveLength(1);
expect(stale[0].id).toBe('album-1');
});
it('still throws when there is nothing cached to fall back to', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
});
it('does not swallow a definitive 404 as an outage', async () => {
// A missing album must keep reporting missing, not fall back to stale data.
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
headers: { get: () => 'application/json' },
});
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
});
it('still refuses albums outside the allowlist', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
});
});