NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
function createApi () {
const core = {
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
apis: {
UserApi: {
getSKey: vi.fn().mockResolvedValue('s'),
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
},
},
};
return { api: new NTQQQzoneApi({} as never, core as never), core };
}
function response (value: Record<string, unknown>): Response {
return new Response(`_Callback(${JSON.stringify(value)});`, { status: 200 });
}
const ownDetail = { code: 0, tid: 'post-tid', uin: '10001', content: ' 原始正文\n ', ugc_right: 1 };
describe('Qzone profile, visibility and blacklist HTTP integration', () => {
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
beforeEach(() => {
fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([undefined, '20002'])('reads profile %s using the current account ticket without exposing other fields', async (target) => {
const { api, core } = createApi();
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '名称 & +', desc: '空间说明', private_field: 'secret' } }));
await expect(api.getProfile(target)).resolves.toEqual({ name: '名称 & +', description: '空间说明' });
const [rawUrl, options] = fetchMock.mock.calls[0]!;
const url = new URL(String(rawUrl));
expect(url.origin + url.pathname).toBe('https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all');
expect(Object.fromEntries(url.searchParams)).toMatchObject({ uin: target ?? '10001', vuin: '10001', fupdate: '1', g_tk: '177685' });
expect(options).toMatchObject({ method: 'GET', redirect: 'error', headers: { Cookie: 'uin=o10001; p_uin=o10001; skey=s; p_skey=p' } });
expect(options?.body).toBeUndefined();
expect(core.apis.UserApi.getPSkey).toHaveBeenCalledExactlyOnceWith(['qzone.qq.com']);
});
it('preserves empty profile values and rejects incomplete data', async () => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '', desc: '' } }));
await expect(api.getProfile()).resolves.toEqual({ name: '', description: '' });
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: 'Name' } }));
await expect(api.getProfile()).rejects.toThrow();
});
it.each([
{ patch: { name: '新空间 & +' }, bitmap: '2048', fields: { spacename: '新空间 & +' } },
{ patch: { description: '' }, bitmap: '4096', fields: { desc: '' } },
{ patch: { name: '', description: '第一行\r\n第二行' }, bitmap: '6144', fields: { spacename: '', desc: '第一行\n第二行' } },
])('updates only supplied profile fields (bitmap $bitmap)', async ({ patch, bitmap, fields }) => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
await expect(api.setProfile(patch)).resolves.toBeUndefined();
const [rawUrl, options] = fetchMock.mock.calls[0]!;
expect(new URL(String(rawUrl)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new');
expect(options).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({ uin: '10001', pageindex: '3', fupdate: '1', mb: bitmap, ...fields });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects invalid management arguments before acquiring tickets or making requests', async () => {
const { api, core } = createApi();
await expect(api.getProfile('invalid')).rejects.toThrow();
await expect(api.setProfile({})).rejects.toThrow();
await expect(api.setProfile({ name: [] } as never)).rejects.toThrow();
await expect(api.setMsgRight('post-tid', 2)).rejects.toThrow();
await expect(api.setMsgRight('post-tid', 16, [])).rejects.toThrow();
await expect(api.setMsgRight('post-tid', 128, ['20002', 'bad'])).rejects.toThrow();
await expect(api.setBan('bad')).rejects.toThrow();
await expect(api.setBan('20002', 'false' as never)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
});
it('reads the complete own post before changing visibility without changing content', async () => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, subcode: 0, ugc_right: 16 }));
await expect(api.setMsgRight('post-tid', 16, ['20002', '20002', '9007199254740993'])).resolves.toEqual({ ugc_right: 16 });
const detailUrl = new URL(String(fetchMock.mock.calls[0]![0]));
expect(detailUrl.pathname).toBe('/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6');
expect(Object.fromEntries(detailUrl.searchParams)).toMatchObject({
tid: 'post-tid', uin: '10001', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
});
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
const [updateUrl, options] = fetchMock.mock.calls[1]!;
expect(new URL(String(updateUrl)).pathname).toBe('/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update');
expect(options?.method).toBe('POST');
const body = new URLSearchParams(options?.body as string);
expect(body.get('con')).toBe(ownDetail.content);
expect(body.get('tid')).toBe('post-tid');
expect(body.get('hostuin')).toBe('10001');
expect(body.get('ugc_right')).toBe('16');
expect(body.get('allow_uins')).toBe('20002|9007199254740993');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it.each([
{ ...ownDetail, tid: 'other-tid' },
{ ...ownDetail, uin: '20002' },
{ code: 0, tid: 'post-tid', content: 'no verified owner' },
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'unusable' }] },
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X, ,L', height: 100, width: 100 }] },
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X,A, ', height: 100, width: 100 }] },
{ ...ownDetail, video: [{ video_id: 'not-safe-to-rebuild' }] },
{ code: -3000, subcode: 0, private_message: 'secret' },
])('does not submit a destructive or unverified permission update (%j)', async (detail) => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response(detail));
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
});
it('prevents overlapping writes to the same post and releases the lock afterward', async () => {
const { api } = createApi();
let resolveDetail!: (value: Response) => void;
fetchMock.mockImplementationOnce(() => new Promise(resolve => { resolveDetail = resolve; }));
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
const first = api.setMsgRight('post-tid', 64);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await expect(api.setMsgRight('post-tid', 4)).rejects.toThrow('正在修改权限');
resolveDetail(response(ownDetail));
await expect(first).resolves.toEqual({ ugc_right: 64 });
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: '4' }));
await expect(api.setMsgRight('post-tid', 4)).resolves.toEqual({ ugc_right: 4 });
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it('does not write after the active account changes while reading the post', async () => {
const { api, core } = createApi();
fetchMock.mockImplementationOnce(async () => {
core.selfInfo.uin = '20002';
core.selfInfo.uid = 'other-uid';
return response(ownDetail);
});
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ账号已切换');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not report success when the server echoes a different visibility', async () => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: 1 }));
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('返回权限与请求不一致');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not rewrite a post whose response contains malformed UTF-8 content', async () => {
const { api } = createApi();
const bytes = Buffer.concat([
Buffer.from('{"code":0,"tid":"post-tid","uin":"10001","content":"'),
Buffer.from([255]),
Buffer.from('","ugc_right":1}'),
]);
fetchMock.mockResolvedValueOnce(new Response(bytes));
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ空间请求失败');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
});
it.each([{ enable: undefined, action: '1' }, { enable: true, action: '1' }, { enable: false, action: '2' }])('updates only the current account blacklist (enable=$enable)', async ({ enable, action }) => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(response({ code: 0, subcode: 0 }));
await expect(api.setBan('9007199254740993', enable)).resolves.toBeUndefined();
const [url, options] = fetchMock.mock.calls[0]!;
expect(new URL(String(url)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new');
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({
uin: '10001', act_uin: '9007199254740993', action, fupdate: '1', qzreferrer: 'https://user.qzone.qq.com/10001/main',
});
});
it.each([
'{}',
'_Callback({"code":0,"subcode":-1,"message":"private-response"});',
'<html>private login page</html>',
'_Callback({"code":0});window.privateSideEffect();',
])('rejects unconfirmed writes without exposing or executing a response (%s)', async (text) => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(new Response(text));
let error: unknown;
try {
await api.setProfile({ name: 'Name' });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect(String(error)).not.toMatch(/private|window\./);
expect(String(error)).toContain('勿自动重试');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('never replays an ambiguous write or follows a redirect with credentials', async () => {
const { api } = createApi();
fetchMock.mockResolvedValueOnce(new Response('private redirect', { status: 302 }));
await expect(api.setBan('20002')).rejects.toThrow('未能确认结果');
expect(fetchMock.mock.calls[0]![1]?.redirect).toBe('error');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});