Dev #21
@@ -1 +0,0 @@
|
||||
../../.agents/skills/impeccable
|
||||
@@ -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
@@ -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}") {
|
||||
|
||||
@@ -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 测试的代码
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
config/test/jest.config.js
|
||||
+111
@@ -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 +0,0 @@
|
||||
config/test/jest.setup.js
|
||||
+203
@@ -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);
|
||||
}
|
||||
};
|
||||
Generated
+1174
-264
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -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",
|
||||
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user