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
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
/**
|
||
* Unified Memories integration tests (UNIFIED-001 – UNIFIED-020).
|
||
* Covers the provider-agnostic /unified/trips/:tripId/photos and
|
||
* /unified/trips/:tripId/album-links routes.
|
||
*
|
||
* No real HTTP is made — safeFetch is mocked to never be called.
|
||
* The broadcast WebSocket call is no-op mocked.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import request from 'supertest';
|
||
import type { Application } from 'express';
|
||
|
||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: () => null,
|
||
canAccessTrip: (tripId: any, userId: number) =>
|
||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||
isOwner: (tripId: any, userId: number) =>
|
||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../src/db/database', () => dbMock);
|
||
vi.mock('../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||
return {
|
||
...actual,
|
||
checkSsrf: vi.fn().mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }),
|
||
safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unified tests')),
|
||
};
|
||
});
|
||
|
||
import { createApp } from '../../src/app';
|
||
import { createTables } from '../../src/db/schema';
|
||
import { runMigrations } from '../../src/db/migrations';
|
||
import { resetTestDb } from '../helpers/test-db';
|
||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||
import { authCookie } from '../helpers/auth';
|
||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||
|
||
const app: Application = createApp();
|
||
|
||
const BASE = '/api/integrations/memories/unified';
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
loginAttempts.clear();
|
||
mfaAttempts.clear();
|
||
});
|
||
|
||
afterAll(() => testDb.close());
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function photosUrl(tripId: number) { return `${BASE}/trips/${tripId}/photos`; }
|
||
function albumLinksUrl(tripId: number, linkId?: number) {
|
||
return linkId ? `${BASE}/trips/${tripId}/album-links/${linkId}` : `${BASE}/trips/${tripId}/album-links`;
|
||
}
|
||
|
||
// ── Unified Photo Management ─────────────────────────────────────────────────
|
||
|
||
describe('Unified photo management', () => {
|
||
it('UNIFIED-001 — GET photos lists own + shared photos from other members', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
|
||
// owner has a private photo; member has a shared photo
|
||
addTripPhoto(testDb, trip.id, owner.id, 'asset-own', 'immich', { shared: false });
|
||
addTripPhoto(testDb, trip.id, member.id, 'asset-shared', 'immich', { shared: true });
|
||
|
||
const res = await request(app)
|
||
.get(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(owner.id));
|
||
|
||
expect(res.status).toBe(200);
|
||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||
expect(ids).toContain('asset-own');
|
||
expect(ids).toContain('asset-shared');
|
||
});
|
||
|
||
it('UNIFIED-002 — GET photos excludes other members\' private photos', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
|
||
addTripPhoto(testDb, trip.id, member.id, 'asset-private', 'immich', { shared: false });
|
||
|
||
const res = await request(app)
|
||
.get(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(owner.id));
|
||
|
||
expect(res.status).toBe(200);
|
||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||
expect(ids).not.toContain('asset-private');
|
||
});
|
||
|
||
it('UNIFIED-003 — GET photos returns 404 for non-member', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: stranger } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
|
||
const res = await request(app)
|
||
.get(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(stranger.id));
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('UNIFIED-004 — POST photos adds photos from selections', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
const res = await request(app)
|
||
.post(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({
|
||
shared: true,
|
||
selections: [{ provider: 'immich', asset_ids: ['asset-a', 'asset-b'] }],
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.added).toBe(2);
|
||
|
||
const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
|
||
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||
});
|
||
|
||
it('UNIFIED-005 — POST photos with empty selections returns 400', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
const res = await request(app)
|
||
.post(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ selections: [] });
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('UNIFIED-006 — POST photos with invalid provider returns 400', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
const res = await request(app)
|
||
.post(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ selections: [{ provider: 'nonexistent', asset_ids: ['asset-x'] }] });
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('UNIFIED-007 — PUT photos/sharing toggles shared flag', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
|
||
|
||
const res = await request(app)
|
||
.put(`${photosUrl(trip.id)}/sharing`)
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ provider: 'immich', asset_id: 'asset-tog', shared: true });
|
||
|
||
expect(res.status).toBe(200);
|
||
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any;
|
||
expect(row.shared).toBe(1);
|
||
});
|
||
|
||
it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: stranger } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
|
||
const res = await request(app)
|
||
.put(`${photosUrl(trip.id)}/sharing`)
|
||
.set('Cookie', authCookie(stranger.id))
|
||
.send({ provider: 'immich', asset_id: 'any', shared: true });
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('UNIFIED-009 — DELETE photos removes own photo', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
|
||
|
||
const res = await request(app)
|
||
.delete(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ provider: 'immich', asset_id: 'asset-del' });
|
||
|
||
expect(res.status).toBe(200);
|
||
const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del');
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: stranger } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
|
||
const res = await request(app)
|
||
.delete(photosUrl(trip.id))
|
||
.set('Cookie', authCookie(stranger.id))
|
||
.send({ provider: 'immich', asset_id: 'any' });
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
// ── Unified Album-Link Management ────────────────────────────────────────────
|
||
|
||
describe('Unified album-link management', () => {
|
||
it('UNIFIED-011 — POST album-links with missing provider returns 400', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
const res = await request(app)
|
||
.post(albumLinksUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ album_id: 'album-abc', album_name: 'Test' }); // no provider
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('UNIFIED-012 — POST album-links with missing album_id returns 400', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
const res = await request(app)
|
||
.post(albumLinksUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ provider: 'immich', album_name: 'Test' }); // no album_id
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('UNIFIED-013 — POST album-links duplicate link returns 409', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
|
||
await request(app)
|
||
.post(albumLinksUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
|
||
|
||
const res = await request(app)
|
||
.post(albumLinksUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id))
|
||
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
|
||
|
||
expect(res.status).toBe(409);
|
||
});
|
||
|
||
it('UNIFIED-014 — GET album-links only returns links for enabled providers', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled');
|
||
|
||
// Disable the immich provider
|
||
testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich');
|
||
|
||
const res = await request(app)
|
||
.get(albumLinksUrl(trip.id))
|
||
.set('Cookie', authCookie(user.id));
|
||
|
||
// Re-enable for future tests
|
||
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
|
||
|
||
expect(res.status).toBe(400); // no providers enabled → error
|
||
});
|
||
});
|
||
|
||
// ── Auth checks ───────────────────────────────────────────────────────────────
|
||
|
||
describe('Unified auth checks', () => {
|
||
it('UNIFIED-020 — GET photos without auth returns 401', async () => {
|
||
const res = await request(app).get(`${BASE}/trips/1/photos`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — POST photos without auth returns 401', async () => {
|
||
const res = await request(app).post(`${BASE}/trips/1/photos`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — PUT photos/sharing without auth returns 401', async () => {
|
||
const res = await request(app).put(`${BASE}/trips/1/photos/sharing`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — DELETE photos without auth returns 401', async () => {
|
||
const res = await request(app).delete(`${BASE}/trips/1/photos`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — GET album-links without auth returns 401', async () => {
|
||
const res = await request(app).get(`${BASE}/trips/1/album-links`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — POST album-links without auth returns 401', async () => {
|
||
const res = await request(app).post(`${BASE}/trips/1/album-links`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('UNIFIED-020 — DELETE album-links without auth returns 401', async () => {
|
||
const res = await request(app).delete(`${BASE}/trips/1/album-links/1`);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
});
|