Files
eSIM-Tools/tests/security/bff-proxy.test.js
Neo Vern 7298ae4a0d feat: QR 码本地打包 + Edge Function 直接生成 (#86)
*  feat: 打包 [email protected] 到本地 ES 模块

* ♻️ refactor(qrcode): 移除 CDN 加载逻辑,改用本地 import 引入 qrcode-generator

* 🔧 chore: 移除 CDN preconnect 提示,QR 码库已内联

* 🔧 chore: 清理 CSP 配置,移除不再需要的 CDN 域名

*  feat: 将 QR 码生成迁移到 Edge Function,消除后端冷启动延迟

- 在 Edge Function 中内联 qrcode-generator 库(~20KB),直接生成 QR 码
- 删除废弃的 Netlify Function (qrcode-generate.js)
- 更新 server.js 移除对已删除函数的引用
- 更新测试文件适配新的 Edge 内联架构

* ♻️ refactor: 代码质量修复 — 移除死代码、消除变量遮蔽、添加交叉引用注释

* 🔧 chore: 修复非阻塞风险 — 补充 strict 模式、移除废弃 qrcode 依赖

- Edge Function qrcode-lib.js 补充 'use strict' 声明,与浏览器版保持一致
- 移除已废弃的 qrcode npm 依赖(原用于已删除的 Netlify Function)

* ♻️ refactor: 消除魔法数字、补充脆耦合和 async 技术债注释

- Edge Function 中 QR margin 魔法数字 8 替换为 QR_MARGIN_MODULES 常量
- generateQRCodeLocal 补充 async 无 await 的技术债说明
- error.message.startsWith 条件补充校验函数耦合关系注释

* 🐛 fix: 修复边界条件 — null JSON body、vendor 字符串异常、CDN preconnect 残留

- Edge Function: null JSON body 解构移到 try/catch 内,添加 null/非对象检查
- qrcode-generator: 库 throw 字符串时统一转换为 Error 对象,避免 .startsWith 崩溃
- index.html: 移除不再需要的 CDN preconnect 提示(jsdelivr/cdnjs)

* ♻️ refactor: 修复 CSP 恢复、异常归一化、自定义 Error 类、负面路径测试

- netlify.toml: 恢复 script-src 中 cdn.jsdelivr.net(Bootstrap JS 仍依赖)
- Edge Function catch: 归一化非 Error 异常(库可能 throw 字符串)
- Edge Function QR margin: createDataURL(cellSize, 2) → createDataURL(cellSize, cellSize * 2) 对齐模块边距
- Edge Function: 补充认证模型和 QR 格式变更注释
- qrcode-generator.js: 引入 QRCodeValidationError 替代 error.message.startsWith 脆耦合
- tests/bff-proxy: 添加 6 个 Edge QR 负面路径测试(无效 JSON、空/超长/非字符串 data、超范围 size)
- tests/qrcode-generator: 添加库 throw 字符串异常的测试
- scripts/sync-qrcode-lib.js: 新增库代码同步验证脚本

* 🐛 fix: 修复 server.js CSP 缺少 jsdelivr、同步脚本 CRLF 归一化、fallback 拦截校验错误

- server.js: 恢复 script-src 中 cdn.jsdelivr.net(本地开发 Bootstrap JS 依赖)
- sync-qrcode-lib.js: 归一化 CRLF 换行符避免跨平台误报
- qrcode-generator.js: generateQRCodeWithFallback 入口处拦截 QRCodeValidationError,避免无效输入触发无意义的后端降级
2026-06-25 22:15:17 +08:00

285 lines
9.1 KiB
JavaScript

describe('BFF proxy guardrails', () => {
const originalEnv = process.env;
const OriginalRequest = global.Request;
const OriginalResponse = global.Response;
const OriginalDeno = global.Deno;
const OriginalFetch = global.fetch;
class MockHeaders {
constructor(init = {}) {
this.map = new Map();
if (init && typeof init.entries === 'function') {
Array.from(init.entries()).forEach(([key, value]) => {
this.set(key, value);
});
} else {
Object.entries(init).forEach(([key, value]) => {
this.set(key, value);
});
}
}
get(name) {
return this.map.get(String(name).toLowerCase()) || null;
}
set(name, value) {
this.map.set(String(name).toLowerCase(), String(value));
}
delete(name) {
this.map.delete(String(name).toLowerCase());
}
entries() {
return this.map.entries();
}
[Symbol.iterator]() {
return this.map[Symbol.iterator]();
}
}
class MockRequest {
constructor(url, init = {}) {
this.url = url;
this.method = init.method || 'GET';
this.headers = init.headers instanceof MockHeaders ? init.headers : new MockHeaders(init.headers || {});
this.body = init.body;
}
async arrayBuffer() {
const text = typeof this.body === 'string' ? this.body : JSON.stringify(this.body || '');
return new TextEncoder().encode(text).buffer;
}
async json() {
const text = typeof this.body === 'string' ? this.body : JSON.stringify(this.body || '');
return JSON.parse(text);
}
}
class MockResponse {
constructor(body = '', init = {}) {
this.body = body;
this.status = init.status || 200;
this.statusText = init.statusText || '';
this.headers = init.headers instanceof MockHeaders ? init.headers : new MockHeaders(init.headers || {});
}
async text() {
return typeof this.body === 'string' ? this.body : JSON.stringify(this.body);
}
}
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
ACCESS_KEY: 'test-access-key',
ALLOWED_ORIGIN: 'https://esim.cosr.eu.org'
};
global.Deno = {
env: {
get: (name) => {
if (name === 'ACCESS_KEY') return 'test-access-key';
if (name === 'ALLOWED_ORIGIN') return 'https://esim.cosr.eu.org';
return '';
}
}
};
global.Request = MockRequest;
global.Response = MockResponse;
global.fetch = jest.fn().mockResolvedValue(new MockResponse('ok', { status: 200, headers: { 'content-type': 'text/plain' } }));
});
afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
global.Request = OriginalRequest;
global.Response = OriginalResponse;
global.fetch = OriginalFetch;
global.Deno = OriginalDeno;
});
it('blocks unknown BFF targets', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/unknown-route', {
method: 'POST',
headers: { origin: 'https://esim.cosr.eu.org' }
});
const response = await mod.default(request);
expect(response.status).toBe(404);
});
it('allows same-origin public GET requests without Origin', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/public-config', {
method: 'GET'
});
const response = await mod.default(request);
expect(response.status).toBe(200);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('returns 403 for missing-origin protected POST requests', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/verify-cookie', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cookie: 'test-cookie' })
});
const response = await mod.default(request);
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBe('https://esim.cosr.eu.org');
});
it('forwards allowlisted BFF targets with the server x-esim-key', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/giffgaff-token-exchange', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json',
'x-esim-key': 'client-key',
'x-app-key': 'client-app-key'
},
body: JSON.stringify({
code: 'authorization-code',
code_verifier: 'a'.repeat(64)
})
});
const response = await mod.default(request);
expect(response.status).toBe(200);
expect(global.fetch).toHaveBeenCalledTimes(1);
const proxiedRequest = global.fetch.mock.calls[0][0];
expect(proxiedRequest.url).toBe('https://example.com/.netlify/functions/giffgaff-token-exchange');
expect(proxiedRequest.headers.get('x-esim-key')).toBe('test-access-key');
expect(proxiedRequest.headers.get('x-app-key')).toBeNull();
});
it('allows qrcode-generate BFF target (Edge 内联处理,无 proxy)', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: 'LPA:1$example', size: 300 })
});
const response = await mod.default(request);
expect(response.status).toBe(200);
// Edge 内联处理,不应调用 fetch 代理到 Netlify Function
expect(global.fetch).toHaveBeenCalledTimes(0);
// 响应应包含 QR 码 data URL
const body = JSON.parse(response.body);
expect(body.success).toBe(true);
expect(body.qrcode).toMatch(/^data:image\/gif;base64,/);
});
it('qrcode-generate: 无效 JSON body 应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: 'not-valid-json'
});
const response = await mod.default(request);
expect(response.status).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toBe('Invalid JSON body');
});
it('qrcode-generate: 空 data 应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: '', size: 300 })
});
const response = await mod.default(request);
expect(response.status).toBe(400);
});
it('qrcode-generate: 超长 data 应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: 'x'.repeat(2049), size: 300 })
});
const response = await mod.default(request);
expect(response.status).toBe(400);
});
it('qrcode-generate: 非字符串 data 应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: 12345, size: 300 })
});
const response = await mod.default(request);
expect(response.status).toBe(400);
});
it('qrcode-generate: size 超出范围应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: 'LPA:1$example', size: 601 })
});
const response = await mod.default(request);
expect(response.status).toBe(400);
});
it('qrcode-generate: size 低于最小值应返回 400', async () => {
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
const request = new Request('https://example.com/bff/qrcode-generate', {
method: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'content-type': 'application/json'
},
body: JSON.stringify({ data: 'LPA:1$example', size: 199 })
});
const response = await mod.default(request);
expect(response.status).toBe(400);
});
});