refactor(server): replace node-fetch with native fetch + undici, fix photo integrations

Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.

Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
  instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
  also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
  total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
  a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
This commit is contained in:
jubnl
2026-04-05 21:11:43 +02:00
parent f3679739d8
commit 5cc81ae4b0
30 changed files with 1685 additions and 549 deletions

View File

@@ -1,6 +1,6 @@
/**
* Immich integration tests.
* Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test).
* Covers IMMICH-001 to IMMICH-024 (settings, SSRF protection, album links).
*
* External Immich API calls are not made — tests focus on settings persistence
* and input validation.
@@ -60,6 +60,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
}),
safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unit tests')),
};
});
@@ -89,43 +90,43 @@ afterAll(() => {
});
describe('Immich settings', () => {
it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => {
it('IMMICH-001 — GET /api/integrations/memories/immich/settings returns current settings', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/integrations/immich/settings')
.get('/api/integrations/memories/immich/settings')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
// Settings may be empty initially
expect(res.body).toBeDefined();
});
it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => {
it('IMMICH-001 — PUT /api/integrations/memories/immich/settings saves Immich URL and API key', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.put('/api/integrations/memories/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => {
it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with private IP is blocked by SSRF guard', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.put('/api/integrations/memories/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' });
expect(res.status).toBe(400);
});
it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => {
it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with loopback is blocked', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/integrations/immich/settings')
.put('/api/integrations/memories/immich/settings')
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' });
expect(res.status).toBe(400);
@@ -133,14 +134,14 @@ describe('Immich settings', () => {
});
describe('Immich authentication', () => {
it('GET /api/immich/settings without auth returns 401', async () => {
const res = await request(app).get('/api/integrations/immich/settings');
it('GET /api/integrations/memories/immich/settings without auth returns 401', async () => {
const res = await request(app).get('/api/integrations/memories/immich/settings');
expect(res.status).toBe(401);
});
it('PUT /api/immich/settings without auth returns 401', async () => {
it('PUT /api/integrations/memories/immich/settings without auth returns 401', async () => {
const res = await request(app)
.put('/api/integrations/immich/settings')
.put('/api/integrations/memories/immich/settings')
.send({ url: 'https://example.com', api_key: 'key' });
expect(res.status).toBe(401);
});
@@ -152,9 +153,9 @@ describe('Immich album links', () => {
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any;
const res = await request(app)
.post(`/api/integrations/immich/trips/${trip.id}/album-links`)
.post(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
.set('Cookie', authCookie(user.id))
.send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024' });
.send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024', provider: 'immich' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
@@ -171,7 +172,7 @@ describe('Immich album links', () => {
testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)').run(trip.id, user.id, 'album-abc', 'My Album', 'immich');
const res = await request(app)
.get(`/api/integrations/immich/trips/${trip.id}/album-links`)
.get(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
@@ -196,7 +197,7 @@ describe('Immich album links', () => {
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich');
const res = await request(app)
.delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`)
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
@@ -212,7 +213,7 @@ describe('Immich album links', () => {
expect(link).toBeUndefined();
});
it('IMMICH-023 — DELETE album-link by non-owner is a no-op', async () => {
it('IMMICH-023 — DELETE album-link by non-member returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(owner.id, 'Test Trip') as any;
@@ -221,12 +222,12 @@ describe('Immich album links', () => {
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id);
// Other user tries to delete owner's album link
// Non-member tries to delete owner's album link — should be denied
const res = await request(app)
.delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`)
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(200); // endpoint returns 200 even when no row matched
expect(res.status).toBe(404);
// Link and photos should still exist
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
@@ -236,7 +237,7 @@ describe('Immich album links', () => {
});
it('IMMICH-024 — DELETE album-link without auth returns 401', async () => {
const res = await request(app).delete('/api/integrations/immich/trips/1/album-links/1');
const res = await request(app).delete('/api/integrations/memories/unified/trips/1/album-links/1');
expect(res.status).toBe(401);
});
});