From c1331071acf2920537773070179fa599085a165a Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 16 Apr 2026 20:41:30 +0800 Subject: [PATCH] Add CI and regression coverage for audit remediation --- .github/workflows/ci.yml | 88 +++++++++++ .gitignore | 4 + eslint.config.mjs | 4 + playwright.config.ts | 76 ++++++++++ playwright/smoke.spec.ts | 88 +++++++++++ tests/auth-rate-limit.test.ts | 47 ++++++ tests/latency-source-map.test.ts | 22 +++ tests/outbound-policy.test.ts | 91 +++++++++++ tests/redis-fallback-routes.test.ts | 96 ++++++++++++ tests/service-worker.test.ts | 226 ++++++++++++++++++++++++++++ tests/source-validation.test.ts | 62 ++++++++ 11 files changed, 804 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 playwright.config.ts create mode 100644 playwright/smoke.spec.ts create mode 100644 tests/auth-rate-limit.test.ts create mode 100644 tests/latency-source-map.test.ts create mode 100644 tests/outbound-policy.test.ts create mode 100644 tests/redis-fallback-routes.test.ts create mode 100644 tests/service-worker.test.ts create mode 100644 tests/source-validation.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..18f1213 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + web: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Playwright smoke tests + run: npm run test:e2e + + - name: Production build + run: npm run build + + - name: Workers build + run: npm run cf:build + + - name: Audit production dependencies + run: npm audit --omit=dev + + - name: Validate docker compose + run: docker compose config + + - name: Build Docker image + run: docker build -t kvideo-ci . + + android-tv: + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + working-directory: android-tv + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android packages + run: sdkmanager "platforms;android-34" "build-tools;34.0.0" + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Android lint, test, and builds + run: ./gradlew --no-daemon lint test assembleDebug assembleRelease diff --git a/.gitignore b/.gitignore index f544e37..4ae9769 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # next.js /.next/ +/.open-next/ /out/ # production @@ -29,6 +30,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +/playwright-report/ +/test-results/ +.wrangler/ # env files (can opt-in for committing if needed) .env* diff --git a/eslint.config.mjs b/eslint.config.mjs index 70eb949..be6a3e5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,8 +16,12 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + ".open-next/**", "out/**", "build/**", + "android-tv/.gradle/**", + "android-tv/app/build/**", + "audit-artifacts/**", ".vercel/**", "next-env.d.ts", ]), diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0b14bcb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +const port = 3301; +const baseUrl = `http://localhost:${port}`; + +function findLocalChromiumExecutable() { + if (process.env.CI) { + return undefined; + } + + const cacheRoot = path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'); + if (!fs.existsSync(cacheRoot)) { + return undefined; + } + + const installedChromiumDirs = fs.readdirSync(cacheRoot) + .filter((entry) => entry.startsWith('chromium-')) + .sort() + .reverse(); + + for (const chromiumDir of installedChromiumDirs) { + const executablePath = path.join( + cacheRoot, + chromiumDir, + 'chrome-mac', + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium', + ); + + if (fs.existsSync(executablePath)) { + return executablePath; + } + } + + return undefined; +} + +const localChromiumExecutable = findLocalChromiumExecutable(); + +export default defineConfig({ + testDir: './playwright', + timeout: 30_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + use: { + baseURL: baseUrl, + trace: 'retain-on-failure', + launchOptions: localChromiumExecutable + ? { + executablePath: localChromiumExecutable, + } + : undefined, + }, + webServer: { + command: `PORT=${port} ACCESS_PASSWORD=playwright-pass AUTH_SECRET=playwright-secret KVIDEO_PUBLIC_RELAY_ENABLED=true npm run dev`, + url: baseUrl, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}); diff --git a/playwright/smoke.spec.ts b/playwright/smoke.spec.ts new file mode 100644 index 0000000..1868d07 --- /dev/null +++ b/playwright/smoke.spec.ts @@ -0,0 +1,88 @@ +import { test, expect, type Page } from '@playwright/test'; + +async function stubAmbientApiCalls(page: Page) { + await page.route('**/api/app-update', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + currentVersion: 'test', + latestVersion: 'test', + status: 'up-to-date', + updateAvailable: false, + checkedAt: new Date().toISOString(), + checkedRemotely: false, + usedRemoteManifest: false, + currentRelease: null, + latestRelease: null, + source: { + repository: 'KuekHaoYang/KVideo', + branch: 'main', + manifestUrl: 'https://example.com/app-release.json', + changelogUrl: 'https://example.com/changelog', + repositoryUrl: 'https://example.com/repo', + }, + }), + }); + }); + + await page.route('**/api/douban/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); +} + +async function login(page: Page) { + await stubAmbientApiCalls(page); + await page.goto('/'); + await expect(page.getByText('访问受限')).toBeVisible(); + await page.getByPlaceholder('输入密码...').fill('playwright-pass'); + await page.getByRole('button', { name: '登录' }).click(); + await expect(page.getByRole('textbox', { name: /搜索/i }).or(page.getByPlaceholder(/搜索/i)).first()).toBeVisible(); +} + +test('legacy password login and logout flow works', async ({ page }) => { + await login(page); + await page.goto('/settings'); + await expect(page.getByText('账户管理')).toBeVisible(); + await page.getByRole('button', { name: '退出登录' }).first().click(); + await expect(page.getByText('访问受限')).toBeVisible(); +}); + +test('settings danger zone and IPTV empty state render after login', async ({ page }) => { + await login(page); + await page.goto('/settings'); + await expect(page.getByText('危险操作')).toBeVisible(); + await page.getByRole('button', { name: '清除所有数据' }).click(); + await expect(page.getByText('这将删除本地设置、历史记录、缓存,并退出当前登录会话。')).toBeVisible(); + await page.getByRole('button', { name: '取消' }).click(); + + await page.goto('/iptv'); + await expect(page.getByText(/IPTV 直播频道|0 个频道/)).toBeVisible(); +}); + +test('settings reset clears the current session and returns to the login gate', async ({ page }) => { + await login(page); + await page.goto('/settings'); + await page.getByRole('button', { name: '清除所有数据' }).click(); + await page.getByRole('button', { name: '清除', exact: true }).click(); + await expect(page.getByText('访问受限')).toBeVisible(); +}); + +test('proxy rejects private targets even when relay is enabled', async ({ page }) => { + await login(page); + const response = await page.request.get('/api/proxy?url=http://127.0.0.1/private.m3u8'); + expect(response.status()).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: 'Proxy request failed', + }); +}); + +test('home still renders in reduced motion mode', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await login(page); + await expect(page.getByRole('textbox', { name: /搜索/i }).or(page.getByPlaceholder(/搜索/i)).first()).toBeVisible(); +}); diff --git a/tests/auth-rate-limit.test.ts b/tests/auth-rate-limit.test.ts new file mode 100644 index 0000000..1e5e718 --- /dev/null +++ b/tests/auth-rate-limit.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NextRequest } from 'next/server'; +import { + clearAuthFailures, + getAuthThrottleStatus, + recordAuthFailure, +} from '@/lib/server/auth-rate-limit'; + +function makeRequest(ipAddress: string) { + return new NextRequest('https://kvideo.example/api/auth', { + headers: { + 'x-forwarded-for': ipAddress, + }, + }); +} + +test('account throttling blocks repeated failures and clears after success', async () => { + const request = makeRequest(`203.0.113.${Math.floor(Math.random() * 200) + 1}`); + const username = `tester-${Date.now()}`; + + for (let attempt = 0; attempt < 5; attempt += 1) { + await recordAuthFailure(request, username); + } + + const blockedStatus = await getAuthThrottleStatus(request, username); + assert.equal(blockedStatus.blocked, true); + assert.ok(blockedStatus.retryAfterSeconds > 0); + + await clearAuthFailures(request, username); + + const clearedStatus = await getAuthThrottleStatus(request, username); + assert.equal(clearedStatus.blocked, false); +}); + +test('ip throttling applies even without a username', async () => { + const request = makeRequest(`198.51.100.${Math.floor(Math.random() * 200) + 1}`); + + const scope = `shared-ip-${Date.now()}`; + for (let attempt = 0; attempt < 10; attempt += 1) { + await recordAuthFailure(request, undefined, scope); + } + + const throttled = await getAuthThrottleStatus(request, undefined, scope); + assert.equal(throttled.blocked, true); + assert.ok(throttled.retryAfterSeconds > 0); +}); diff --git a/tests/latency-source-map.test.ts b/tests/latency-source-map.test.ts new file mode 100644 index 0000000..74d5d28 --- /dev/null +++ b/tests/latency-source-map.test.ts @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildLatencySourceUrls } from '@/lib/utils/latency-source-map'; + +test('buildLatencySourceUrls maps visible source ids back to their real configured base URLs', () => { + const mapped = buildLatencySourceUrls( + [ + { id: 'source-b' }, + { id: 'source-a' }, + { id: 'missing-source' }, + ], + [ + { id: 'source-a', baseUrl: 'https://a.example.com' }, + { id: 'source-b', baseUrl: 'https://b.example.com' }, + ], + ); + + assert.deepEqual(mapped, [ + { id: 'source-b', baseUrl: 'https://b.example.com' }, + { id: 'source-a', baseUrl: 'https://a.example.com' }, + ]); +}); diff --git a/tests/outbound-policy.test.ts b/tests/outbound-policy.test.ts new file mode 100644 index 0000000..e7908d7 --- /dev/null +++ b/tests/outbound-policy.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + OutboundPolicyError, + assertOutboundUrlAllowed, + fetchWithPolicy, + getRelayForwardHeaders, + sanitizeHeaderMap, + sanitizeReferer, + sanitizeUserAgent, +} from '@/lib/server/outbound-policy'; + +test('assertOutboundUrlAllowed rejects non-http protocols and private IPs by default', async () => { + await assert.rejects( + assertOutboundUrlAllowed('ftp://example.com/video.m3u8'), + (error: unknown) => + error instanceof OutboundPolicyError && error.code === 'UNSUPPORTED_OUTBOUND_PROTOCOL', + ); + + await assert.rejects( + assertOutboundUrlAllowed('http://127.0.0.1/stream.m3u8'), + (error: unknown) => + error instanceof OutboundPolicyError && error.code === 'PRIVATE_OUTBOUND_TARGET', + ); +}); + +test('assertOutboundUrlAllowed permits explicitly allowlisted private hosts', async () => { + const previousAllowlist = process.env.KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST; + process.env.KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST = '127.0.0.1,lan.example'; + + try { + const url = await assertOutboundUrlAllowed('http://127.0.0.1/live.m3u8'); + assert.equal(url.hostname, '127.0.0.1'); + } finally { + process.env.KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST = previousAllowlist; + } +}); + +test('fetchWithPolicy blocks redirects into private ranges', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(null, { + status: 302, + headers: { + location: 'http://127.0.0.1/private.m3u8', + }, + })) as typeof fetch; + + try { + await assert.rejects( + fetchWithPolicy('https://1.1.1.1/public.m3u8'), + (error: unknown) => + error instanceof OutboundPolicyError && error.code === 'PRIVATE_OUTBOUND_TARGET', + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('relay header sanitizers keep the safe forwarding surface small', async () => { + const sanitizedHeaders = sanitizeHeaderMap({ + Range: 'bytes=0-1024', + Cookie: 'session=secret', + Referer: 'https://1.1.1.1/watch', + 'X-Forwarded-For': '1.2.3.4', + }); + + assert.deepEqual(sanitizedHeaders, { + Range: 'bytes=0-1024', + Referer: 'https://1.1.1.1/watch', + }); + + const request = new Request('https://kvideo.example/api/proxy?url=https://1.1.1.1/test', { + headers: { + Range: 'bytes=100-200', + Cookie: 'session=secret', + }, + }); + + const forwardHeaders = getRelayForwardHeaders(request, { + Referer: 'https://1.1.1.1/watch', + 'User-Agent': sanitizeUserAgent('KVideo Test Agent'.repeat(40))!, + }); + + assert.equal(forwardHeaders.get('Range'), 'bytes=100-200'); + assert.equal(forwardHeaders.get('Cookie'), null); + assert.equal(forwardHeaders.get('Referer'), 'https://1.1.1.1/watch'); + assert.ok((forwardHeaders.get('User-Agent') || '').length <= 512); + + assert.equal(await sanitizeReferer('https://1.1.1.1/watch'), 'https://1.1.1.1/watch'); +}); diff --git a/tests/redis-fallback-routes.test.ts b/tests/redis-fallback-routes.test.ts new file mode 100644 index 0000000..1441c91 --- /dev/null +++ b/tests/redis-fallback-routes.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NextRequest } from 'next/server'; +import { signSessionPayload } from '@/lib/server/auth-helpers'; + +process.env.AUTH_SECRET = 'route-test-secret'; +delete process.env.UPSTASH_REDIS_REST_URL; +delete process.env.UPSTASH_REDIS_REST_TOKEN; + +async function createSessionCookie(): Promise { + return signSessionPayload({ + accountId: 'account-1', + profileId: 'profile-1', + username: 'tester', + name: 'Tester', + role: 'admin', + customPermissions: [], + mode: 'managed', + iat: Date.now(), + }, process.env.AUTH_SECRET!); +} + +async function makeAuthenticatedRequest(url: string, init?: RequestInit) { + const sessionCookie = await createSessionCookie(); + const headers = new Headers(init?.headers); + headers.set('cookie', `kvideo_session=${sessionCookie}`); + + if (init?.body && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + + return new NextRequest(url, { + ...init, + headers, + }); +} + +test('user config routes silently no-op when Redis is unavailable', async () => { + const configRoute = await import('../app/api/user/config/route'); + const readResponse = await configRoute.GET( + await makeAuthenticatedRequest('https://kvideo.example/api/user/config'), + ); + + assert.equal(readResponse.status, 200); + assert.deepEqual(await readResponse.json(), { + success: true, + data: null, + synced: false, + }); + + const writeResponse = await configRoute.POST( + await makeAuthenticatedRequest('https://kvideo.example/api/user/config', { + method: 'POST', + body: JSON.stringify({ locale: 'en-US' }), + }), + ); + + assert.equal(writeResponse.status, 200); + assert.deepEqual(await writeResponse.json(), { + success: true, + synced: false, + }); +}); + +test('user sync routes fall back to local-only responses when Redis is unavailable', async () => { + const syncRoute = await import('../app/api/user/sync/route'); + const readResponse = await syncRoute.GET( + await makeAuthenticatedRequest('https://kvideo.example/api/user/sync'), + ); + + assert.equal(readResponse.status, 200); + assert.deepEqual(await readResponse.json(), { + success: true, + data: { + history: [], + favorites: [], + }, + synced: false, + }); + + const writeResponse = await syncRoute.POST( + await makeAuthenticatedRequest('https://kvideo.example/api/user/sync', { + method: 'POST', + body: JSON.stringify({ + history: [{ id: 'video-1' }], + favorites: [{ id: 'video-2' }], + }), + }), + ); + + assert.equal(writeResponse.status, 200); + assert.deepEqual(await writeResponse.json(), { + success: true, + synced: false, + }); +}); diff --git a/tests/service-worker.test.ts b/tests/service-worker.test.ts new file mode 100644 index 0000000..d5f63df --- /dev/null +++ b/tests/service-worker.test.ts @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; + +type ListenerMap = Record) => void>; + +function toAbsoluteUrl(origin: string, input: RequestInfo | URL | string): string { + if (typeof input === 'string') { + return new URL(input, origin).toString(); + } + + if (input instanceof URL) { + return input.toString(); + } + + return input.url; +} + +function createCacheStorage(origin: string) { + const stores = new Map>(); + + function getStore(name: string) { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + return store; + } + + async function matchAcrossCaches(input: RequestInfo | URL | string) { + const absoluteUrl = toAbsoluteUrl(origin, input); + for (const store of stores.values()) { + const response = store.get(absoluteUrl); + if (response) { + return response.clone(); + } + } + return undefined; + } + + return { + caches: { + async open(name: string) { + const store = getStore(name); + return { + async addAll(urls: string[]) { + for (const url of urls) { + store.set( + new URL(url, origin).toString(), + new Response(`cached:${url}`, { status: 200 }), + ); + } + }, + async put(input: RequestInfo | URL | string, response: Response) { + store.set(toAbsoluteUrl(origin, input), response.clone()); + }, + async match(input: RequestInfo | URL | string) { + const response = store.get(toAbsoluteUrl(origin, input)); + return response?.clone(); + }, + }; + }, + async keys() { + return [...stores.keys()]; + }, + async delete(name: string) { + return stores.delete(name); + }, + async match(input: RequestInfo | URL | string) { + return matchAcrossCaches(input); + }, + }, + stores, + }; +} + +function createServiceWorkerHarness() { + const origin = 'https://kvideo.example'; + const listeners: ListenerMap = {}; + const { caches, stores } = createCacheStorage(origin); + let skipWaitingCalled = false; + let clientsClaimed = false; + let fetchImpl: typeof fetch = () => Promise.reject(new Error('fetch not stubbed')); + + const context = { + URL, + Request, + Response, + Promise, + console, + caches, + fetch: (...args: Parameters) => fetchImpl(...args), + self: { + location: { origin }, + skipWaiting() { + skipWaitingCalled = true; + return Promise.resolve(); + }, + clients: { + claim() { + clientsClaimed = true; + return Promise.resolve(); + }, + }, + addEventListener(type: string, listener: (event: Record) => void) { + listeners[type] = listener; + }, + }, + }; + + const scriptPath = path.join(process.cwd(), 'public', 'sw.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + vm.runInNewContext(script, context, { filename: scriptPath }); + + return { + listeners, + stores, + origin, + caches, + setFetchImpl(nextFetch: typeof fetch) { + fetchImpl = nextFetch; + }, + get skipWaitingCalled() { + return skipWaitingCalled; + }, + get clientsClaimed() { + return clientsClaimed; + }, + }; +} + +async function dispatchInstall(harness: ReturnType) { + let installPromise: Promise | undefined; + harness.listeners.install({ + waitUntil(promise: Promise) { + installPromise = promise; + }, + }); + await installPromise; +} + +async function dispatchActivate(harness: ReturnType) { + let activatePromise: Promise | undefined; + harness.listeners.activate({ + waitUntil(promise: Promise) { + activatePromise = promise; + }, + }); + await activatePromise; +} + +async function dispatchFetch( + harness: ReturnType, + request: { method: string; mode?: string; url: string }, +) { + let responsePromise: Promise | undefined; + harness.listeners.fetch({ + request, + respondWith(promise: Promise) { + responsePromise = promise; + }, + }); + + return responsePromise; +} + +test('service worker precaches the same-origin shell and clears legacy cache buckets', async () => { + const harness = createServiceWorkerHarness(); + const legacyCache = await harness.caches.open('video-cache-old'); + await legacyCache.put('/legacy.js', new Response('legacy', { status: 200 })); + + await dispatchInstall(harness); + await dispatchActivate(harness); + + const shellCache = harness.stores.get('kvideo-shell-v2'); + assert.ok(shellCache); + assert.ok(shellCache?.has(`${harness.origin}/`)); + assert.ok(shellCache?.has(`${harness.origin}/offline.html`)); + assert.equal(harness.stores.has('video-cache-old'), false); + assert.equal(harness.skipWaitingCalled, true); + assert.equal(harness.clientsClaimed, true); +}); + +test('service worker falls back to cached shell for offline navigations and caches successful static assets', async () => { + const harness = createServiceWorkerHarness(); + await dispatchInstall(harness); + + harness.setFetchImpl(async (input) => { + const url = toAbsoluteUrl(harness.origin, input); + if (url.endsWith('/app.js')) { + return new Response('asset-body', { status: 200 }); + } + + throw new Error('offline'); + }); + + const offlineNavigationResponsePromise = await dispatchFetch( + harness, + { + method: 'GET', + mode: 'navigate', + url: `${harness.origin}/settings`, + }, + ); + assert.ok(offlineNavigationResponsePromise); + const offlineNavigationResponse = await offlineNavigationResponsePromise; + assert.equal(await offlineNavigationResponse.text(), 'cached:/'); + + const staticAssetResponsePromise = await dispatchFetch( + harness, + { + method: 'GET', + url: `${harness.origin}/app.js`, + }, + ); + assert.ok(staticAssetResponsePromise); + const staticAssetResponse = await staticAssetResponsePromise; + assert.equal(await staticAssetResponse.text(), 'asset-body'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const staticCache = harness.stores.get('kvideo-static-v2'); + assert.ok(staticCache?.has(`${harness.origin}/app.js`)); +}); diff --git a/tests/source-validation.test.ts b/tests/source-validation.test.ts new file mode 100644 index 0000000..9992db6 --- /dev/null +++ b/tests/source-validation.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildSourceEndpointUrl, + normalizeSourceConfig, + normalizeSourceConfigList, +} from '@/lib/server/source-validation'; + +test('normalizeSourceConfig accepts safe public source definitions and sanitizes headers', async () => { + const source = await normalizeSourceConfig({ + id: 'demo-source', + name: 'Demo Source', + baseUrl: 'https://1.1.1.1', + searchPath: '/api/search?q={wd}', + detailPath: '/api/detail?id={id}', + headers: { + Referer: 'https://1.1.1.1/app', + Cookie: 'do-not-forward', + }, + enabled: true, + priority: 5, + }); + + assert.ok(source); + assert.equal(source?.id, 'demo-source'); + assert.equal(source?.headers?.Referer, 'https://1.1.1.1/app'); + assert.equal(source?.headers?.Cookie, undefined); + assert.equal( + buildSourceEndpointUrl(source!.baseUrl, source!.searchPath || '/'), + 'https://1.1.1.1/api/search?q={wd}', + ); +}); + +test('normalizeSourceConfig rejects malformed source objects and unsafe absolute paths', async () => { + const invalid = await normalizeSourceConfig({ + id: 'Bad Source', + name: 'Bad Source', + baseUrl: 'http://127.0.0.1', + searchPath: 'https://evil.example/redirect', + }); + + assert.equal(invalid, null); + + const normalizedList = await normalizeSourceConfigList([ + { + id: 'valid-source', + name: 'Valid Source', + baseUrl: 'https://1.1.1.1', + searchPath: '/search', + detailPath: '/detail', + }, + { + id: 'bad source', + name: 'Bad Source', + baseUrl: 'https://1.1.1.1', + searchPath: 'https://evil.example', + }, + ]); + + assert.equal(normalizedList.length, 1); + assert.equal(normalizedList[0].id, 'valid-source'); +});