mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 08:43:44 +08:00
Add CI and regression coverage for audit remediation
This commit is contained in:
@@ -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
|
||||
@@ -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*
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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<string> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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<string, (event: Record<string, unknown>) => 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<string, Map<string, Response>>();
|
||||
|
||||
function getStore(name: string) {
|
||||
let store = stores.get(name);
|
||||
if (!store) {
|
||||
store = new Map<string, Response>();
|
||||
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<typeof fetch>) => fetchImpl(...args),
|
||||
self: {
|
||||
location: { origin },
|
||||
skipWaiting() {
|
||||
skipWaitingCalled = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
clients: {
|
||||
claim() {
|
||||
clientsClaimed = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
addEventListener(type: string, listener: (event: Record<string, unknown>) => 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<typeof createServiceWorkerHarness>) {
|
||||
let installPromise: Promise<unknown> | undefined;
|
||||
harness.listeners.install({
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
installPromise = promise;
|
||||
},
|
||||
});
|
||||
await installPromise;
|
||||
}
|
||||
|
||||
async function dispatchActivate(harness: ReturnType<typeof createServiceWorkerHarness>) {
|
||||
let activatePromise: Promise<unknown> | undefined;
|
||||
harness.listeners.activate({
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
activatePromise = promise;
|
||||
},
|
||||
});
|
||||
await activatePromise;
|
||||
}
|
||||
|
||||
async function dispatchFetch(
|
||||
harness: ReturnType<typeof createServiceWorkerHarness>,
|
||||
request: { method: string; mode?: string; url: string },
|
||||
) {
|
||||
let responsePromise: Promise<Response> | undefined;
|
||||
harness.listeners.fetch({
|
||||
request,
|
||||
respondWith(promise: Promise<Response>) {
|
||||
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`));
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user