805 lines
17 KiB
JavaScript
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',
|
|
);
|
|
});
|