Add CI and regression coverage for audit remediation

This commit is contained in:
kuekhaoyang
2026-04-16 20:41:30 +08:00
parent 32791a62a7
commit c1331071ac
11 changed files with 804 additions and 0 deletions
+88
View File
@@ -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
+4
View File
@@ -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*
+4
View File
@@ -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",
]),
+76
View File
@@ -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'],
},
},
],
});
+88
View File
@@ -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();
});
+47
View File
@@ -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);
});
+22
View File
@@ -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' },
]);
});
+91
View File
@@ -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');
});
+96
View File
@@ -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,
});
});
+226
View File
@@ -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`));
});
+62
View File
@@ -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');
});