mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
350 lines
16 KiB
TypeScript
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([]);
|
|
});
|
|
});
|