kt-template-online-api/test/modules/qqbot/napcat/new-device-flow.spec.ts

266 lines
8.1 KiB
TypeScript

import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat';
import * as QRCode from 'qrcode';
describe('NapCat new-device flow API client', () => {
it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with NapCat WebUI payloads', async () => {
const calls: Array<{ body: unknown; path: string }> = [];
const pullQrCodeSig = { key: 'sig-1' };
const client = new NapcatLoginApiClient({
post: jest.fn(async (path: string, body: unknown) => {
calls.push({ body, path });
if (path.endsWith('/GetNewDeviceQRCode')) {
return {
bytes_token: 'bytes-1',
newDevicePullQrCodeSig: pullQrCodeSig,
str_url: 'https://qq.example/new-device/qr?str_url=https%3A%2F%2Fproof.qq.com%2Ftoken',
};
}
if (path.endsWith('/PollNewDeviceQR') && calls.length === 2) {
return { status: 'scanned' };
}
if (path.endsWith('/PollNewDeviceQR') && calls.length === 3) {
return { status: 'confirming' };
}
if (path.endsWith('/NewDeviceLogin')) {
return { message: 'ok', status: 'ok' };
}
throw new Error(`unexpected path ${path}`);
}),
});
const qr = await client.getNewDeviceQRCode({
jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
uin: '10001',
});
const scanned = await client.pollNewDeviceQR({
bytesToken: qr.bytesToken,
uin: '10001',
});
const confirming = await client.pollNewDeviceQR({
bytesToken: qr.bytesToken,
uin: '10001',
});
const verified = await client.newDeviceLogin({
newDevicePullQrCodeSig: pullQrCodeSig,
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
});
expect(qr).toMatchObject({
bytesToken: 'bytes-1',
deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
pullQrCodeSig,
status: 'qr-pending',
});
expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/);
expect(scanned.status).toBe('scanned');
expect(confirming.status).toBe('confirming');
expect(verified).toEqual({
jumpUrl: undefined,
message: 'ok',
needNewDevice: false,
pullQrCodeSig: undefined,
status: 'verified',
success: true,
});
expect(calls).toEqual([
{
body: {
jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-1',
uin: '10001',
},
path: '/api/QQLogin/GetNewDeviceQRCode',
},
{
body: { bytesToken: 'bytes-1', uin: '10001' },
path: '/api/QQLogin/PollNewDeviceQR',
},
{
body: { bytesToken: 'bytes-1', uin: '10001' },
path: '/api/QQLogin/PollNewDeviceQR',
},
{
body: {
newDevicePullQrCodeSig: pullQrCodeSig,
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
},
path: '/api/QQLogin/NewDeviceLogin',
},
]);
});
it('derives bytesToken from str_url when NapCat omits bytes_token', async () => {
const rawProofUrl = 'https://proof.qq.com/token?a=1&b=2';
const strUrl = `https://qq.example/new-device/qr?str_url=${encodeURIComponent(rawProofUrl)}`;
const client = new NapcatLoginApiClient({
post: jest
.fn()
.mockResolvedValueOnce({ str_url: strUrl })
.mockResolvedValueOnce({ status: 'waiting' })
.mockResolvedValueOnce({ status: 'expired', message: 'expired' })
.mockResolvedValueOnce({ status: 'failed', message: 'denied' }),
});
const qr = await client.getNewDeviceQRCode({
jumpUrl: 'https://accounts.qq.com/safe/verify?sid=sid-2',
uin: '10002',
});
expect(qr).toMatchObject({
bytesToken: Buffer.from(rawProofUrl, 'utf8').toString('base64'),
deviceVerifyUrl: 'https://accounts.qq.com/safe/verify?sid=sid-2',
status: 'qr-pending',
});
expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/);
expect(qr.qrcodeUrl).not.toBe(strUrl);
await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: undefined,
status: 'qr-pending',
});
await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: 'expired',
status: 'expired',
});
await expect(
client.pollNewDeviceQR({ bytesToken: qr.bytesToken, uin: '10002' }),
).resolves.toEqual({
message: 'denied',
status: 'failed',
});
});
it('prefers NapCat str_url over generic url as QR payload', async () => {
const strUrl = 'https://qq.example/new-device/qr?str_url=https%3A%2F%2Fproof.qq.com%2Ftoken';
const toDataUrl = jest.spyOn(
QRCode,
'toDataURL',
) as unknown as jest.Mock;
toDataUrl.mockResolvedValue('data:image/png;base64,encoded-qr');
const client = new NapcatLoginApiClient({
post: jest.fn().mockResolvedValue({
bytes_token: 'bytes-1',
str_url: strUrl,
url: 'https://accounts.qq.com/safe/verify?missing=location-context',
}),
});
try {
const qr = await client.getNewDeviceQRCode({
jumpUrl: 'https://accounts.qq.com/safe/verify?sig=sig&uin-token=token',
uin: '10001',
});
expect(qr.qrcodeUrl).toBe('data:image/png;base64,encoded-qr');
expect(toDataUrl).toHaveBeenCalledWith(
strUrl,
expect.objectContaining({
type: 'image/png',
}),
);
} finally {
toDataUrl.mockRestore();
}
});
it('normalizes NewDeviceLogin status-only failures', async () => {
const client = new NapcatLoginApiClient({
post: jest.fn().mockResolvedValue({
message: 'denied',
status: 'failed',
}),
});
await expect(
client.newDeviceLogin({
newDevicePullQrCodeSig: 'sig-1',
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
}),
).resolves.toEqual({
jumpUrl: undefined,
message: 'denied',
needNewDevice: false,
pullQrCodeSig: undefined,
status: 'failed',
success: false,
});
});
it('normalizes NapCat official numeric QR poll statuses and success token', async () => {
const client = new NapcatLoginApiClient({
post: jest
.fn()
.mockResolvedValueOnce({ uint32_guarantee_status: 3 })
.mockResolvedValueOnce({
str_nt_succ_token: 'nt-success-token',
uint32_guarantee_status: 1,
}),
});
await expect(
client.pollNewDeviceQR({ bytesToken: 'bytes-1', uin: '10001' }),
).resolves.toEqual({
message: undefined,
status: 'scanned',
});
await expect(
client.pollNewDeviceQR({ bytesToken: 'bytes-1', uin: '10001' }),
).resolves.toEqual({
confirmToken: 'nt-success-token',
message: undefined,
status: 'confirming',
});
});
it('does not treat repeated needNewDevice from NewDeviceLogin as success', async () => {
const client = new NapcatLoginApiClient({
post: jest.fn().mockResolvedValue({
jumpUrl: 'https://accounts.qq.com/safe/verify?sig=next&uin-token=next',
needNewDevice: true,
newDevicePullQrCodeSig: 'next-sig',
}),
});
await expect(
client.newDeviceLogin({
newDevicePullQrCodeSig: 'nt-success-token',
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
}),
).resolves.toEqual({
jumpUrl: 'https://accounts.qq.com/safe/verify?sig=next&uin-token=next',
message: undefined,
needNewDevice: true,
pullQrCodeSig: 'next-sig',
status: 'failed',
success: false,
});
});
it('treats NapCat NewDeviceLogin null payload as success', async () => {
const client = new NapcatLoginApiClient({
post: jest.fn().mockResolvedValue(null),
});
await expect(
client.newDeviceLogin({
newDevicePullQrCodeSig: 'nt-success-token',
passwordMd5: '0123456789abcdef0123456789abcdef',
uin: '10001',
}),
).resolves.toEqual({
jumpUrl: undefined,
message: undefined,
needNewDevice: false,
pullQrCodeSig: undefined,
status: 'verified',
success: true,
});
});
});