63 lines
8.6 KiB
JavaScript
63 lines
8.6 KiB
JavaScript
// Real React UI, mocked Tencent SDK/API: no SMS or chargeable verification calls.
|
|||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { createRequire } from 'node:module';
|
||
|
|
import { resolve } from 'node:path';
|
||
|
|
import { pathToFileURL } from 'node:url';
|
||
|
|
import { mkdir } from 'node:fs/promises';
|
||
|
|
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
|
||
|
|
const { chromium } = require('playwright');
|
||
|
|
const { createServer } = await import(pathToFileURL(resolve('web/node_modules/vite/dist/node/index.js')));
|
||
|
|
const server = await createServer({ configFile: false, root: resolve('web'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 5325, strictPort: true }, plugins: [{ name: 'captcha-test-harness', resolveId(id) { if (id === '/captcha-harness.jsx') return id; }, load(id) { if (id === '/captcha-harness.jsx') return `import '/src/styles.css';import '/src/maintenance-console.css';import React from 'react';import {createRoot} from 'react-dom/client';import {CaptchaPolicySettings} from '/src/components/CaptchaPolicySettings.tsx';createRoot(document.getElementById('root')).render(<CaptchaPolicySettings tenant="test-tenant" token="test-token"/>);`; } }] });
|
||
|
|
await server.listen(); let browser, currentPage; const errors = [], checks = [];
|
||
|
|
try {
|
||
|
|
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
|
||
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); currentPage = page; page.on('pageerror', e => errors.push(e.message));
|
||
|
|
await page.route('https://turing.captcha.qcloud.com/TJCaptcha.js', r => r.fulfill({ contentType: 'text/javascript', body: `window.TencentCaptcha=class{constructor(id,cb){window.lastCaptchaApp=id;this.cb=cb;}show(){setTimeout(()=>this.cb(window.captchaResult||{ret:0,ticket:'web-ticket',randstr:'random'}),0)}destroy(){}}` }));
|
||
|
|
let generation = 0, sends = 0, passwords = 0, mode = 'tencent';
|
||
|
|
let expireNextCaptcha = true;
|
||
|
|
let policy = { saas_mode: 'image', mini_mode: 'image', version: 1, saas_configured: true, mini_configured: false }, writes = 0;
|
||
|
|
await page.route('**/api/v1/**', async route => {
|
||
|
|
const req = route.request(), path = new URL(req.url()).pathname, body = req.method() === 'POST' ? req.postDataJSON() : undefined;
|
||
|
|
let status = 200, code = 'OK', message = '', data = {};
|
||
|
|
if (path.endsWith('/account/session')) { status = 401; code = 'LOGIN_REQUIRED'; }
|
||
|
|
else if (path.endsWith('/login/session')) data = { sms_enabled: true, csrf_token: 'login-csrf', password_enabled: true };
|
||
|
|
else if (path.endsWith('/login/captcha')) { data = { captcha_id: 'captcha-' + ++generation, mode, captcha_app_id: mode === 'tencent' ? '123' : undefined, image: mode === 'image' ? 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' : undefined, expires_at_ms: Date.now() + (expireNextCaptcha ? -1000 : 120000) }; expireNextCaptcha = false; }
|
||
|
|
else if (path.endsWith('/sms/send')) { assert.equal(body.captcha_ticket, 'web-ticket'); assert.equal(body.captcha_randstr, 'random'); assert.equal(body.captcha_code, undefined); assert.equal(body.captcha_id, 'captcha-' + generation); sends++; data = { sms_challenge_id: 'sms', expires_at_ms: Date.now() + 300000, resend_after_ms: 60000 }; }
|
||
|
|
else if (path.endsWith('/login/password')) { assert.equal(body.captcha_ticket, 'web-ticket'); assert.equal(body.captcha_randstr, 'random'); passwords++; status = 401; code = 'PASSWORD_INVALID'; message = '手机号或密码不正确'; }
|
||
|
|
else if (path === '/api/v1/ops/login-captcha-policy') {
|
||
|
|
assert.equal(req.headers()['x-tenant-id'], 'test-tenant'); assert.equal(req.headers().authorization, 'Bearer test-token');
|
||
|
|
if (body) { assert.equal(body.version, policy.version); assert.equal(body.saas_mode, 'tencent'); assert.equal(body.mini_mode, 'image'); writes++; policy = { ...policy, ...body, version: policy.version + 1 }; }
|
||
|
|
data = policy;
|
||
|
|
} else throw new Error('Unexpected API ' + path);
|
||
|
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ ok: status === 200, code, message, data }) });
|
||
|
|
});
|
||
|
|
await page.goto('http://127.0.0.1:5325/'); await page.getByLabel('手机号', { exact: true }).waitFor();
|
||
|
|
assert.equal(await page.locator('#customer-image-code').count(), 0); await page.getByLabel('手机号', { exact: true }).fill('13800000000');
|
||
|
|
assert.equal(await page.getByText('安全验证', { exact: true }).count(), 0);
|
||
|
|
assert.equal(await page.getByRole('button', { name: '刷新验证', exact: true }).count(), 0);
|
||
|
|
assert.equal(await page.evaluate(() => !!window.TencentCaptcha), false);
|
||
|
|
const beforeExpiredRetry = generation;
|
||
|
|
await page.evaluate(() => { window.captchaResult = { ret: 2 }; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText('已取消验证', { exact: true }).waitFor(); assert.equal(sends, 0);
|
||
|
|
assert(generation > beforeExpiredRetry, 'Expired hidden challenge is renewed when send is clicked');
|
||
|
|
checks.push('no security panel or SDK on initial render; clicking send renews an expired challenge and opens Tencent verification');
|
||
|
|
await page.evaluate(() => { window.captchaResult = { ret: 0, ticket: 'trerror_123', randstr: 'random' }; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText('人机验证未通过,请重试', { exact: true }).waitFor(); assert.equal(sends, 0);
|
||
|
|
await page.evaluate(() => { window.captchaResult = undefined; }); await page.getByRole('button', { name: '获取验证码', exact: true }).click(); await page.getByText(/验证码已发送/).waitFor(); assert.equal(sends, 1); assert.equal(await page.evaluate(() => window.lastCaptchaApp), '123');
|
||
|
|
checks.push('SaaS SDK ticket submitted only after successful verification; cancel and disaster tickets cannot send SMS');
|
||
|
|
await page.getByRole('button', { name: '密码登录', exact: true }).click(); await page.getByLabel('密码', { exact: true }).fill('WrongPassword123!'); await page.locator('.sms-login-form').getByRole('button', { name: /^登\s*录$/ }).click(); await page.getByText('手机号或密码不正确', { exact: true }).waitFor(); assert.equal(passwords, 1);
|
||
|
|
checks.push('password login uses Tencent proof and refreshes after failure');
|
||
|
|
assert.equal(await page.getByRole('button', { name: '忘记密码', exact: true }).count(), 1);
|
||
|
|
mode = 'image'; await page.reload(); await page.getByLabel('图片验证码', { exact: true }).waitFor();
|
||
|
|
checks.push('server policy switch returns numeric image UI without changing the client');
|
||
|
|
await page.route('**/__captcha-settings', r => r.fulfill({ contentType: 'text/html', body: '<div id="root"></div><script type="module" src="/captcha-harness.jsx"></script>' }));
|
||
|
|
await page.goto('http://127.0.0.1:5325/__captcha-settings'); await page.getByText('登录与短信安全验证', { exact: true }).waitFor();
|
||
|
|
const web = page.getByRole('radiogroup', { name: 'SaaS / 管理端验证码方式' }), mini = page.getByRole('radiogroup', { name: '微信小程序验证码方式' });
|
||
|
|
assert(await mini.getByRole('radio', { name: /^腾讯云验证码/ }).isDisabled());
|
||
|
|
await web.getByRole('radio', { name: /^腾讯云验证码/ }).check(); await page.getByRole('button', { name: '保存验证码设置' }).click(); await page.getByText(/已保存。新验证立即生效/).waitFor(); assert.equal(writes, 1);
|
||
|
|
await page.reload(); await page.getByText('登录与短信安全验证', { exact: true }).waitFor(); assert(await web.getByRole('radio', { name: /^腾讯云验证码/ }).isChecked());
|
||
|
|
await mkdir(resolve('.design-evidence/tencent-captcha'), { recursive: true }); await page.screenshot({ path: resolve('.design-evidence/tencent-captcha/settings.png'), fullPage: true });
|
||
|
|
await page.setViewportSize({ width: 375, height: 850 }); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||
|
|
checks.push('admin settings keep clients independent, disable unconfigured Tencent mode, save and reload; mobile layout has no horizontal overflow');
|
||
|
|
assert.deepEqual(errors, []); console.log(JSON.stringify({ checks, errors }));
|
||
|
|
} catch (error) { console.error((await currentPage?.locator('body').innerText())?.slice(0,2000)); console.error(await currentPage?.locator('button').evaluateAll(items=>items.map(x=>({text:x.textContent,html:x.outerHTML.slice(0,800)})))); console.error(await currentPage?.evaluate(()=>({sdk:window.TencentCaptcha?.toString().slice(0,150),result:window.captchaResult,body:document.body.getAttribute('aria-hidden')}))); console.error(errors); throw error; }
|
||
|
|
finally { await browser?.close(); await server.close(); }
|