Files
memind/server/portal-wechat-auth-routes.test.mjs

805 lines
17 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
attachPortalWechatAuthRoutes,
} from './portal-wechat-auth-routes.mjs';
function createResponse() {
return {
statusCode: 200,
headers: {},
body: undefined,
redirectUrl: null,
status(code) {
this.statusCode = code;
return this;
},
set(name, value) {
this.headers[name] = value;
return this;
},
json(body) {
this.body = body;
return this;
},
redirect(code, url) {
this.statusCode = code;
this.redirectUrl = url;
return this;
},
send(body) {
this.body = body;
return this;
},
};
}
function createSetup(overrides = {}) {
const routes = [];
const calls = [];
let userAuth = {
async getMe(token) {
calls.push(['get-me', token]);
return { id: 'user-1' };
},
async getWechatBindingStatus(userId, appId) {
calls.push([
'binding-status',
userId,
appId,
]);
return {
bound: true,
nickname: 'John',
};
},
async getWechatPendingBind(token) {
calls.push(['pending', token]);
return {
nickname: '微信用户',
avatar_url: 'avatar.png',
return_to: '/chat',
};
},
async completeWechatRegister(options) {
calls.push(['register', options]);
return {
ok: true,
token: 'register-token',
user: { id: 'user-2' },
isNewUser: true,
returnTo: '/welcome',
utmSource: 'wechat-menu',
utmMedium: 'oauth',
utmCampaign: 'summer',
};
},
async completeWechatBindAccount(options) {
calls.push(['bind', options]);
return {
ok: true,
token: 'bind-token',
user: { id: 'user-1' },
returnTo: '/bound',
};
},
};
let wechatOAuthService = {
enabled: true,
publicConfig(req) {
calls.push(['public-config', req]);
return {
enabled: true,
scanEnabled: true,
};
},
async startScanLogin(req) {
calls.push(['scan-start', req]);
return {
state: 'scan-state',
url: 'https://wechat.example/scan',
};
},
async pollScanLogin(state) {
calls.push(['scan-poll', state]);
return {
status: 'complete',
token: 'scan-token',
};
},
async buildAuthorizeRedirect(req, options) {
calls.push([
'authorize',
req,
options,
]);
return 'https://wechat.example/authorize';
},
async handleCallback(options) {
calls.push(['callback', options]);
return {
token: 'callback-token',
user: { id: 'user-3' },
isNewUser: true,
returnTo: '/after-wechat',
utmMedium: 'oauth',
};
},
};
let wechatMpService = {
enabled: true,
async createJsSdkSignature(url) {
calls.push(['signature', url]);
return {
appId: 'mp-app',
nonceStr: 'nonce',
};
},
async getRouteStatusForUser(userId) {
calls.push(['route-status', userId]);
return {
enabled: true,
routeId: 'route-1',
};
},
async recreateRouteForUser(userId) {
calls.push(['route-reset', userId]);
return {
ok: true,
routeId: 'route-2',
};
},
};
const jsonBody = (_req, _res, next) => next();
const app = {
get(path, ...handlers) {
routes.push({
method: 'get',
path,
handlers,
});
},
post(path, ...handlers) {
routes.push({
method: 'post',
path,
handlers,
});
},
};
const plazaSeo = {
recordAttribution(payload, ip) {
calls.push([
'attribution',
payload,
ip,
]);
return Promise.resolve();
},
};
const options = {
app,
jsonBody,
userAuthReady: Promise.resolve(),
getUserAuth: () => userAuth,
getWechatOAuthService: () =>
wechatOAuthService,
getWechatMpService: () => wechatMpService,
wechatMpConfig: {
enabled: true,
publicBaseUrl: 'https://portal.example',
},
userToken: () => 'request-token',
setUserLoginCookies(_res, _req, token) {
calls.push(['set-cookies', token]);
},
isSecureRequest() {
calls.push(['secure']);
return true;
},
getPlazaSeo: () => plazaSeo,
plazaClientIp: () => '203.0.113.8',
logger: {
warn(...args) {
calls.push(['warn', ...args]);
},
error(...args) {
calls.push(['error', ...args]);
},
},
isWechatUserAgentFn(userAgent) {
calls.push(['wechat-agent', userAgent]);
return userAgent.includes('MicroMessenger');
},
loadWechatOAuthConfigFn: () => ({
appId: 'oauth-app',
}),
validateWechatShareSignatureUrlFn(
url,
validationOptions,
) {
calls.push([
'validate-signature-url',
url,
validationOptions,
]);
return `validated:${url}`;
},
...overrides,
};
attachPortalWechatAuthRoutes(options);
return {
routes,
calls,
jsonBody,
setUserAuth(value) {
userAuth = value;
},
setWechatOAuthService(value) {
wechatOAuthService = value;
},
setWechatMpService(value) {
wechatMpService = value;
},
route(method, path) {
return routes.find(
(route) =>
route.method === method &&
route.path === path,
);
},
};
}
async function invoke(
setup,
method,
path,
req = {},
) {
const route = setup.route(method, path);
assert.ok(
route,
`${method.toUpperCase()} ${path}`,
);
const res = createResponse();
const headers = {
host: 'portal.example',
...req.headers,
};
await route.handlers.at(-1)(
{
body: {},
query: {},
params: {},
ip: '127.0.0.1',
get(name) {
return headers[String(name).toLowerCase()] ?? '';
},
...req,
},
res,
);
return res;
}
test('registers the WeChat auth route inventory, order, and conditional Agent routes', () => {
const setup = createSetup();
assert.deepEqual(
setup.routes.map(({ method, path }) => [
method,
path,
]),
[
['get', '/auth/wechat/config'],
['get', '/auth/wechat/js-sdk-signature'],
[
'get',
'/auth/wechat/public-js-sdk-signature',
],
['get', '/auth/wechat/status'],
['get', '/auth/wechat/agent-route'],
[
'post',
'/auth/wechat/agent-route/reset',
],
['get', '/auth/wechat/pending/:token'],
['post', '/auth/wechat/register'],
['post', '/auth/wechat/bind'],
['post', '/auth/wechat/scan/start'],
['get', '/auth/wechat/scan/poll'],
['get', '/auth/wechat/authorize'],
['get', '/auth/wechat/callback'],
],
);
assert.equal(
setup.route(
'post',
'/auth/wechat/register',
).handlers[0],
setup.jsonBody,
);
assert.equal(
setup.route(
'post',
'/auth/wechat/bind',
).handlers[0],
setup.jsonBody,
);
const disabled = createSetup({
wechatMpConfig: {
enabled: false,
publicBaseUrl: '',
},
});
assert.equal(
disabled.route(
'get',
'/auth/wechat/agent-route',
),
undefined,
);
assert.equal(disabled.routes.length, 11);
});
test('preserves public WeChat configuration modes', async () => {
const disabled = createSetup();
disabled.setWechatOAuthService(null);
const disabledRes = await invoke(
disabled,
'get',
'/auth/wechat/config',
{
headers: {
'user-agent': 'MicroMessenger/8.0',
},
},
);
assert.deepEqual(disabledRes.body, {
enabled: false,
inWechat: true,
scanEnabled: false,
});
const enabled = createSetup();
const enabledRes = await invoke(
enabled,
'get',
'/auth/wechat/config',
);
assert.deepEqual(enabledRes.body, {
enabled: true,
scanEnabled: true,
});
});
test('preserves authenticated and public JS-SDK signature flows', async () => {
const setup = createSetup();
const privateRes = await invoke(
setup,
'get',
'/auth/wechat/js-sdk-signature',
{
query: {
url: 'https://portal.example/page#section',
},
headers: {
'x-forwarded-host': 'public.example',
},
},
);
assert.deepEqual(privateRes.body, {
appId: 'mp-app',
nonceStr: 'nonce',
});
assert.deepEqual(
setup.calls.find(
([name]) =>
name === 'validate-signature-url',
),
[
'validate-signature-url',
'https://portal.example/page',
{
publicBaseUrl: 'https://portal.example',
requestHost: 'public.example',
},
],
);
const publicRes = await invoke(
setup,
'get',
'/auth/wechat/public-js-sdk-signature',
{
query: {
url: 'https://portal.example/public',
},
},
);
assert.equal(publicRes.statusCode, 200);
setup.setUserAuth(null);
const unavailable = await invoke(
setup,
'get',
'/auth/wechat/js-sdk-signature',
{
query: { url: 'https://portal.example' },
},
);
assert.equal(unavailable.statusCode, 503);
});
test('preserves JS-SDK validation and signing errors', async () => {
const missing = createSetup();
const missingRes = await invoke(
missing,
'get',
'/auth/wechat/public-js-sdk-signature',
);
assert.equal(missingRes.statusCode, 400);
const failing = createSetup({
validateWechatShareSignatureUrlFn() {
throw new Error('invalid signature url');
},
});
const failingRes = await invoke(
failing,
'get',
'/auth/wechat/public-js-sdk-signature',
{
query: {
url: 'https://invalid.example',
},
},
);
assert.equal(failingRes.statusCode, 502);
assert.deepEqual(failingRes.body, {
message: 'invalid signature url',
});
});
test('preserves binding status and公众号 Agent route operations', async () => {
const setup = createSetup();
const status = await invoke(
setup,
'get',
'/auth/wechat/status',
);
assert.deepEqual(status.body, {
enabled: true,
bound: true,
nickname: 'John',
});
const routeStatus = await invoke(
setup,
'get',
'/auth/wechat/agent-route',
);
assert.deepEqual(routeStatus.body, {
enabled: true,
routeId: 'route-1',
});
const reset = await invoke(
setup,
'post',
'/auth/wechat/agent-route/reset',
);
assert.deepEqual(reset.body, {
ok: true,
routeId: 'route-2',
});
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'binding-status' &&
call[1] === 'user-1' &&
call[2] === 'oauth-app',
),
);
});
test('preserves pending bind lookup and WeChat registration attribution', async () => {
const setup = createSetup();
const pending = await invoke(
setup,
'get',
'/auth/wechat/pending/:token',
{
params: { token: 'pending-1' },
},
);
assert.deepEqual(pending.body, {
nickname: '微信用户',
avatarUrl: 'avatar.png',
returnTo: '/chat',
});
const registered = await invoke(
setup,
'post',
'/auth/wechat/register',
{
body: { pendingToken: 'pending-1' },
},
);
assert.deepEqual(registered.body, {
authenticated: true,
user: { id: 'user-2' },
returnTo: '/welcome',
});
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'set-cookies' &&
call[1] === 'register-token',
),
);
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'attribution' &&
call[1].utm_source === 'wechat-menu' &&
call[2] === '203.0.113.8',
),
);
});
test('preserves WeChat account binding validation, retry limits, and success', async () => {
const missing = createSetup();
const missingRes = await invoke(
missing,
'post',
'/auth/wechat/bind',
{
body: {
pendingToken: 'pending-1',
username: '',
password: '',
},
},
);
assert.equal(missingRes.statusCode, 400);
const limited = createSetup();
limited.setUserAuth({
async completeWechatBindAccount() {
return {
ok: false,
message: 'too many attempts',
retryAfterMs: 2500,
};
},
});
const limitedRes = await invoke(
limited,
'post',
'/auth/wechat/bind',
{
body: {
pendingToken: 'pending-1',
username: 'john',
password: 'secret',
},
},
);
assert.equal(limitedRes.statusCode, 429);
assert.equal(
limitedRes.headers['Retry-After'],
'3',
);
const success = createSetup();
const successRes = await invoke(
success,
'post',
'/auth/wechat/bind',
{
body: {
pendingToken: 'pending-1',
username: 'john',
password: 'secret',
},
},
);
assert.deepEqual(successRes.body, {
authenticated: true,
user: { id: 'user-1' },
bound: true,
returnTo: '/bound',
});
});
test('preserves scan start, polling, and completed-login cookies', async () => {
const setup = createSetup();
const started = await invoke(
setup,
'post',
'/auth/wechat/scan/start',
);
assert.deepEqual(started.body, {
state: 'scan-state',
url: 'https://wechat.example/scan',
});
const polled = await invoke(
setup,
'get',
'/auth/wechat/scan/poll',
{
query: { state: 'scan-state' },
},
);
assert.deepEqual(polled.body, {
status: 'complete',
token: 'scan-token',
});
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'set-cookies' &&
call[1] === 'scan-token',
),
);
setup.setWechatOAuthService(null);
const unavailable = await invoke(
setup,
'post',
'/auth/wechat/scan/start',
);
assert.equal(unavailable.statusCode, 503);
});
test('preserves authorize login and authenticated binding intents', async () => {
const setup = createSetup();
const login = await invoke(
setup,
'get',
'/auth/wechat/authorize',
);
assert.equal(login.statusCode, 302);
assert.equal(
login.redirectUrl,
'https://wechat.example/authorize',
);
const binding = await invoke(
setup,
'get',
'/auth/wechat/authorize',
{
query: { intent: ' BIND ' },
},
);
assert.equal(binding.statusCode, 302);
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'authorize' &&
call[2].bindUserId === 'user-1',
),
);
setup.setUserAuth({
async getMe() {
return null;
},
});
const unauthorized = await invoke(
setup,
'get',
'/auth/wechat/authorize',
{
query: { intent: 'bind' },
},
);
assert.equal(unauthorized.statusCode, 401);
});
test('preserves callback gates, attribution, cookies, scan HTML, and errors', async () => {
const setup = createSetup();
const completed = await invoke(
setup,
'get',
'/auth/wechat/callback',
{
query: {
code: 'oauth-code',
state: 'oauth-state',
},
ip: '198.51.100.4',
},
);
assert.equal(completed.statusCode, 302);
assert.equal(
completed.redirectUrl,
'/after-wechat',
);
assert.ok(
setup.calls.some(
(call) =>
call[0] === 'set-cookies' &&
call[1] === 'callback-token',
),
);
const gate = createSetup();
gate.setWechatOAuthService({
enabled: true,
async handleCallback() {
return {
action: 'binding_gate',
pendingToken: 'pending token',
returnTo: '/chat?a=1',
};
},
});
const gateRes = await invoke(
gate,
'get',
'/auth/wechat/callback',
);
assert.equal(
gateRes.redirectUrl,
'/?wechat_pending=pending+token&return_to=%2Fchat%3Fa%3D1',
);
const scan = createSetup();
scan.setWechatOAuthService({
enabled: true,
async handleCallback() {
return {
authMode: 'scan',
token: 'unused',
};
},
});
const scanRes = await invoke(
scan,
'get',
'/auth/wechat/callback',
);
assert.match(scanRes.body, /扫码登录成功/);
assert.equal(
scan.calls.some(
([name]) => name === 'set-cookies',
),
false,
);
const failing = createSetup();
failing.setWechatOAuthService({
enabled: true,
async handleCallback() {
throw new Error('callback failed');
},
});
const failingRes = await invoke(
failing,
'get',
'/auth/wechat/callback',
);
assert.equal(
failingRes.redirectUrl,
'/?wechat_error=callback%20failed',
);
failing.setWechatOAuthService(null);
const unavailable = await invoke(
failing,
'get',
'/auth/wechat/callback',
);
assert.equal(
unavailable.redirectUrl,
'/?wechat_error=unavailable',
);
});