diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 95ecb40..2b2a181 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -66,7 +66,7 @@ app.use(helmet({ scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:", "https:", "http:"], - connectSrc: ["'self'", "https://qrcode.show", "https://api.qrserver.com", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"], + connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"], fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"] } } diff --git a/index.html b/index.html index 0f7c3f2..4ab41cb 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ - + eSIM Tools - Giffgaff & Simyo eSIM 在线管理工具 | 设备更换、激活与二维码生成 @@ -67,7 +67,6 @@ - diff --git a/netlify/functions/qrcode-generate.js b/netlify/functions/qrcode-generate.js index 7fb2d86..6f41509 100644 --- a/netlify/functions/qrcode-generate.js +++ b/netlify/functions/qrcode-generate.js @@ -48,6 +48,8 @@ function withTimeout(promise, timeoutMs) { } exports.handler = withAuth(async (event, context, { body }) => { + const startTime = Date.now(); + // 405 优先于 Schema 验证(确保 HTTP 方法错误优先返回) if (event.httpMethod !== 'POST') { throw new AuthError('Method Not Allowed', 405); @@ -58,6 +60,9 @@ exports.handler = withAuth(async (event, context, { body }) => { const size = normalizeSize(body.size); + // 记录请求开始(不记录 body.data,避免 LPA 激活信息泄露) + console.log(`[qrcode-generate] Request received: size=${size}, dataLength=${body.data ? body.data.length : 0}`); + try { const qrcode = await withTimeout(QRCode.toDataURL(body.data, { errorCorrectionLevel: 'M', @@ -66,6 +71,11 @@ exports.handler = withAuth(async (event, context, { body }) => { width: size }), QR_TIMEOUT_MS); + const duration = Date.now() - startTime; + + // 记录成功(不记录 QR 码内容) + console.log(`[qrcode-generate] Success: size=${size}, duration=${duration}ms, qrcodeLength=${qrcode.length}`); + return { statusCode: 200, body: JSON.stringify({ @@ -74,8 +84,10 @@ exports.handler = withAuth(async (event, context, { body }) => { }) }; } catch (error) { + const duration = Date.now() - startTime; + // 不记录 data,避免 LPA 激活信息进入日志或 Sentry。 - console.error('[qrcode-generate] QR code generation failed:', error.message); + console.error(`[qrcode-generate] Failed: error=${error.message}, duration=${duration}ms`); // 创建一个不含敏感数据的错误对象,防止 LPA 字符串通过 Sentry 泄露 const sanitizedError = new Error(error.message); diff --git a/server.js b/server.js index 68bfc71..d732488 100644 --- a/server.js +++ b/server.js @@ -62,7 +62,7 @@ app.use(helmet({ scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:", "https:", "http:"], - connectSrc: ["'self'", "https://qrcode.show", "https://api.qrserver.com", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"], + connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"], fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"] } } diff --git a/src/giffgaff/giffgaff_modular.html b/src/giffgaff/giffgaff_modular.html index 004d926..ef1cf38 100644 --- a/src/giffgaff/giffgaff_modular.html +++ b/src/giffgaff/giffgaff_modular.html @@ -15,13 +15,12 @@ - - + Giffgaff eSIM 工具 diff --git a/src/giffgaff/js/modules/api-config.js b/src/giffgaff/js/modules/api-config.js index 63f8239..c2b4e5c 100644 --- a/src/giffgaff/js/modules/api-config.js +++ b/src/giffgaff/js/modules/api-config.js @@ -34,8 +34,7 @@ export function getApiEndpoints() { : "https://publicapi.giffgaff.com/gateway/graphql", cookieVerify: "/bff/verify-cookie", autoActivate: "/bff/auto-activate-esim", - smsActivate: "/bff/giffgaff-sms-activate", - qrcode: "https://qrcode.show/" + smsActivate: "/bff/giffgaff-sms-activate" }; } diff --git a/src/js/modules/qrcode-generator.js b/src/js/modules/qrcode-generator.js index e918bbb..b054013 100644 --- a/src/js/modules/qrcode-generator.js +++ b/src/js/modules/qrcode-generator.js @@ -1,7 +1,5 @@ 'use strict'; -import { tl } from './i18n.js'; - // CDN 多源备用列表(全部已验证 HTTP 200 + 浏览器 UMD 全局变量) // 统一使用 qrcode-generator 包(UMD 格式,设置 window.qrcode 全局变量) // 注意:jsdelivr 的 qrcode@1.5.4/lib/browser.js 是 CommonJS 模块,浏览器无法使用 @@ -16,6 +14,9 @@ const MAX_QR_SIZE = 600; const MAX_QR_DATA_LENGTH = 2048; const BACKEND_ENDPOINT = '/bff/qrcode-generate'; const BACKEND_TIMEOUT_MS = 10000; +const CDN_LOAD_TIMEOUT_MS = 5000; +const QR_MARGIN_MODULES = 8; +const LARGE_PREVIEW_SIZE = 400; let qrCodeLibraryPromise = null; @@ -30,8 +31,11 @@ let qrCodeLibraryPromise = null; */ function trackQRCodeEvent({ type, source, success, duration, error }) { try { + const isBrowser = typeof window !== 'undefined'; + if (!isBrowser) return; + // 1. 上报到 Sentry(错误事件) - if (!success && typeof window !== 'undefined' && window.Sentry) { + if (!success && window.Sentry) { window.Sentry.captureMessage(`QR Code ${type} failed`, { level: 'warning', tags: { @@ -47,16 +51,14 @@ function trackQRCodeEvent({ type, source, success, duration, error }) { } // 2. 上报到 Analytics(成功和失败都上报) - if (typeof window !== 'undefined') { - window.__esimAnalytics = window.__esimAnalytics || []; - window.__esimAnalytics.push({ - event: type, - source, - success, - duration, - error: error || null - }); - } + window.__esimAnalytics = window.__esimAnalytics || []; + window.__esimAnalytics.push({ + event: type, + source, + success, + duration, + error: error || null + }); // 3. 控制台日志(开发调试) if (!success) { @@ -136,12 +138,14 @@ export async function loadQRCodeLibrary() { * @returns {Promise} QRCode 库对象 */ async function loadFromCDNWithRetry() { - for (const cdnUrl of QRCODE_CDN_URLS) { + for (let i = 0; i < QRCODE_CDN_URLS.length; i++) { + const cdnUrl = QRCODE_CDN_URLS[i]; try { const lib = await loadSingleCDN(cdnUrl); + console.log(`[QRCode] CDN loaded successfully: source=${i + 1}/${QRCODE_CDN_URLS.length}, url=${cdnUrl}`); return lib; } catch (error) { - console.warn(`[QRCode] CDN ${cdnUrl} failed: ${error.message}`); + console.warn(`[QRCode] CDN ${i + 1}/${QRCODE_CDN_URLS.length} failed: url=${cdnUrl}, error=${error.message}`); } } qrCodeLibraryPromise = null; @@ -160,7 +164,6 @@ function loadSingleCDN(cdnUrl) { return; } - const LOAD_TIMEOUT_MS = 5000; let timeoutId = null; // 移除已失效的旧脚本 @@ -205,7 +208,7 @@ function loadSingleCDN(cdnUrl) { cleanup(); script.remove(); reject(new Error(`QRCode library loading timed out from ${cdnUrl}`)); - }, LOAD_TIMEOUT_MS); + }, CDN_LOAD_TIMEOUT_MS); script.src = cdnUrl; script.async = true; @@ -231,7 +234,7 @@ function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl, labels alt = 'eSIM QR Code', ariaLabel = 'eSIM Installation QR Code', tooltipAlt = 'eSIM QR Code Preview' - } = labels || {}; + } = labels; const container = document.createElement('div'); container.className = 'qrcode-container'; @@ -281,27 +284,35 @@ export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE, labels = try { const safeData = validateQRCodeData(data); const safeSize = normalizeQRCodeSize(size); - const qrCode = await loadQRCodeLibrary(); + const qrCodeLib = await loadQRCodeLibrary(); - const imageUrl = await qrCode.toDataURL(safeData, { - errorCorrectionLevel: 'M', - margin: 2, - type: 'image/png', - width: safeSize - }); + // qrcode-generator 包的 API 与 qrcode 包不同 + // 正确用法:qrcode(typeNumber, errorCorrectionLevel).addData(data).make().createDataURL(cellSize, margin) + // typeNumber 0 = 自动检测,errorCorrectionLevel: L/M/Q/H + const qr = qrCodeLib(0, 'M'); // 0 = auto type number, M = medium error correction + qr.addData(safeData); + qr.make(); - const largeImageUrl = await qrCode.toDataURL(safeData, { - errorCorrectionLevel: 'M', - margin: 2, - type: 'image/png', - width: 400 - }); + // cellSize 计算:根据目标尺寸和 QR 码模块数计算 + // qr.getModuleCount() 返回 QR 码的模块数(行/列数) + const moduleCount = qr.getModuleCount(); + const cellSize = Math.max(1, Math.floor(safeSize / (moduleCount + QR_MARGIN_MODULES))); + const largeCellSize = Math.max(1, Math.floor(LARGE_PREVIEW_SIZE / (moduleCount + QR_MARGIN_MODULES))); + + const imageUrl = qr.createDataURL(cellSize, 2); + const largeImageUrl = qr.createDataURL(largeCellSize, 2); + + console.log(`[QRCode] Local generation success: size=${safeSize}, modules=${moduleCount}, cellSize=${cellSize}`); return { ...createQRCodeContainer(imageUrl, safeSize, largeImageUrl, labels), source: 'local' }; } catch (error) { + // 验证错误(data/size 不合法)直接抛出,不包装 + if (error.message.startsWith('QR code data') || error.message.startsWith('QR code size')) { + throw error; + } throw new Error(`Local QR code generation failed: ${error.message}`); } } @@ -338,6 +349,8 @@ export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE, labels throw new Error((payload && payload.error) || 'Backend QR code response is invalid'); } + console.log(`[QRCode] Backend generation success: size=${safeSize}, qrcodeLength=${payload.qrcode.length}`); + return { ...createQRCodeContainer(payload.qrcode, safeSize, payload.qrcode, labels), source: 'backend' diff --git a/src/js/modules/resource-hints.js b/src/js/modules/resource-hints.js index ebfaf4f..5f0f8ec 100644 --- a/src/js/modules/resource-hints.js +++ b/src/js/modules/resource-hints.js @@ -14,10 +14,7 @@ class ResourceHintsManager { 'https://cdn.jsdelivr.net', 'https://cdnjs.cloudflare.com' ], - dnsPrefetch: [ - 'https://api.qrserver.com', - 'https://qrcode.show' - ], + dnsPrefetch: [], preload: { fonts: [], scripts: [ @@ -131,7 +128,7 @@ class ResourceHintsManager { if (entry.isIntersecting) { const link = entry.target; const href = link.getAttribute('href'); - + if (href && !link.dataset.prefetched) { this.prefetchRoute(href); link.dataset.prefetched = 'true'; @@ -211,7 +208,7 @@ class ResourceHintsManager { */ createLink(attributes) { const link = document.createElement('link'); - + Object.entries(attributes).forEach(([key, value]) => { if (value) { if (key === 'crossOrigin') { @@ -255,7 +252,7 @@ class ResourceHintsManager { const options = { rel: 'preload', href, as }; if (type) options.type = type; if (as === 'font') options.crossOrigin = 'anonymous'; - + this.createLink(options); } diff --git a/src/simyo/js/modules/api-config.js b/src/simyo/js/modules/api-config.js index 44b0a11..9653ba1 100644 --- a/src/simyo/js/modules/api-config.js +++ b/src/simyo/js/modules/api-config.js @@ -51,10 +51,7 @@ export function getApiEndpoints() { // 安装确认 confirmInstall: isNetlify ? "/api/simyo/esim/reorder-profile-installed" - : `${localBase}/api/simyo/esim/reorder-profile-installed`, - - // 二维码服务 - qrcode: "https://qrcode.show/" + : `${localBase}/api/simyo/esim/reorder-profile-installed` }; } diff --git a/src/simyo/simyo_modular.html b/src/simyo/simyo_modular.html index 5b16268..18ba2f6 100644 --- a/src/simyo/simyo_modular.html +++ b/src/simyo/simyo_modular.html @@ -43,11 +43,10 @@ - - + Simyo NL eSIM 工具 diff --git a/tests/giffgaff/session-restore-lpa.test.js b/tests/giffgaff/session-restore-lpa.test.js index 91f3b87..1846bb1 100644 --- a/tests/giffgaff/session-restore-lpa.test.js +++ b/tests/giffgaff/session-restore-lpa.test.js @@ -115,7 +115,8 @@ describe('Giffgaff Session Restore - LPA Display', () => { if (state.lpaString) { const img = document.createElement('img'); - img.src = `https://qrcode.show/${encodeURIComponent(state.lpaString)}`; + // 模拟本地 QR 码生成(data URL) + img.src = `data:image/png;base64,mock-qr-${encodeURIComponent(state.lpaString)}`; img.alt = 'eSIM二维码'; qrcode.appendChild(img); } @@ -123,7 +124,7 @@ describe('Giffgaff Session Restore - LPA Display', () => { // 验证二维码生成 expect(resultContainer.classList.contains('active')).toBe(true); expect(qrcode.querySelector('img')).toBeTruthy(); - expect(qrcode.querySelector('img').src).toContain('qrcode.show'); + expect(qrcode.querySelector('img').src).toContain('data:image/png;base64'); }); test('session 恢复应该处理不完整状态(无 SSN)', () => { diff --git a/tests/modules/qrcode-generator.test.js b/tests/modules/qrcode-generator.test.js index 0bea1ed..3972405 100644 --- a/tests/modules/qrcode-generator.test.js +++ b/tests/modules/qrcode-generator.test.js @@ -3,30 +3,48 @@ */ describe('qrcode-generator', () => { + /** 创建本地生成失败的 qrcode-generator mock */ + function createFailingQRMock() { + return jest.fn(() => ({ + addData: jest.fn(), + make: jest.fn(() => { throw new Error('local failed'); }), + getModuleCount: jest.fn(() => 25), + createDataURL: jest.fn() + })); + } + beforeEach(() => { jest.resetModules(); fetch.mockReset(); delete window.QRCode; + delete window.qrcode; }); it('应该使用浏览器本地 qrcode.js 生成 img 容器', async () => { - window.QRCode = { - toDataURL: jest.fn((data, options) => Promise.resolve(`data:image/png;base64,local-${options.width}`)) + // qrcode-generator 包的 mock:window.qrcode 是一个工厂函数 + const mockQRInstance = { + addData: jest.fn(), + make: jest.fn(), + getModuleCount: jest.fn(() => 25), // 25x25 模块的 QR 码 + createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`) }; + window.qrcode = jest.fn(() => mockQRInstance); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); const result = await generateQRCodeLocal('LPA:1$example', 300); expect(result.source).toBe('local'); expect(result.container.className).toBe('qrcode-container'); - expect(result.container.querySelector('img').getAttribute('src')).toBe('data:image/png;base64,local-300'); - expect(window.QRCode.toDataURL).toHaveBeenCalledTimes(2); + expect(result.container.querySelector('img').getAttribute('src')).toContain('data:image/png;base64,local-'); + expect(window.qrcode).toHaveBeenCalledWith(0, 'M'); + expect(mockQRInstance.addData).toHaveBeenCalledWith('LPA:1$example'); + expect(mockQRInstance.make).toHaveBeenCalled(); + expect(mockQRInstance.createDataURL).toHaveBeenCalledTimes(2); // 正常尺寸 + 大尺寸 }); it('本地生成失败时应该调用后端 BFF 降级', async () => { - window.QRCode = { - toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) - }; + window.qrcode = createFailingQRMock(); + fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' }) @@ -43,9 +61,8 @@ describe('qrcode-generator', () => { }); it('本地和后端都失败时应该抛出最终错误', async () => { - window.QRCode = { - toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) - }; + window.qrcode = createFailingQRMock(); + fetch.mockResolvedValueOnce({ ok: false, status: 500 @@ -58,20 +75,18 @@ describe('qrcode-generator', () => { }); it('应该拒绝过长的二维码内容', async () => { - window.QRCode = { - toDataURL: jest.fn() - }; + // 模拟 qrcode-generator 已加载 + window.qrcode = jest.fn(); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); await expect(generateQRCodeLocal('x'.repeat(2049), 300)) - .rejects.toThrow('Local QR code generation failed'); + .rejects.toThrow('QR code data length must be between'); }); it('应该拒绝非整数的 size', async () => { - window.QRCode = { - toDataURL: jest.fn() - }; + // 模拟 qrcode-generator 已加载 + window.qrcode = jest.fn(); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); @@ -80,9 +95,8 @@ describe('qrcode-generator', () => { }); it('应该拒绝低于最小值的 size', async () => { - window.QRCode = { - toDataURL: jest.fn() - }; + // 模拟 qrcode-generator 已加载 + window.qrcode = jest.fn(); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); @@ -91,9 +105,8 @@ describe('qrcode-generator', () => { }); it('应该拒绝高于最大值的 size', async () => { - window.QRCode = { - toDataURL: jest.fn() - }; + // 模拟 qrcode-generator 已加载 + window.qrcode = jest.fn(); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); @@ -102,9 +115,8 @@ describe('qrcode-generator', () => { }); it('应该拒绝非字符串的 data', async () => { - window.QRCode = { - toDataURL: jest.fn() - }; + // 模拟 qrcode-generator 已加载 + window.qrcode = jest.fn(); const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); @@ -113,9 +125,7 @@ describe('qrcode-generator', () => { }); it('后端超时时应该抛出超时错误', async () => { - window.QRCode = { - toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) - }; + window.qrcode = createFailingQRMock(); // Mock fetch 超时(AbortError) fetch.mockImplementationOnce(() => @@ -136,9 +146,7 @@ describe('qrcode-generator', () => { }); it('后端返回无效 JSON 时应该抛出错误', async () => { - window.QRCode = { - toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) - }; + window.qrcode = createFailingQRMock(); fetch.mockResolvedValueOnce({ ok: true, @@ -150,4 +158,144 @@ describe('qrcode-generator', () => { await expect(generateQRCodeWithFallback('LPA:1$example', 300)) .rejects.toThrow('QR code generation failed after local and backend fallback'); }); + + // ========== 日志和 Sentry 上报测试 ========== + + it('本地生成成功时应输出 console.log', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const mockQRInstance = { + addData: jest.fn(), + make: jest.fn(), + getModuleCount: jest.fn(() => 25), + createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`) + }; + window.qrcode = jest.fn(() => mockQRInstance); + + const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); + await generateQRCodeLocal('LPA:1$example', 300); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[QRCode] Local generation success') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('size=300') + ); + consoleSpy.mockRestore(); + }); + + it('本地生成失败时应输出 console.warn 并上报 Sentry', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + window.Sentry = { captureMessage: jest.fn() }; + + window.qrcode = createFailingQRMock(); + + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' }) + }); + + const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js'); + await generateQRCodeWithFallback('LPA:1$example', 300); + + // 验证 console.warn 输出降级日志(第一个参数包含关键信息) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[QRCode] Local generation failed'), + expect.anything() + ); + + // 验证 Sentry captureMessage 被调用 + expect(window.Sentry.captureMessage).toHaveBeenCalledWith( + 'QR Code qr_fallback failed', + expect.objectContaining({ + level: 'warning', + tags: expect.objectContaining({ + module: 'qrcode-generation', + source: 'local', + type: 'qr_fallback' + }) + }) + ); + + delete window.Sentry; + consoleSpy.mockRestore(); + }); + + it('后端生成成功时应输出 console.log', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + window.qrcode = createFailingQRMock(); + + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend-qr-data' }) + }); + + const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js'); + await generateQRCodeWithFallback('LPA:1$example', 300); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[QRCode] Backend generation success') + ); + consoleSpy.mockRestore(); + }); + + it('后端生成失败时应输出 console.error 并上报 Sentry', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + window.Sentry = { captureMessage: jest.fn() }; + + window.qrcode = createFailingQRMock(); + + fetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js'); + await expect(generateQRCodeWithFallback('LPA:1$example', 300)) + .rejects.toThrow('QR code generation failed after local and backend fallback'); + + // 验证 console.error 输出后端失败日志(第一个参数包含关键信息) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('[QRCode] Backend fallback failed'), + expect.anything() + ); + + // 验证 Sentry 上报了后端失败事件 + expect(window.Sentry.captureMessage).toHaveBeenCalledWith( + 'QR Code qr_generation failed', + expect.objectContaining({ + level: 'warning', + tags: expect.objectContaining({ + source: 'failed' + }) + }) + ); + + delete window.Sentry; + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('CDN 加载成功时应输出 console.log', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const mockQRInstance = { + addData: jest.fn(), + make: jest.fn(), + getModuleCount: jest.fn(() => 25), + createDataURL: jest.fn((cellSize, margin) => `data:image/png;base64,local-${cellSize}`) + }; + window.qrcode = jest.fn(() => mockQRInstance); + + const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); + await generateQRCodeLocal('LPA:1$example', 300); + + // 如果 window.qrcode 已存在,CDN 不会被加载,所以检查是否有 CDN 或 Local 日志 + const allCalls = consoleSpy.mock.calls.flat().join(' '); + expect(allCalls).toMatch(/\[QRCode\].*(CDN loaded|Local generation success)/); + + consoleSpy.mockRestore(); + }); }); diff --git a/tests/test_giffgaff_esim.html b/tests/test_giffgaff_esim.html index 7d73ebf..03a2bea 100644 --- a/tests/test_giffgaff_esim.html +++ b/tests/test_giffgaff_esim.html @@ -808,9 +808,9 @@ const lpaString = tokenResponse.data.eSimDownloadToken.lpaString; testFramework.assertTrue(lpaString.startsWith('LPA:'), 'LPA string should start with LPA:'); - // 模拟二维码生成 - const qrUrl = `https://qrcode.show/${encodeURIComponent(lpaString)}?size=300`; - testFramework.assertTrue(qrUrl.includes('qrcode.show'), 'Should generate QR code URL'); + // 模拟本地二维码生成(data URL) + const qrUrl = `data:image/png;base64,mock-qr-${encodeURIComponent(lpaString)}`; + testFramework.assertTrue(qrUrl.startsWith('data:image/png;base64'), 'Should generate QR code data URL'); return true; }); diff --git a/tests/test_simyo_esim.html b/tests/test_simyo_esim.html index 77cfd34..74b1925 100644 --- a/tests/test_simyo_esim.html +++ b/tests/test_simyo_esim.html @@ -684,9 +684,9 @@ testFramework.assertTrue(lpaString.startsWith('LPA:'), 'Should generate valid LPA string'); - // 模拟二维码URL生成 - const qrUrl = `https://qrcode.show/${encodeURIComponent(lpaString)}?size=300`; - testFramework.assertTrue(qrUrl.includes('qrcode.show'), 'Should generate QR code URL'); + // 模拟本地二维码生成(data URL) + const qrUrl = `data:image/png;base64,mock-qr-${encodeURIComponent(lpaString)}`; + testFramework.assertTrue(qrUrl.startsWith('data:image/png;base64'), 'Should generate QR code data URL'); await testFramework.sleep(500); return true;