Files
eSIM-Tools/tests/security/bff-proxy.test.js
Abner f5c5dee5ab 🐛 fix: 修复 Session 恢复后二维码和 LPA 不显示的问题
问题:
- 用户完成 eSIM 激活后,因外部二维码服务不可用导致页面空白
- Session 恢复时未调用 showESimResult() 显示二维码和 LPA 信息

解决方案:
1. 新增通用二维码生成模块 (src/js/modules/qrcode-generator.js)
   - 实现三层降级策略:本地 CDN → 后端 Function → 文本提示
   - 消除对外部服务的依赖,提升隐私保护

2. 新增后端 Function (netlify/functions/qrcode-generate.js)
   - POST /bff/qrcode-generate 接口
   - 返回 base64 编码的 PNG 二维码
   - withAuth 中间件保护 + 输入验证

3. 重构前端二维码生成逻辑
   - Giffgaff/Simyo 统一使用 generateQRCodeWithFallback()
   - 保留并发调用防护和 tooltip 交互
   - 使用 i18n 翻译替代硬编码错误提示

4. 修复 Session 恢复逻辑
   - 在 handleSessionRestore() 中调用 showESimResult()
   - 确保刷新页面后二维码和 LPA 正常显示

技术改进:
- 懒加载 qrcode.js(~13KB gzip),仅在首次调用时加载
- 完整的测试覆盖(前端单元测试 + 后端安全测试)
- 更新 BFF 路由配置(Edge Function + 本地开发服务器)

Closes #75
2026-06-13 19:20:22 +08:00

186 lines
5.8 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;
}
}
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', 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);
expect(global.fetch).toHaveBeenCalledTimes(1);
const proxiedRequest = global.fetch.mock.calls[0][0];
expect(proxiedRequest.url).toBe('https://example.com/.netlify/functions/qrcode-generate');
expect(proxiedRequest.headers.get('x-esim-key')).toBe('test-access-key');
});
});