Files
TREK/server/tests/unit/nest/unsplash/unsplash.service.test.ts
T

350 lines
16 KiB
TypeScript

import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service';
import { asLegacyResult } from '../../../helpers/domain-error';
import { makeStorageFixture } from '../../../helpers/storage-fixture';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, vi, afterEach } from 'vitest';
// safeFetch is mocked so saveUnsplashCover never hits the network.
// decrypt_api_key is a passthrough so stored values compare as plaintext.
// getUnsplashKey resolves through resolveApiKey (instance-api-keys.ts, Plan 3a
// Task 5), which now reads AppSettingsRepository.getValue (the instance-wide
// row, #1939's precedence winner) and UsersRepository.getApiKeyColumn (the
// caller's own row, the last resort) — mockGetValue/mockGetApiKeyColumn stand
// in for those two repository methods, each its own seam so a test can pin
// exactly which one was (or was not) reached.
const { safeFetch, mockGetValue, mockGetApiKeyColumn } = vi.hoisted(() => ({
safeFetch: vi.fn(),
mockGetValue: vi.fn((..._args: unknown[]) => Promise.resolve(undefined as string | null | undefined)),
mockGetApiKeyColumn: vi.fn((..._args: unknown[]) => Promise.resolve(undefined as string | null | undefined)),
}));
vi.mock('../../../../src/utils/ssrfGuard', () => ({ safeFetch }));
vi.mock('../../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
// Unused by the read path here, but instance-api-keys imports it.
maybe_encrypt_api_key: (v: string | null) => v,
}));
// Same four entry points, now methods. Stub repositories feed getUnsplashKey.
const coverFx = makeStorageFixture('covers/');
const appSettingsStub = { getValue: mockGetValue } as unknown as AppSettingsRepository;
const usersStub = { getApiKeyColumn: mockGetApiKeyColumn } as unknown as UsersRepository;
const svc = new UnsplashService(appSettingsStub, usersStub, new RuntimeEnvService(), coverFx.storage);
const searchUnsplashPhotos = (...args: Parameters<UnsplashService['searchUnsplashPhotos']>) =>
asLegacyResult(svc.searchUnsplashPhotos(...args));
const getUnsplashKey = svc.getUnsplashKey.bind(svc);
const saveUnsplashCover = svc.saveUnsplashCover.bind(svc);
const isUnsplashCoverUrl = svc.isUnsplashCoverUrl.bind(svc);
const KEY_NEEDED =
'Unsplash refused this server. An administrator can add a free Unsplash Access Key in Admin > Settings.';
const ORIGINAL_UNSPLASH_ENV = process.env.UNSPLASH_ACCESS_KEY;
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
mockGetValue.mockResolvedValue(undefined);
mockGetApiKeyColumn.mockResolvedValue(undefined);
if (ORIGINAL_UNSPLASH_ENV === undefined) delete process.env.UNSPLASH_ACCESS_KEY;
else process.env.UNSPLASH_ACCESS_KEY = ORIGINAL_UNSPLASH_ENV;
});
function fakeRes(init: { ok: boolean; status?: number; type?: string; bytes?: number; json?: unknown }): Response {
return {
ok: init.ok,
status: init.status ?? (init.ok ? 200 : 500),
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? (init.type ?? '') : null) },
arrayBuffer: async () => new ArrayBuffer(init.bytes ?? 8),
json: async () => init.json ?? {},
} as unknown as Response;
}
describe('unsplashService.isUnsplashCoverUrl', () => {
it('UNSPLASH-001: accepts only the Unsplash image CDN host', () => {
expect(isUnsplashCoverUrl('https://images.unsplash.com/photo-1?w=1080')).toBe(true);
expect(isUnsplashCoverUrl('https://evil.example.com/x.jpg')).toBe(false);
expect(isUnsplashCoverUrl('/uploads/covers/local.jpg')).toBe(false);
expect(isUnsplashCoverUrl(null)).toBe(false);
expect(isUnsplashCoverUrl(undefined)).toBe(false);
});
});
describe('unsplashService.searchUnsplashPhotos', () => {
it('UNSPLASH-002: rejects an empty query without hitting the network', async () => {
expect(await searchUnsplashPhotos(' ')).toEqual({ error: 'Search query is required', status: 400 });
});
it('UNSPLASH-002a: gives the search request a deadline so a hung upstream cannot pin the caller', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
vi.stubGlobal('fetch', fetchMock);
await searchUnsplashPhotos('paris');
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('UNSPLASH-003: maps a non-ok response to an error', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
fakeRes({ ok: false, status: 429, type: 'application/json', json: { errors: ['Rate limited'] } }),
),
);
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Rate limited', status: 429 });
});
it('UNSPLASH-016: a 200 carrying unparseable JSON becomes a 502, not a crash', async () => {
// The web endpoint answers 200 with an HTML challenge page when it decides the
// caller is a datacenter (#1449), so a parse failure on an ok response is an
// upstream problem, not the caller's: without a key, the key is the way out.
const bad = fakeRes({ ok: true, type: 'text/html' });
(bad as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token < in JSON');
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
expect(await searchUnsplashPhotos('paris')).toMatchObject({ error: KEY_NEEDED, status: 502 });
// With a key the official API answered, so no key is named.
expect(await searchUnsplashPhotos('paris', 9, 'key')).toEqual({
error: 'Unsplash search unavailable',
status: 502,
});
});
it('UNSPLASH-016b: a keyless search turned away with 401 or 403 says the Access Key is the way out (#1449)', async () => {
const refusedWith = (status: number) => {
const refused = fakeRes({ ok: false, status, type: 'text/plain' });
(refused as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token A in JSON');
};
return refused;
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(refusedWith(401)).mockResolvedValueOnce(refusedWith(403)));
const errors = [
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
];
for (const err of errors) {
expect(err).toBeInstanceOf(DomainError);
expect((err as DomainError).getStatus()).toBe(502);
expect(err).toMatchObject({ publicMessage: KEY_NEEDED, code: 'unsplash_key_needed' });
expect((err as DomainError).toBody()).toEqual({ error: KEY_NEEDED, code: 'unsplash_key_needed' });
}
});
it('UNSPLASH-017: a failing response with unparseable JSON keeps its own status', async () => {
const bad = fakeRes({ ok: false, status: 503, type: 'text/html' });
(bad as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token < in JSON');
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 503 });
});
it('UNSPLASH-018: falls back to the generic message when a non-ok body names no error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 500, type: 'application/json', json: {} })),
);
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 500 });
});
it('UNSPLASH-019: uses the single error field when the errors array is absent', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
fakeRes({ ok: false, status: 401, type: 'application/json', json: { error: 'Bad credentials' } }),
),
);
// A key the official API refuses is named by Unsplash itself.
expect(await searchUnsplashPhotos('paris', 9, 'stale-key')).toEqual({ error: 'Bad credentials', status: 401 });
});
it('UNSPLASH-004: returns normalised photos on success and drops entries missing a url/thumb', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
fakeRes({
ok: true,
type: 'application/json',
json: {
results: [
{
id: 'a',
urls: { regular: 'https://images.unsplash.com/a', small: 'https://images.unsplash.com/a-s' },
user: { name: 'Alice' },
links: { html: 'https://unsplash.com/a' },
},
{ id: 'b', urls: {} }, // dropped — no url/thumb
],
},
}),
),
);
const res = (await searchUnsplashPhotos('paris')) as { photos: { id: string }[] };
expect(res.photos).toHaveLength(1);
expect(res.photos[0]).toMatchObject({ id: 'a', photographer: 'Alice', link: 'https://unsplash.com/a' });
});
it('UNSPLASH-010: hits the unauthenticated web endpoint when no access key is given', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
vi.stubGlobal('fetch', fetchMock);
await searchUnsplashPhotos('paris');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('unsplash.com/napi/search/photos');
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
});
it('UNSPLASH-011: hits the official API with a Client-ID header when an access key is given', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } }));
vi.stubGlobal('fetch', fetchMock);
await searchUnsplashPhotos('paris', 9, 'my-access-key');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('api.unsplash.com/search/photos');
expect((init.headers as Record<string, string>).Authorization).toBe('Client-ID my-access-key');
expect((init.headers as Record<string, string>)['Accept-Version']).toBe('v1');
});
});
describe('unsplashService.getUnsplashKey', () => {
it('UNSPLASH-012: prefers the UNSPLASH_ACCESS_KEY env var over any stored key', async () => {
process.env.UNSPLASH_ACCESS_KEY = 'env-key';
mockGetApiKeyColumn.mockResolvedValue('user-key');
expect(await getUnsplashKey(1)).toBe('env-key');
expect(mockGetValue).not.toHaveBeenCalled();
expect(mockGetApiKeyColumn).not.toHaveBeenCalled();
});
it('UNSPLASH-013: returns the user key when set and no env var', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockGetApiKeyColumn.mockResolvedValueOnce('user-key');
expect(await getUnsplashKey(1)).toBe('user-key');
});
it('UNSPLASH-014: the instance-wide key wins over the user own key (#1939)', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockGetValue.mockResolvedValue('instance-key');
mockGetApiKeyColumn.mockResolvedValue('user-key');
expect(await getUnsplashKey(1)).toBe('instance-key');
expect(mockGetApiKeyColumn).not.toHaveBeenCalled(); // the own row is not even read
});
it('UNSPLASH-015: returns null when neither env, instance, nor the user has a key', async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockGetValue.mockResolvedValue(undefined);
mockGetApiKeyColumn.mockResolvedValue(undefined);
expect(await getUnsplashKey(1)).toBeNull();
});
it("UNSPLASH-015b: never reads another user's key — the admin fallback is gone (#1939)", async () => {
delete process.env.UNSPLASH_ACCESS_KEY;
mockGetValue.mockResolvedValue(undefined);
mockGetApiKeyColumn.mockResolvedValue(undefined);
expect(await getUnsplashKey(1)).toBeNull();
// Both reads are scoped: the instance row and this caller's own row.
expect(mockGetApiKeyColumn).toHaveBeenCalledTimes(1);
expect(mockGetApiKeyColumn).toHaveBeenCalledWith(1, 'unsplash_api_key');
});
});
describe('unsplashService.saveUnsplashCover', () => {
const coversDir = path.join(coverFx.root, 'covers');
const writtenCovers = () => (fs.existsSync(coversDir) ? fs.readdirSync(coversDir) : []);
afterEach(() => {
try {
fs.rmSync(coversDir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('UNSPLASH-005: rejects a non-Unsplash host before any fetch', async () => {
await expect(saveUnsplashCover('https://evil.example.com/x.jpg')).rejects.toThrow('Not an Unsplash image URL');
expect(safeFetch).not.toHaveBeenCalled();
});
it('UNSPLASH-006: downloads an Unsplash image and stores it under covers', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/jpeg', bytes: 1234 }));
const filename = await saveUnsplashCover('https://images.unsplash.com/photo-1?w=1080');
expect(filename).toMatch(/\.jpg$/);
expect(fs.existsSync(path.join(coversDir, filename))).toBe(true);
});
it('UNSPLASH-007: rejects an unsupported content type without writing', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'text/html' }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow(
/Unsupported cover image type/,
);
expect(writtenCovers()).toEqual([]);
});
it('UNSPLASH-008: rejects an oversized image without writing', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/png', bytes: 16 * 1024 * 1024 }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large');
expect(writtenCovers()).toEqual([]);
});
it('UNSPLASH-008a: rejects on the declared length before the body is buffered', async () => {
const arrayBuffer = vi.fn(async () => new ArrayBuffer(8));
safeFetch.mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : String(20 * 1024 * 1024)),
},
arrayBuffer,
} as unknown as Response);
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large');
expect(arrayBuffer).not.toHaveBeenCalled();
expect(writtenCovers()).toEqual([]);
});
it('UNSPLASH-008b: stops a chunked oversized body mid-stream instead of buffering it', async () => {
let cancelled = false;
let served = 0;
safeFetch.mockResolvedValue({
ok: true,
status: 200,
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : null) },
body: {
getReader: () => ({
read: async () => {
served++;
return { done: false, value: new Uint8Array(4 * 1024 * 1024) };
},
cancel: async () => {
cancelled = true;
},
}),
},
} as unknown as Response);
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large');
expect(cancelled).toBe(true);
// 15MB budget, 4MB chunks: the read stops on the fourth, not after an endless body.
expect(served).toBe(4);
expect(writtenCovers()).toEqual([]);
});
it('UNSPLASH-008c: passes a deadline through safeFetch', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/jpeg', bytes: 32 }));
await saveUnsplashCover('https://images.unsplash.com/photo-1');
expect(safeFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
});
it('UNSPLASH-009: throws when the download fails', async () => {
safeFetch.mockResolvedValue(fakeRes({ ok: false, status: 404 }));
await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow(/HTTP 404/);
expect(writtenCovers()).toEqual([]);
});
});