266 lines
8.1 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|