Dev #21

Merged
zhangxiang merged 69 commits from dev into main 2026-08-17 20:29:08 +08:00
12 changed files with 1945 additions and 306 deletions
Showing only changes of commit dd088a5ee1 - Show all commits
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/impeccable
+9
View File
@@ -266,6 +266,15 @@ performance-baseline.json
test-data-*.json
test-results-*.json
# Stryker mutation testing temp files
.stryker-tmp/
# Pi framework temp files
.pi/
# Agent config (local only)
.agents/settings.local.json
# Additional E2E test artifacts
e2e/blob-report/
e2e/report/
Vendored
+80
View File
@@ -162,6 +162,86 @@ pipeline {
}
}
// ====== L3: E2E + 用户旅程测试 ======
stage('🌐 E2E 测试') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
echo "🔨 构建用于 E2E 测试..."
npm run build
echo "🚀 启动预览服务器..."
npx serve dist -l 3000 &
sleep 5
echo "🧪 运行 E2E 快速测试(@smoke + @critical..."
cd e2e && npx playwright test --grep "@smoke|@critical" || echo "⚠️ E2E 测试部分失败,继续执行"
echo "🧪 运行用户旅程测试..."
npx playwright test --grep @journey || echo "⚠️ 用户旅程测试部分失败,继续执行"
'''
}
}
post {
always {
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
publishHTML(target: [
allowMissing: true,
reportDir: 'e2e/playwright-report',
reportFiles: 'index.html',
reportName: 'E2E Test Report'
])
}
failure {
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
}
}
}
// ====== L4: 视觉回归测试 ======
stage('👁️ 视觉回归测试') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
echo "🚀 启动预览服务器..."
npx serve dist -l 3000 &
sleep 5
echo "🧪 运行视觉回归测试..."
cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop || echo "⚠️ 视觉回归测试失败,请检查基线是否需要更新"
'''
}
}
post {
always {
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
}
}
}
// ====== L5: 安全扫描 ======
stage('🔒 安全扫描') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
echo "🔒 运行依赖安全审计..."
npm audit --audit-level=high || echo "⚠️ 存在高危依赖漏洞,请检查"
echo "🔒 检查安全响应头..."
npm run test:security:headers -- --url https://novalon.cn || echo "⚠️ 安全头检查未通过,请检查 Nginx 配置"
'''
}
}
}
stage('🏗️ 构建 dist') {
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
+3
View File
@@ -1,6 +1,9 @@
const path = require('path');
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
rootDir: path.resolve(__dirname, '../..'),
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
// 仅收集实际可单元测试的目录的覆盖率,排除 pages/API routes/admin 等需集成/E2E 测试的代码
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
reporter: [
['html', { open: 'never' }],
['list', { printSteps: true }],
['allure-playwright', { outputFolder: 'allure-results' }],
],
snapshotDir: './visual-snapshots',
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
-1
View File
@@ -1 +0,0 @@
config/test/jest.config.js
+111
View File
@@ -0,0 +1,111 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
// 仅收集实际可单元测试的目录的覆盖率,排除 pages/API routes/admin 等需集成/E2E 测试的代码
collectCoverageFrom: [
'src/components/ui/**/*.{ts,tsx}',
'src/components/layout/**/*.{ts,tsx}',
'src/components/seo/**/*.{ts,tsx}',
'src/components/detail/**/*.{ts,tsx}',
'src/components/sections/**/*.{ts,tsx}',
'src/components/content/**/*.{ts,tsx}',
'src/hooks/**/*.{ts,tsx}',
'src/lib/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
'!src/**/__tests__/**',
],
// 覆盖率阈值采用渐进提升策略:基于 Phase 1-5 实测值设定目录级阈值,避免全局平均掩盖核心模块缺口
// 实测基准 (Phase 5): global(~33% stmts/~32% branches), hooks(~61%/~41%), lib(~45%/~43%),
// ui(~15%/~21%), layout(~59%/~48%), detail(~32%/~29%), sections(~31%/~35%), content(~29%/~5%),
// lib/cms(~67%/~83%), lib/constants(~94%/~81%)
coverageThreshold: {
global: {
branches: 30,
functions: 25,
lines: 32,
statements: 30,
},
'./src/hooks/': {
branches: 38,
functions: 60,
lines: 60,
statements: 60,
},
'./src/lib/': {
branches: 40,
functions: 45,
lines: 42,
statements: 42,
},
'./src/components/ui/': {
branches: 18,
functions: 10,
lines: 15,
statements: 14,
},
'./src/components/layout/': {
branches: 45,
functions: 35,
lines: 60,
statements: 55,
},
'./src/components/detail/': {
branches: 25,
functions: 22,
lines: 30,
statements: 30,
},
'./src/components/sections/': {
branches: 30,
functions: 18,
lines: 30,
statements: 30,
},
'./src/components/content/': {
branches: 4,
functions: 4,
lines: 25,
statements: 25,
},
'./src/components/seo/': {
branches: 0,
functions: 18,
lines: 40,
statements: 40,
},
'./src/lib/cms/': {
branches: 80,
functions: 75,
lines: 60,
statements: 65,
},
'./src/lib/constants/': {
branches: 88,
functions: 45,
lines: 85,
statements: 85,
},
},
coverageProvider: 'v8',
coverageReporters: ['text', 'lcov', 'html', 'json'],
coverageDirectory: '<rootDir>/coverage',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transform: {
// ts-jest 需要明确指定 jsx: 'react-jsx',因为项目 tsconfig 使用 'preserve'
// 由后续 SWC/Babel 处理,但 Jest 环境下没有这些工具链
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
},
transformIgnorePatterns: [
'node_modules/(?!(nanoid|next-auth|@auth|@aws-sdk)/)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testTimeout: 10000,
verbose: true,
maxWorkers: '50%',
};
-1
View File
@@ -1 +0,0 @@
config/test/jest.setup.js
+203
View File
@@ -0,0 +1,203 @@
require('@testing-library/jest-dom');
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'test-id-123'),
}));
jest.mock('@/generated/prisma/client', () => ({
PrismaClient: jest.fn().mockImplementation(() => ({
contentItem: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
},
contentModel: {
findMany: jest.fn().mockResolvedValue([]),
},
contentZone: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
},
})),
}));
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn().mockResolvedValue([]),
getPublishedItemBySlug: jest.fn().mockResolvedValue(null),
getItemById: jest.fn().mockResolvedValue(null),
getPageZones: jest.fn().mockResolvedValue([]),
getZone: jest.fn().mockResolvedValue(null),
getContentModels: jest.fn().mockResolvedValue([]),
getCases: jest.fn().mockResolvedValue([]),
getNews: jest.fn().mockResolvedValue([]),
getServices: jest.fn().mockResolvedValue([]),
getProducts: jest.fn().mockResolvedValue([]),
getSolutions: jest.fn().mockResolvedValue([]),
getStats: jest.fn().mockResolvedValue([]),
getHeroBanners: jest.fn().mockResolvedValue([]),
getCaseBySlug: jest.fn().mockResolvedValue(null),
getNewsBySlug: jest.fn().mockResolvedValue(null),
getServiceBySlug: jest.fn().mockResolvedValue(null),
getProductBySlug: jest.fn().mockResolvedValue(null),
getSolutionBySlug: jest.fn().mockResolvedValue(null),
getHomePageZones: jest.fn().mockResolvedValue([]),
getAllPublishedSlugs: jest.fn().mockResolvedValue([]),
}));
jest.mock('next/dynamic', () => ({
__esModule: true,
default: (_importFn, _options) => {
const MockComponent = (_props) => null;
MockComponent.displayName = 'DynamicComponent';
MockComponent.preload = () => Promise.resolve();
return MockComponent;
},
}));
jest.mock('next/server', () => ({
NextRequest: class MockNextRequest {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
},
NextResponse: {
json: (body, init = {}) => ({
status: init.status || 200,
json: async () => body,
}),
},
}));
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
};
class MockIntersectionObserver {
constructor(callback, options = {}) {
this.callback = callback;
this.options = options;
this.elements = new Set();
this.observationEntries = [];
}
observe(element) {
this.elements.add(element);
const entry = {
isIntersecting: true,
target: element,
boundingClientRect: element.getBoundingClientRect ? element.getBoundingClientRect() : {},
intersectionRatio: 1,
intersectionRect: {},
rootBounds: {},
time: Date.now(),
};
this.observationEntries.push(entry);
this.callback(this.observationEntries, this);
}
unobserve(element) {
this.elements.delete(element);
this.observationEntries = this.observationEntries.filter(
entry => entry.target !== element
);
}
disconnect() {
this.elements.clear();
this.observationEntries = [];
}
takeRecords() {
return this.observationEntries;
}
}
global.IntersectionObserver = MockIntersectionObserver;
global.IntersectionObserverEntry = class IntersectionObserverEntry {
constructor() {
this.isIntersecting = true;
this.target = {};
this.boundingClientRect = {};
this.intersectionRatio = 1;
this.intersectionRect = {};
this.rootBounds = {};
this.time = Date.now();
}
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
global.Request = class Request {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
};
global.Headers = class Headers {
constructor(init = {}) {
this.headers = {};
if (init) {
Object.entries(init).forEach(([key, value]) => {
this.headers[key.toLowerCase()] = value;
});
}
}
get(name) {
return this.headers[name.toLowerCase()];
}
set(name, value) {
this.headers[name.toLowerCase()] = value;
}
};
global.Response = class Response {
constructor(body, init = {}) {
this.body = body;
this.status = init.status || 200;
this.statusText = init.statusText || 'OK';
this.headers = new Headers(init.headers);
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
async text() {
return String(this.body);
}
};
+1174 -264
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -19,7 +19,6 @@
"test:coverage:check": "jest --coverage --ci",
"coverage:report": "open coverage/lcov-report/index.html",
"test:e2e": "cd e2e && npx playwright test --config=playwright.config.ts",
"test:smoke": "cd e2e && npx playwright test --grep @smoke",
"test:visual": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop",
"test:visual:all": "cd e2e && npx playwright test visual-regression.spec.ts",
"test:visual:update": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --update-snapshots",
@@ -28,8 +27,19 @@
"test:visual:browsers": "cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop --project=visual-firefox-desktop --project=visual-webkit-desktop",
"test:performance": "k6 run tests/performance/load-test.js",
"test:stress": "k6 run tests/performance/stress-test.js",
"test:smoke": "cd e2e && npx playwright test --grep @smoke",
"test:critical": "cd e2e && npx playwright test --grep @critical",
"test:e2e:fast": "cd e2e && npx playwright test --grep '@smoke|@critical'",
"test:e2e:standard": "cd e2e && npx playwright test --grep @regression",
"test:e2e:journey": "cd e2e && npx playwright test --grep @journey",
"test:e2e:mobile": "cd e2e && npx playwright test --grep @mobile",
"test:mutation": "rm -rf .stryker-tmp && npx stryker run --inPlace",
"test:mutation:quick": "rm -rf .stryker-tmp && npx stryker run --inPlace --mutate 'src/lib/utils.ts'",
"test:all": "npm run type-check && npm run lint && npm run test:coverage && npm run test:e2e:fast && npm run test:security:headers",
"check:contrast": "tsx scripts/utils/check-color-contrast.ts",
"check:headings": "tsx scripts/utils/check-heading-hierarchy.ts",
"test:security": "npm audit --audit-level=high && npm run test:security:headers",
"test:security:headers": "tsx scripts/utils/check-security-headers.ts",
"lighthouse": "lhci autorun",
"lighthouse:collect": "lhci collect",
"lighthouse:assert": "lhci assert",
@@ -98,6 +108,8 @@
"@commitlint/config-conventional": "^20.5.0",
"@eslint/eslintrc": "^3.3.5",
"@lhci/cli": "^0.15.1",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/jest-runner": "^9.6.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -109,6 +121,7 @@
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"allure-playwright": "^3.10.2",
"autoprefixer": "^10.4.20",
"chrome-launcher": "^1.2.1",
"eslint": "^9.25.1",
-38
View File
@@ -1,38 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://novalon.cn',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
});
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env tsx
/**
* 安全响应头检查脚本
*
* 检查部署网站的安全头配置是否合规,包括:
* - 核心安全头(Content-Security-Policy, X-Content-Type-Options 等)
* - CORS 配置
* - Cookie 安全标记
*/
interface SecurityCheck {
header: string;
expected: string;
actual: string | null;
status: 'pass' | 'fail' | 'warn';
description: string;
}
interface CookieCheck {
name: string;
hasHttpOnly: boolean;
hasSecure: boolean;
hasSameSite: boolean;
status: 'pass' | 'fail' | 'warn';
}
function parseArgs(): string {
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
if (urlIndex !== -1 && args[urlIndex + 1]) {
return args[urlIndex + 1]!;
}
return 'https://novalon.cn';
}
function parseCookies(setCookieHeader: string | null): Array<{ name: string; flags: string[] }> {
if (!setCookieHeader) return [];
const cookies: Array<{ name: string; flags: string[] }> = [];
// Handle multiple Set-Cookie headers (joined by comma-newline)
const cookieStrings = setCookieHeader.split(/\n|,(?=\s*\w+=)/);
for (const cookieStr of cookieStrings) {
const trimmed = cookieStr.trim();
if (!trimmed) continue;
const parts = trimmed.split(';').map((p) => p.trim());
const nameValue = parts[0]!;
const name = nameValue.split('=')[0] || 'unknown';
const flags = parts.slice(1).map((f) => f.toLowerCase());
cookies.push({ name, flags });
}
return cookies;
}
function checkCookies(cookies: Array<{ name: string; flags: string[] }>): CookieCheck[] {
return cookies.map((cookie) => ({
name: cookie.name,
hasHttpOnly: cookie.flags.some((f) => f === 'httponly'),
hasSecure: cookie.flags.some((f) => f === 'secure'),
hasSameSite: cookie.flags.some((f) => f.startsWith('samesite')),
status: 'pass' as const,
}));
}
async function checkSecurityHeaders(url: string): Promise<{
headerChecks: SecurityCheck[];
cookieChecks: CookieCheck[];
}> {
console.log(`\n🔒 安全响应头检查 — ${url}\n`);
const response = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(15000),
});
const headers = response.headers;
const headerChecks: SecurityCheck[] = [
{
header: 'Content-Security-Policy',
expected: '存在(需包含合理策略)',
actual: headers.get('content-security-policy'),
status: headers.get('content-security-policy') ? 'pass' : 'warn',
description: '防止 XSS 和数据注入攻击的核心策略',
},
{
header: 'X-Content-Type-Options',
expected: 'nosniff',
actual: headers.get('x-content-type-options'),
status: headers.get('x-content-type-options') === 'nosniff' ? 'pass' : 'fail',
description: '防止 MIME 类型嗅探攻击',
},
{
header: 'X-Frame-Options',
expected: 'DENY 或 SAMEORIGIN',
actual: headers.get('x-frame-options'),
status: (() => {
const val = headers.get('x-frame-options');
if (val === 'DENY' || val === 'SAMEORIGIN') return 'pass' as const;
return val ? 'warn' as const : 'fail' as const;
})(),
description: '防止点击劫持(Clickjacking)攻击',
},
{
header: 'Strict-Transport-Security',
expected: '存在(需包含 max-age',
actual: headers.get('strict-transport-security'),
status: headers.get('strict-transport-security') ? 'pass' : 'fail',
description: '强制 HTTPS 连接,防止 SSL Strip 攻击',
},
{
header: 'Referrer-Policy',
expected: '存在(如 strict-origin-when-cross-origin',
actual: headers.get('referrer-policy'),
status: headers.get('referrer-policy') ? 'pass' : 'fail',
description: '控制 Referer 头信息的发送策略',
},
{
header: 'Permissions-Policy',
expected: '存在(需配置合理权限)',
actual: headers.get('permissions-policy'),
status: headers.get('permissions-policy') ? 'pass' : 'warn',
description: '限制浏览器 API 权限(摄像头、麦克风等)',
},
{
header: 'X-XSS-Protection',
expected: '存在(0 或 1; mode=block',
actual: headers.get('x-xss-protection'),
status: (() => {
const val = headers.get('x-xss-protection');
if (val === '0' || val === '1; mode=block') return 'pass' as const;
return val ? 'warn' as const : 'warn' as const;
})(),
description: '已废弃的 XSS 过滤器(现代浏览器不再需要)',
},
];
// CORS check
const corsOrigin = headers.get('access-control-allow-origin');
if (corsOrigin) {
headerChecks.push({
header: 'Access-Control-Allow-Origin',
expected: '非通配符(或仅对特定来源开放)',
actual: corsOrigin,
status: corsOrigin === '*' ? 'warn' : 'pass',
description: 'CORS 跨域配置',
});
} else {
headerChecks.push({
header: 'Access-Control-Allow-Origin',
expected: '无(不暴露 CORS 头)',
actual: null,
status: 'pass',
description: 'CORS 跨域配置(未设置,符合安全预期)',
});
}
// Cookie security check
const setCookie = headers.get('set-cookie');
const parsedCookies = parseCookies(setCookie);
const cookieChecks = checkCookies(parsedCookies);
return { headerChecks, cookieChecks };
}
function printTable(checks: SecurityCheck[]): void {
// Column widths
const headerWidth = 34;
const statusWidth = 6;
const expectedWidth = 38;
const actualWidth = 38;
const separator = `${'─'.repeat(headerWidth + 2)}${'─'.repeat(statusWidth + 2)}${'─'.repeat(expectedWidth + 2)}${'─'.repeat(actualWidth + 2)}`;
const topBorder = `${'─'.repeat(headerWidth + 2)}${'─'.repeat(statusWidth + 2)}${'─'.repeat(expectedWidth + 2)}${'─'.repeat(actualWidth + 2)}`;
const bottomBorder = `${'─'.repeat(headerWidth + 2)}${'─'.repeat(statusWidth + 2)}${'─'.repeat(expectedWidth + 2)}${'─'.repeat(actualWidth + 2)}`;
const headerRow = `${'Header'.padEnd(headerWidth)}${'Status'.padEnd(statusWidth)}${'Expected'.padEnd(expectedWidth)}${'Actual'.padEnd(actualWidth)}`;
console.log(topBorder);
console.log(headerRow);
console.log(separator);
for (const check of checks) {
const statusIcon = check.status === 'pass' ? '✅ PASS' : check.status === 'fail' ? '❌ FAIL' : '⚠️ WARN';
const actual = check.actual ?? '(未设置)';
const row = `${check.header.padEnd(headerWidth)}${statusIcon.padEnd(statusWidth + 2)}${check.expected.padEnd(expectedWidth)}${actual.padEnd(actualWidth)}`;
console.log(row);
}
console.log(bottomBorder);
}
function printCookieTable(cookieChecks: CookieCheck[]): void {
if (cookieChecks.length === 0) {
console.log('\n🍪 Cookie 安全标记: 无 Cookie 设置\n');
return;
}
const nameWidth = 24;
const httpOnlyWidth = 10;
const secureWidth = 8;
const sameSiteWidth = 10;
const topBorder = `${'─'.repeat(nameWidth + 2)}${'─'.repeat(httpOnlyWidth + 2)}${'─'.repeat(secureWidth + 2)}${'─'.repeat(sameSiteWidth + 2)}`;
const separator = `${'─'.repeat(nameWidth + 2)}${'─'.repeat(httpOnlyWidth + 2)}${'─'.repeat(secureWidth + 2)}${'─'.repeat(sameSiteWidth + 2)}`;
const bottomBorder = `${'─'.repeat(nameWidth + 2)}${'─'.repeat(httpOnlyWidth + 2)}${'─'.repeat(secureWidth + 2)}${'─'.repeat(sameSiteWidth + 2)}`;
const headerRow = `${'Cookie Name'.padEnd(nameWidth)}${'HttpOnly'.padEnd(httpOnlyWidth)}${'Secure'.padEnd(secureWidth)}${'SameSite'.padEnd(sameSiteWidth)}`;
console.log('\n🍪 Cookie 安全标记\n');
console.log(topBorder);
console.log(headerRow);
console.log(separator);
for (const cookie of cookieChecks) {
const httpOnly = cookie.hasHttpOnly ? '✅' : '❌';
const secure = cookie.hasSecure ? '✅' : '❌';
const sameSite = cookie.hasSameSite ? '✅' : '❌';
const row = `${cookie.name.padEnd(nameWidth)}${httpOnly.padEnd(httpOnlyWidth)}${secure.padEnd(secureWidth)}${sameSite.padEnd(sameSiteWidth)}`;
console.log(row);
}
console.log(bottomBorder);
}
function printSummary(headerChecks: SecurityCheck[], cookieChecks: CookieCheck[]): void {
const total = headerChecks.length;
const passed = headerChecks.filter((c) => c.status === 'pass').length;
const failed = headerChecks.filter((c) => c.status === 'fail').length;
const warned = headerChecks.filter((c) => c.status === 'warn').length;
const cookiePassed = cookieChecks.filter((c) => c.status === 'pass').length;
const cookieFailed = cookieChecks.filter((c) => c.status === 'fail').length;
console.log('\n📊 检查摘要\n');
console.log(` 安全头检查:`);
console.log(` 总计: ${total}`);
console.log(` ✅ 通过: ${passed}`);
console.log(` ⚠️ 警告: ${warned}`);
console.log(` ❌ 失败: ${failed}`);
if (cookieChecks.length > 0) {
console.log(`\n Cookie 安全标记:`);
console.log(` 总计: ${cookieChecks.length}`);
console.log(` ✅ 通过: ${cookiePassed}`);
console.log(` ❌ 失败: ${cookieFailed}`);
}
}
async function main() {
const url = parseArgs();
try {
const { headerChecks, cookieChecks } = await checkSecurityHeaders(url);
// Print header check table
console.log('安全头检查结果:');
printTable(headerChecks);
// Print cookie check table
printCookieTable(cookieChecks);
// Print descriptions for failed/warned checks
const issues = headerChecks.filter((c) => c.status !== 'pass');
if (issues.length > 0) {
console.log('\n📝 说明:');
for (const issue of issues) {
console.log(` • [${issue.status === 'fail' ? '❌' : '⚠️'}] ${issue.header}: ${issue.description}`);
if (issue.status === 'fail') {
console.log(` 期望: ${issue.expected}`);
console.log(` 实际: ${issue.actual ?? '(未设置)'}`);
}
}
}
// Print summary
printSummary(headerChecks, cookieChecks);
// Determine exit code
const hasFailures = headerChecks.some((c) => c.status === 'fail');
if (hasFailures) {
console.log('\n❌ 存在失败的安全头检查项!');
process.exit(1);
} else {
console.log('\n✅ 所有关键安全头检查通过!');
process.exit(0);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\n❌ 检查执行失败: ${message}`);
process.exit(1);
}
}
main();
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"reporters": ["html", "clear-text", "progress"],
"testRunner": "jest",
"coverageAnalysis": "perTest",
"mutate": [
"src/lib/**/*.ts",
"src/hooks/**/*.{ts,tsx}",
"src/components/ui/**/*.{ts,tsx}",
"src/components/layout/**/*.{ts,tsx}",
"src/lib/cms/**/*.ts",
"!src/**/*.test.{ts,tsx}",
"!src/**/__tests__/**",
"!src/lib/constants/**"
],
"thresholds": {
"high": 80,
"low": 60,
"break": 30
},
"timeoutMS": 60000,
"cleanTempDir": true,
"symlinkNodeModules": true,
"ignorePatterns": [
".eslintrc.json",
".eslintrc",
"eslint.config.mjs",
".lintstagedrc.js",
"commitlint.config.js",
"next.config.mjs",
"postcss.config.js",
"tailwind.config.js",
"tsconfig.json",
"public/",
"dist/",
".next/",
"prisma/",
"scripts/",
"e2e/",
".claude/",
".trae/",
".git/",
".husky/",
"node_modules/",
"coverage/"
],
"jest": {
"configFile": "config/test/jest.config.js"
}
}