Files
TREK/server/tests/unit/nest/memories-access.service.test.ts
T
Maurice c0d90d57b2 refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
2026-08-08 22:27:56 +02:00

456 lines
19 KiB
TypeScript

/**
* Unit tests for the memories helpers and access checks — MEM-HELPERS-001 to 020.
* Moved with the fold: the pure half is nest/memories/memories.helpers.ts, the
* DB-backed half is MemoriesAccessService.
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
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 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-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { mockSafeFetch } = vi.hoisted(() => ({
mockSafeFetch: vi.fn(),
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(msg: string) { super(msg); this.name = 'SsrfBlockedError'; }
}
return {
safeFetch: mockSafeFetch,
SsrfBlockedError,
checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
const access = new MemoriesAccessService(new DatabaseService(testDb));
const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access);
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
mockSafeFetch.mockReset();
});
afterAll(() => {
testDb.close();
});
// ── mapDbError ────────────────────────────────────────────────────────────────
describe('mapDbError', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
const err = new Error('UNIQUE constraint failed: users.email');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
expect(result.error.message).toBe('Resource already exists');
});
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
const err = new Error('constraint violation');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
});
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
const err = new Error('Something went wrong');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(500);
expect(result.error.message).toBe('Something went wrong');
});
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
const err = new Error('disk I/O error');
const result = mapDbError(err, 'fallback');
expect(result.error.status).toBe(500);
});
});
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
describe('getAlbumIdFromLink', () => {
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
const result = getAlbumIdFromLink('9999', 'link-1', 1);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
});
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(result.error.message).toBe('Album link not found');
});
it('MEM-HELPERS-007: returns album_id when link exists', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Insert with auto-increment id (INTEGER PRIMARY KEY)
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const linkId = ins.lastInsertRowid;
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
expect(result.success).toBe(true);
expect((result as any).data).toBe('album-123');
});
});
// ── pipeAsset ─────────────────────────────────────────────────────────────────
describe('pipeAsset', () => {
function mockResponse(overrides: Record<string, any> = {}) {
return {
status: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
end: vi.fn(),
json: vi.fn(),
headersSent: false,
...overrides,
} as any;
}
it('MEM-HELPERS-009: calls response.end() when resp.body is null', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: { get: vi.fn(() => null) },
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/asset', res);
expect(res.end).toHaveBeenCalled();
});
it('MEM-HELPERS-010: returns 400 when SsrfBlockedError is thrown', async () => {
mockSafeFetch.mockRejectedValue(new SsrfBlockedError('SSRF blocked'));
const res = mockResponse({ headersSent: false });
await pipeAsset('https://internal.example.com/asset', res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
});
it('MEM-HELPERS-011: returns 500 for generic fetch error', async () => {
mockSafeFetch.mockRejectedValue(new Error('Network error'));
const res = mockResponse({ headersSent: false });
await pipeAsset('https://example.com/asset', res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Failed to fetch asset' });
});
it('MEM-HELPERS-012: calls response.end() when headersSent is true on error', async () => {
mockSafeFetch.mockRejectedValue(new Error('fail'));
const res = mockResponse({ headersSent: true });
await pipeAsset('https://example.com/asset', res);
expect(res.end).toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('MEM-HELPERS-013: sets content-type header when present in response', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: {
get: (h: string) => {
if (h === 'content-type') return 'image/jpeg';
return null;
},
},
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/img.jpg', res);
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
expect(res.end).toHaveBeenCalled();
});
});
// ── pipeAsset fetch options (#1611) ───────────────────────────────────────────
describe('pipeAsset fetch options (#1611)', () => {
function mockResponse(overrides: Record<string, any> = {}) {
return {
status: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
end: vi.fn(),
json: vi.fn(),
headersSent: false,
...overrides,
} as any;
}
it('MEM-HELPERS-021: forwards fetchOptions to safeFetch', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: { get: vi.fn(() => null) },
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/asset', res, undefined, undefined, undefined, { rejectUnauthorized: false });
expect(mockSafeFetch).toHaveBeenCalledWith(
'https://example.com/asset',
expect.anything(),
{ rejectUnauthorized: false },
);
});
it('MEM-HELPERS-022: omitting fetchOptions leaves safeFetch options undefined', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: { get: vi.fn(() => null) },
body: null,
});
const res = mockResponse();
await pipeAsset('https://example.com/asset', res);
expect(mockSafeFetch.mock.calls[0][2]).toBeUndefined();
});
it('MEM-HELPERS-023: logs the underlying error when responding 500', async () => {
const boom = new Error('unable to verify the first certificate');
mockSafeFetch.mockRejectedValue(boom);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const res = mockResponse({ headersSent: false });
await pipeAsset('https://example.com/asset', res);
expect(res.status).toHaveBeenCalledWith(500);
expect(errorSpy).toHaveBeenCalledWith(expect.any(String), boom);
errorSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// The access checks
//
// These decide who may fetch someone else's provider photo, and they had no
// cases at all — they sat outside the measured tree. A provider asset is
// reachable through a trip *or* a journey, which is why neither check can live
// in the trips or the journey domain alone, and why both need pinning here.
// ---------------------------------------------------------------------------
/** A trek_photos row plus the trip_photos link that shares it. */
function shareInTrip(tripId: number, ownerId: number, assetId: string, provider = 'immich', shared = 1): number {
const photoId = Number(testDb.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, ownerId).lastInsertRowid);
testDb.prepare('INSERT INTO trip_photos (trip_id, photo_id, user_id, shared) VALUES (?, ?, ?, ?)')
.run(tripId, photoId, ownerId, shared);
return photoId;
}
function makeJourney(userId: number): number {
return Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)"
).run(userId).lastInsertRowid);
}
describe('canAccessUserPhoto', () => {
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => {
expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
});
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
});
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0);
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
});
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
});
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'same-id', 'immich');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
});
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
});
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-asset-2', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
});
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
});
});
describe('canAccessTrekPhoto', () => {
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => {
expect(access.canAccessTrekPhoto(1, 999999)).toBe(false);
});
it('MEM-ACCESS-011: the owner passes', () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)"
).run(user.id).lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
});
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const photoId = shareInTrip(trip.id, owner.id, 'trek-shared');
expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
});
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => {
const { user: owner } = createUser(testDb);
const { user: uploader } = createUser(testDb, { username: 'uploader' });
const trip = createTrip(testDb, owner.id);
const photoId = shareInTrip(trip.id, uploader.id, 'owner-path');
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
});
it('MEM-ACCESS-014: a journey contributor passes', () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'j-trek', ?)"
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
});
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const journeyId = makeJourney(owner.id);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'journey/x.jpg')"
).run().lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
});
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')"
).run().lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
});
});