fix(regression): resolve dogfood findings across marketing, auth, e2e and docs
- Fix list-to-detail navigation on product/service/solution/case pages - Fix soft 404 on service detail by removing (marketing)/loading.tsx and using force-dynamic - Fix contact form submission feedback and news placeholder image handling - Unify SSR/client authentication state in auth.ts - Add "新闻动态" to main navigation - Fix Playwright storageState path and Firefox footer link flakiness - Add E2E coverage for nav dropdown, cases filter and auth token parsing - Update visual regression baselines (desktop/tablet/mobile, chromium/webkit/firefox) - Update README, lessons-learned and add REGRESSION_REPORT_2026-07-27.md - Ignore .lighthouseci/ and heading-hierarchy-report.json
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
process.env.JWT_SECRET = 'test-jwt-secret';
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unmock('./auth');
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest, getTokenFromRequest, getTokenFromCookie } from './auth';
|
||||
import type { JwtPayload } from './auth';
|
||||
|
||||
const mockJwtVerify = jwt.verify as jest.MockedFunction<typeof jwt.verify>;
|
||||
|
||||
type NextRequestInit = NonNullable<ConstructorParameters<typeof NextRequest>[1]>;
|
||||
|
||||
function createRequest(init?: NextRequestInit): NextRequest {
|
||||
const request = new NextRequest('http://localhost/api/admin/models', init);
|
||||
const cookieHeader = init?.headers && 'cookie' in init.headers ? init.headers.cookie : undefined;
|
||||
const cookieMap = new Map<string, string>();
|
||||
if (typeof cookieHeader === 'string') {
|
||||
cookieHeader.split(';').forEach((pair) => {
|
||||
const [name, value] = pair.trim().split('=');
|
||||
if (name && value !== undefined) cookieMap.set(name, value);
|
||||
});
|
||||
}
|
||||
Object.defineProperty(request, 'cookies', {
|
||||
value: {
|
||||
get: (name: string) => {
|
||||
const value = cookieMap.get(name);
|
||||
return value !== undefined ? { name, value } : undefined;
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
describe('getTokenFromRequest', () => {
|
||||
it('returns token from Authorization header', () => {
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer valid-token' },
|
||||
});
|
||||
expect(getTokenFromRequest(request)).toBe('valid-token');
|
||||
});
|
||||
|
||||
it('returns null when Authorization header is missing', () => {
|
||||
const request = createRequest();
|
||||
expect(getTokenFromRequest(request)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when Authorization header is not Bearer', () => {
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Basic valid-token' },
|
||||
});
|
||||
expect(getTokenFromRequest(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenFromCookie', () => {
|
||||
it('returns token from novalon_token cookie', () => {
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'novalon_token=cookie-token; other=value' },
|
||||
});
|
||||
expect(getTokenFromCookie(request)).toBe('cookie-token');
|
||||
});
|
||||
|
||||
it('returns null when novalon_token cookie is missing', () => {
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'other=value' },
|
||||
});
|
||||
expect(getTokenFromCookie(request)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no cookies are present', () => {
|
||||
const request = createRequest();
|
||||
expect(getTokenFromCookie(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticateRequest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('authenticates from Authorization header', () => {
|
||||
const payload: JwtPayload = { userId: 'user-1', username: 'admin', role: 'super_admin' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer header-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toEqual(payload);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('falls back to cookie when Authorization header is missing', () => {
|
||||
const payload: JwtPayload = { userId: 'user-2', username: 'editor', role: 'editor' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'novalon_token=cookie-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toEqual(payload);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('cookie-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('prefers Authorization header over cookie when both are present', () => {
|
||||
const payload: JwtPayload = { userId: 'user-3', username: 'admin', role: 'super_admin' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: {
|
||||
Authorization: 'Bearer header-token',
|
||||
cookie: 'novalon_token=cookie-token',
|
||||
},
|
||||
});
|
||||
|
||||
authenticateRequest(request);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('returns null when no token is present', () => {
|
||||
const request = createRequest();
|
||||
expect(authenticateRequest(request)).toBeNull();
|
||||
expect(mockJwtVerify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when token verification fails', () => {
|
||||
mockJwtVerify.mockImplementation(() => {
|
||||
throw new Error('invalid token');
|
||||
});
|
||||
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
+16
-3
@@ -47,6 +47,8 @@ export function verifyRefreshToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET!) as JwtPayload;
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'novalon_token';
|
||||
|
||||
// ============ 请求认证 ============
|
||||
|
||||
export function getTokenFromRequest(request: NextRequest): string | null {
|
||||
@@ -57,8 +59,19 @@ export function getTokenFromRequest(request: NextRequest): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求 cookie 中读取 access token。
|
||||
* 中间件(Edge Runtime)与 API 路由(Node Runtime)均使用同一 cookie 名,
|
||||
* 保证 SSR/页面路由与 API 调用的认证状态一致。
|
||||
*/
|
||||
export function getTokenFromCookie(request: NextRequest): string | null {
|
||||
return request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
|
||||
}
|
||||
|
||||
export function authenticateRequest(request: NextRequest): JwtPayload | null {
|
||||
const token = getTokenFromRequest(request);
|
||||
// 优先使用 Authorization 头(adminApi 客户端显式携带),同时兼容 cookie 认证
|
||||
// 使中间件与 API 路由共享同一认证来源,避免 SSR/客户端状态不一致。
|
||||
const token = getTokenFromRequest(request) || getTokenFromCookie(request);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return verifyAccessToken(token);
|
||||
@@ -85,11 +98,11 @@ export function setTokenCookie(
|
||||
refreshToken: string,
|
||||
secure = false,
|
||||
): void {
|
||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', accessToken, 86400, secure));
|
||||
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, accessToken, 86400, secure));
|
||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', refreshToken, 604800, secure));
|
||||
}
|
||||
|
||||
export function clearTokenCookie(response: Response, secure = false): void {
|
||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', '', 0, secure));
|
||||
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, '', 0, secure));
|
||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', '', 0, secure));
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const NAVIGATION_V2: NavigationItemV2[] = [
|
||||
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
||||
{ id: 'services', label: '服务', href: '/services' },
|
||||
{ id: 'cases', label: '案例', href: '/cases' },
|
||||
{ id: 'news', label: '新闻动态', href: '/news' },
|
||||
{ id: 'about', label: '关于我们', href: '/about' },
|
||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user