Files
order_site/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts
T

330 lines
11 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { WEIBO_TICKET_PREFIX, WeiboNativeConnector } from './weibo-native-connector.js'
type CapturedCall = { url: string; init: RequestInit }
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
function createFetchMock(
handler: (url: string, init: RequestInit) => Response | Promise<Response>,
) {
const calls: CapturedCall[] = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: String(input.url)
const requestInit = init || {}
calls.push({ url, init: requestInit })
return handler(url, requestInit)
}) as typeof fetch
return { fetchImpl, calls }
}
function defaultHandler(url: string): Response {
if (url.includes('/sso/signin')) {
return new Response('<html></html>', {
status: 200,
headers: {
'content-type': 'text/html',
'set-cookie': 'X-CSRF-TOKEN=CK-demo; Path=/sso',
},
})
}
if (url.includes('/sso/v2/web/config')) {
return jsonResponse({
retcode: 20000000,
data: {
country_code: [
{ country_code: '86', local: { zh_CN: '中国' } },
{ country_code: '852', local: { zh_CN: '中国香港' } },
{ country_code: '886', local: { zh_CN: '中国台湾' } },
],
},
})
}
if (url.includes('/sso/v2/qrcode/image')) {
return jsonResponse({
retcode: 20000000,
data: { qrid: 'qr-1', image: 'https://v2.qr.weibo.cn/qr-demo.png' },
})
}
if (url.includes('/sso/v2/qrcode/check')) {
return jsonResponse({ retcode: 50114001, msg: '未使用', data: null })
}
if (url.startsWith('https://v2.qr.weibo.cn/')) {
return new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
})
}
if (url.includes('/sso/v2/sms/send')) {
return jsonResponse({ retcode: 20000000 })
}
if (url.includes('/sso/v2/login')) {
const headers = new Headers({
'content-type': 'application/json',
})
headers.append('set-cookie', 'SUB=demo-sub; Path=/; Domain=.weibo.com')
headers.append('set-cookie', 'PASSPORT_ONLY=demo-passport; Path=/sso')
return new Response(
JSON.stringify({
retcode: 20000000,
data: {
data: {
act: 'goto',
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo',
},
},
}),
{
status: 200,
headers,
},
)
}
if (url.includes('/sso/v2/goto')) {
return new Response(null, {
status: 302,
headers: {
location: 'https://weibo.com/',
'set-cookie': 'SUB=demo-sub; Path=/; Domain=.weibo.com',
},
})
}
if (url === 'https://weibo.com/') {
return new Response('<html></html>', {
status: 200,
headers: { 'content-type': 'text/html' },
})
}
if (url.includes('/ajax/side/info')) {
return jsonResponse({
data: {
user: {
id: 123456,
screen_name: '微博测试账号',
avatar_large: 'https://tvax1.sinaimg.cn/demo.jpg',
},
},
})
}
return new Response('not found', { status: 404 })
}
test('微博二维码会话解析 qrid 并把图片转成 base64', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' })
assert.equal(session.status, 'waiting_scan')
assert.match(session.qrImage || '', /^data:image\/png;base64,/)
assert.ok(calls.some((call) => call.url.includes('qrcode/image?entry=miniblog&size=180')))
})
test('微博二维码未扫码时保持等待扫码状态', async () => {
const { fetchImpl } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' })
const polled = await connector.getQrLoginSession(session.sessionId)
assert.equal(polled.status, 'waiting_scan')
})
test('微博发送短信验证码使用大陆 11 位手机号', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '18900762182' })
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
const form = new URLSearchParams(String(call.init.body))
assert.equal(form.get('entry'), 'miniblog')
assert.equal(form.get('mobile'), 'T3JH3iRZfvXRi9K')
assert.equal(form.get('mfa_id'), null)
assert.equal(form.get('el'), '2')
})
test('微博短信发送支持其他国家码并按 00 前缀编码', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({
taskId: 1,
mobile: '61234567',
countryCode: '852',
})
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
const form = new URLSearchParams(String(call.init.body))
assert.equal(form.get('mobile'), 'TiNs13aS9CXDe9Nva3')
})
test('微博国家码列表来自 web config 并缓存', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const first = await connector.getCountryCodes()
const second = await connector.getCountryCodes()
assert.deepEqual(first, [
{ code: '86', name: '中国' },
{ code: '852', name: '中国香港' },
{ code: '886', name: '中国台湾' },
])
assert.deepEqual(second, first)
assert.equal(calls.filter((call) => call.url.includes('/sso/v2/web/config')).length, 1)
})
test('微博短信登录会带上发码响应下发的 Cookie', async () => {
const { fetchImpl, calls } = createFetchMock((url) => {
if (url.includes('/sso/v2/sms/send')) {
return new Response(JSON.stringify({ retcode: 20000000 }), {
status: 200,
headers: {
'content-type': 'application/json',
'set-cookie': 'SCF=send-cookie; Path=/',
},
})
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '13800138000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
const redirectCall = calls.find((item) => item.url.includes('/sso/v2/goto'))
assert.ok(redirectCall)
const headers = redirectCall.init.headers as Record<string, string>
assert.match(headers.cookie || '', /SCF=send-cookie/)
})
test('微博短信登录建立会话并可生成一次性注入票据', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '138 0013 8000' })
const { sessionId, session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '138 0013 8000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
assert.equal(session.huyaUid, '123456')
assert.equal(session.nickname, '微博测试账号')
const loginCall = calls.find((item) => item.url.includes('/sso/v2/login'))
assert.ok(loginCall)
const form = new URLSearchParams(String(loginCall.init.body))
assert.equal(form.get('type'), '2')
assert.equal(form.get('username'), 'T3Gs3iR_9fN7A9t')
assert.equal(form.get('scode'), 'T3r81iMS')
const ticket = await connector.createBrowserTicket({
sessionId,
targetUrl: 'https://weibo.com/',
})
assert.ok(ticket.ticket.startsWith(WEIBO_TICKET_PREFIX))
const payload = await connector.consumeBrowserTicket(ticket.ticket)
assert.equal(payload.platform, 'weibo')
assert.equal(payload.targetUrl, 'https://weibo.com/')
assert.ok(payload.cookies.some((cookie) => cookie.name === 'SUB' && cookie.value === 'demo-sub'))
await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/)
})
test('微博短信发送遇到 mfa_1 时返回易盾挑战,完成挑战后可用 mfa_id 重发', async () => {
let sendCount = 0
const { fetchImpl, calls } = createFetchMock((url, init) => {
if (url.includes('/sso/v2/sms/send')) {
sendCount += 1
const form = new URLSearchParams(String(init.body))
if (!form.get('mfa_id')) {
return jsonResponse({
retcode: 0,
msg: 'need mfa',
data: { act: 'mfa_1', mfa_id: 'mfa-demo' },
})
}
return jsonResponse({ retcode: 20000000, msg: '', data: [] })
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
const first = await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
assert.equal(first.mfaRequired, true)
assert.equal(first.mfaId, 'mfa-demo')
assert.equal(first.captchaId, '7cda0ba2785647ada4d6e946ddb2d465')
const second = await connector.sendSmsCode({
taskId: 1,
mobile: '13800138000',
mfaId: 'mfa-demo',
})
assert.equal(second.mfaRequired, false)
assert.equal(sendCount, 2)
const secondForm = new URLSearchParams(
String(calls.filter((call) => call.url.includes('/sso/v2/sms/send'))[1]?.init.body),
)
assert.equal(secondForm.get('mfa_id'), 'mfa-demo')
})
test('微博 CK 可按账号级凭证导出并从序列化 Cookie 恢复会话', async () => {
const { fetchImpl } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { sessionId } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '13800138000',
code: '123456',
})
const credential = connector.exportSessionCredential(sessionId)
assert.equal(credential.platformUid, '123456')
assert.ok(credential.cookies.some((cookie) => cookie.name === 'SUB'))
assert.ok(
credential.cookies.some(
(cookie) =>
cookie.name === 'PASSPORT_ONLY' &&
cookie.domain === 'passport.weibo.com' &&
cookie.path === '/sso',
),
)
const ticket = await connector.createBrowserTicket({
sessionId,
targetUrl: 'https://weibo.com/',
})
const injection = await connector.consumeBrowserTicket(ticket.ticket)
assert.equal(
injection.cookies.some((cookie) => cookie.name === 'PASSPORT_ONLY'),
false,
)
const restored = await connector.restoreSessionFromCredential({
taskId: 2,
orderNo: 'MOCKWB2',
platformUid: credential.platformUid,
nickname: credential.nickname,
cookies: credential.cookies,
})
assert.equal(restored.status, 'authenticated')
assert.equal(restored.hasCredential, true)
})