Files
eSIM-Tools/tests/functions/middleware-logging.test.js
Abner 574506bcbf feat: 添加结构化日志支持并优化错误处理
- 在 netlify/functions/_shared/server-logger.js 中重构日志函数,统一日志输出格式,支持 context 字段覆盖基础字段(如 message)
- 在 netlify/functions/_shared/middleware.js 中增强 withAuth 中间件,注入结构化 logger 到上下文,增加 request_start / request_end / request_error 日志追踪,并记录请求耗时
- 为所有 Netlify Function(giffgaff-sms-activate、giffgaff-graphql、auto-activate-esim、verify-cookie、giffgaff-token-exchange、giffgaff-mfa-challenge、giffgaff-mfa-validation、health)添加结构化日志,替换原有 console 输出,包含请求入参、状态和耗时
- 在 netlify/edge-functions/bff-proxy.js 和 markdown-negotiation.js 中实现 Deno 内联结构化日志,生成 requestId 并统一日志输出格式,支持 INFO/WARN/ERROR/DEBUG 级别控制
- 更新测试用例 tests/modules/server-logger.test.js 和 tests/functions/middleware-logging.test.js,新增日志级别过滤、context 覆盖、多实例隔离、OPTIONS 预检处理等测试场景
2026-06-26 21:28:21 +08:00

253 lines
8.4 KiB
JavaScript

/**
* withAuth 中间件日志集成测试
* 验证请求 ID 生成、请求开始/结束日志、耗时统计和 logger 注入
*/
'use strict';
// Mock server-logger 以捕获日志输出
const mockLogs = [];
jest.mock('../../netlify/functions/_shared/server-logger', () => ({
createLogger: jest.fn((fn, reqId) => ({
info: jest.fn((msg, ctx) => mockLogs.push({ level: 'INFO', msg, fn, reqId, ctx })),
warn: jest.fn((msg, ctx) => mockLogs.push({ level: 'WARN', msg, fn, reqId, ctx })),
error: jest.fn((msg, ctx) => mockLogs.push({ level: 'ERROR', msg, fn, reqId, ctx })),
debug: jest.fn((msg, ctx) => mockLogs.push({ level: 'DEBUG', msg, fn, reqId, ctx })),
})),
}));
// Mock sentry 避免初始化问题
jest.mock('../../netlify/functions/_shared/sentry', () => ({
captureException: jest.fn(),
flush: jest.fn(() => Promise.resolve(true)),
setContext: jest.fn(),
setTag: jest.fn(),
isDev: true,
sentryInitialized: false,
}));
const { withAuth } = require('../../netlify/functions/_shared/middleware');
const { createLogger } = require('../../netlify/functions/_shared/server-logger');
describe('withAuth 日志集成', () => {
beforeEach(() => {
jest.clearAllMocks();
mockLogs.length = 0;
});
it('应该在请求开始时输出 INFO 日志', async () => {
const mockHandler = jest.fn(async () => ({
statusCode: 200,
body: JSON.stringify({ ok: true }),
}));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'GET',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
},
{ functionName: 'test-fn' }
);
const startLog = mockLogs.find((l) => l.msg === 'request_start');
expect(startLog).toBeDefined();
expect(startLog.fn).toBe('test-fn');
expect(startLog.ctx.method).toBe('GET');
expect(startLog.ctx.path).toBe('/bff/test');
});
it('应该在请求结束时输出 INFO 日志包含耗时', async () => {
const mockHandler = jest.fn(async () => ({
statusCode: 200,
body: JSON.stringify({ ok: true }),
}));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'GET',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
},
{ functionName: 'test-fn' }
);
const endLog = mockLogs.find((l) => l.msg === 'request_end');
expect(endLog).toBeDefined();
expect(endLog.ctx.status).toBe(200);
expect(typeof endLog.ctx.duration).toBe('number');
});
it('应该通过 context 暴露 logger 实例给 handler', async () => {
let capturedLogger;
const mockHandler = jest.fn(async (event, context) => {
capturedLogger = context.logger;
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
});
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'GET',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
},
{ functionName: 'test-fn' }
);
expect(capturedLogger).toBeDefined();
expect(typeof capturedLogger.info).toBe('function');
expect(typeof capturedLogger.error).toBe('function');
});
it('handler 抛出异常时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => {
throw new Error('test error');
});
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'GET',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
},
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.errorMessage).toBe('test error');
expect(errorLog.ctx.status).toBe(500);
});
it('每个请求应该生成唯一的 requestId', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/a', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'fn1' }
);
const reqId1 = mockLogs[0]?.reqId;
mockLogs.length = 0;
await wrapped(
{ httpMethod: 'GET', path: '/b', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'fn2' }
);
const reqId2 = mockLogs[0]?.reqId;
expect(reqId1).toBeDefined();
expect(reqId2).toBeDefined();
expect(reqId1).not.toBe(reqId2);
});
it('createLogger 应该使用正确的 functionName 和 requestId', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/test', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'my-function' }
);
expect(createLogger).toHaveBeenCalledWith('my-function', expect.any(String));
});
it('CORS 鉴权失败时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/bff/test', headers: { origin: 'https://evil.com' } },
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(403);
expect(errorLog.ctx.errorName).toBe('AuthError');
// handler 不应被调用
expect(mockHandler).not.toHaveBeenCalled();
});
it('无效 JSON body 时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'POST',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
body: 'not-valid-json{{{',
},
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(400);
expect(errorLog.ctx.errorMessage).toBe('Invalid JSON body');
expect(mockHandler).not.toHaveBeenCalled();
});
it('输入验证失败时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const schema = { name: { required: true, type: 'string' } };
const wrapped = withAuth(mockHandler, { requireAuth: false, validateSchema: schema });
await wrapped(
{
httpMethod: 'POST',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
body: JSON.stringify({}),
},
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(400);
expect(errorLog.ctx.errorMessage).toMatch(/name is required/);
expect(mockHandler).not.toHaveBeenCalled();
});
it('error 日志应包含耗时字段', async () => {
const mockHandler = jest.fn(async () => {
throw new Error('slow fail');
});
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/bff/test', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog.ctx.duration).toBeDefined();
expect(typeof errorLog.ctx.duration).toBe('number');
expect(errorLog.ctx.duration).toBeGreaterThanOrEqual(0);
});
it('OPTIONS 预检请求不输出 request_start 日志(提前返回)', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'OPTIONS', path: '/bff/test', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'test-fn' }
);
// request_start 仍然会输出(在鉴权之前),但 request_end 不会(预检提前返回)
const startLog = mockLogs.find((l) => l.msg === 'request_start');
expect(startLog).toBeDefined();
// handler 不应被调用
expect(mockHandler).not.toHaveBeenCalled();
});
});