81 lines
5.0 KiB
JavaScript
81 lines
5.0 KiB
JavaScript
// Real HTTPS cookies + /state/ reverse-proxy layout; only WeChat is simulated.
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import https from 'node:https';
|
|
import { execFileSync, execSync } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
const require = createRequire(import.meta.url);
|
|
const createAccounts = require('../lib/accounts');
|
|
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
|
|
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
|
let browser, server;
|
|
const realFetch = global.fetch;
|
|
try {
|
|
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(root, 'key.pem'),
|
|
'-out', path.join(root, 'cert.pem'), '-days', '1', '-subj', '/CN=skin.example.test'], { stdio: 'ignore' });
|
|
let accounts;
|
|
server = https.createServer({ key: fs.readFileSync(path.join(root, 'key.pem')), cert: fs.readFileSync(path.join(root, 'cert.pem')) }, async (req, res) => {
|
|
const url = new URL(req.url, 'https://skin.example.test');
|
|
if (!url.pathname.startsWith('/state/')) { res.writeHead(404); return res.end(); }
|
|
req.url = req.url.slice('/state'.length); // nginx proxy_pass trailing slash
|
|
const route = url.pathname.slice('/state'.length);
|
|
if (await accounts.route(req, res, route)) return;
|
|
const file = route === '/' ? '/index.html' : route;
|
|
if (!/^\/(index\.html|(?:js|css|assets|vendor)\/[^.][^]*)$/.test(file) || file.includes('..')) { res.writeHead(404); return res.end(); }
|
|
try {
|
|
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
|
|
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream');
|
|
res.end(fs.readFileSync(path.join(h5, file)));
|
|
} catch (_) { res.writeHead(404); res.end(); }
|
|
});
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
const base = 'https://skin.example.test:' + server.address().port + '/state/';
|
|
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
|
|
global.fetch = async url => {
|
|
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
|
|
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
|
|
};
|
|
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
|
|
let firstId;
|
|
for (let device = 0; device < 2; device++) {
|
|
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.route('https://open.weixin.qq.com/**', async route => {
|
|
const auth = new URL(route.request().url());
|
|
assert.equal(auth.searchParams.get('scope'), 'snsapi_base');
|
|
const callback = new URL(auth.searchParams.get('redirect_uri'));
|
|
assert.equal(callback.href, base + 'api/auth/wechat/callback');
|
|
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
|
|
await route.fulfill({ status: 302, headers: { location: callback.href }, body: '' });
|
|
});
|
|
await page.goto(base);
|
|
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
|
await page.check('#login-consent');
|
|
await page.click('[data-action="login-wechat"]');
|
|
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
|
|
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
|
|
await page.waitForSelector('#s-splash.active');
|
|
const user = await page.evaluate(() => AccountClient.user());
|
|
assert.equal(user.kind, 'wechat');
|
|
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
|
|
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
|
|
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
|
|
await page.click('[data-action="history"]');
|
|
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
|
|
assert.deepEqual(errors, []);
|
|
await context.close();
|
|
}
|
|
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
|
|
} finally {
|
|
global.fetch = realFetch;
|
|
if (browser) await browser.close();
|
|
if (server) await new Promise(resolve => server.close(resolve));
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|