Files
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

299 lines
11 KiB
TypeScript

// Regression test for the "two images in one message" bug user report.
//
// User saw: 1st image fast-uploaded (server already had the bytes), 2nd
// image triggered an actual highway PUT and the highway server returned
// `error_code=921`.
//
// This test walks two `runNtv2Upload` calls back-to-back through the
// real pipeline + buildHighwayExtend code path with the highway TCP/HTTP
// transport stubbed, and verifies:
//
// - the two calls don't share state (each has its own session, its
// own OIDB request, its own extend bytes),
// - the second call's extend payload is well-formed (uKey + msgInfoBody
// + ipv4s come from the *second* call's response, not the first),
// - the highway PUT receives the second image's bytes + md5 + sha1
// verbatim (no aliasing / truncation across calls).
//
// If this test ever starts failing, it's a real bug in the pipeline
// (state sharing, async race, or wrong-buffer aliasing). If it
// continues to pass, the user's 921 is almost certainly server-side
// (rate limit, malformed image rejection, expired session) rather than
// a client-emit issue.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
vi.mock('@snowluma/protocol/highway', async () => {
const real = await vi.importActual<typeof import('@snowluma/protocol/highway')>(
'@snowluma/protocol/highway',
);
return {
...real,
// Only the TCP/HTTP transport is mocked — we keep real
// `buildHighwayExtend` so we can inspect its output bytes.
fetchHighwaySession: vi.fn(async () => ({
sigSession: new Uint8Array([0xaa]),
sessionKey: new Uint8Array([0xbb]),
servers: [{ host: 'highway.example', port: 80 }],
})),
uploadHighwayHttp: vi.fn(async () => undefined),
};
});
import * as highway from '@snowluma/protocol/highway';
import { runNtv2Upload, type MediaSubFileUpload } from '@snowluma/protocol/highway/pipeline';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
NTV2UploadRichMediaReq,
NTV2UploadRichMediaResp,
NTV2RichMediaHighwayExt,
} from '@snowluma/proto-defs/highway';
function encodeOidbResponse(body: any): Buffer {
return Buffer.from(
protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
command: 0x11c4,
subCommand: 100,
errorCode: 0,
body: body as Record<string, unknown>,
errorMsg: '',
reserved: 1,
}),
);
}
function makeBridge(responses: Buffer[]) {
const sendRawPacket = vi.fn();
for (const r of responses) {
sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: r,
});
}
return { sendRawPacket, identity: { uin: '10001' } } as any;
}
const baseParams = (bridge: any) => ({
bridge,
isGroup: true,
targetIdOrUid: 894750076,
oidbCmd: 0x11c4,
serviceCmd: 'OidbSvcTrpcTcp.0x11c4_100',
requestId: 1,
businessType: 1,
compatQmsgSceneType: 2,
extBizInfo: { pic: { bizType: 0, textSummary: '[image]' } },
});
describe('runNtv2Upload — two images in one message (sequential)', () => {
beforeEach(() => {
vi.mocked(highway.fetchHighwaySession).mockClear();
vi.mocked(highway.uploadHighwayHttp).mockClear();
});
it("does not leak the first image's upload metadata into the second's extend", async () => {
// Image 1 server response: NO uKey (fast-upload hit). The pipeline
// must skip the highway PUT entirely for this call.
// Image 2 server response: uKey + ipv4s + msgInfoBody set → real PUT.
const image1Bytes = Buffer.from(new Uint8Array(238963));
const image1Md5 = new Uint8Array(16).fill(0xf4);
const image2Bytes = Buffer.from(new Uint8Array(58));
const image2Md5 = new Uint8Array(16).fill(0xdd);
const image2Sha1 = new Uint8Array(20).fill(0x11);
const bridge = makeBridge([
// First OIDB upload response: fast-path hit (no uKey).
encodeOidbResponse({
upload: {
msgInfo: {
msgInfoBody: [{ index: { fileUuid: 'uuid-1' } }],
extBizInfo: {},
},
},
}),
// Second OIDB upload response: needs highway PUT.
encodeOidbResponse({
upload: {
uKey: 'ukey-image-2',
ipv4s: [{ outIp: 0x04030201, outPort: 8080 }],
msgInfo: {
msgInfoBody: [
{
index: {
fileUuid: 'uuid-2',
info: { fileHash: 'dd', fileSha1: '11' },
},
},
],
extBizInfo: {},
},
},
}),
]);
const uploads1: MediaSubFileUpload[] = [
{
source: 'top',
cmdId: 1004,
bytes: image1Bytes,
md5: image1Md5,
sha1: new Uint8Array(20).fill(0xab),
fastOnlyError: 'image fast-upload not available',
},
];
const uploads2: MediaSubFileUpload[] = [
{
source: 'top',
cmdId: 1004,
bytes: image2Bytes,
md5: image2Md5,
sha1: image2Sha1,
fastOnlyError: 'image fast-upload not available',
},
];
// Image 1 — fast path; must NOT touch the highway transport.
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [
{
fileInfo: { fileName: 'a.jpg', fileSize: 238963 },
subFileType: 0,
},
],
uploads: uploads1,
label: 'image',
});
expect(highway.fetchHighwaySession).not.toHaveBeenCalled();
expect(highway.uploadHighwayHttp).not.toHaveBeenCalled();
// Image 2 — must PUT through highway exactly once.
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [
{
fileInfo: { fileName: 'b.jpg', fileSize: 58 },
subFileType: 0,
},
],
uploads: uploads2,
label: 'image',
});
expect(highway.fetchHighwaySession).toHaveBeenCalledOnce();
expect(highway.uploadHighwayHttp).toHaveBeenCalledOnce();
// The PUT receives image-2's bytes, image-2's md5, and the
// image-2-derived extend (NOT image-1's data).
const [, , cmdId, putSource, putMd5, putExtend] = vi.mocked(highway.uploadHighwayHttp).mock
.calls[0]!;
expect(cmdId).toBe(1004);
// The PUT source is a ChunkSource wrapping image-2's bytes — verify it
// yields image-2 exactly (not image-1) through its public read().
expect(putSource.size).toBe(image2Bytes.length);
expect(
Buffer.from(await putSource.read(0, putSource.size)).equals(Buffer.from(image2Bytes)),
).toBe(true);
expect(putMd5).toBe(image2Md5);
// Decode the extend bytes; uKey + fileUuid must come from image-2's
// response, ipv4s must come from image-2's response.
const extend = protobuf_decode<NTV2RichMediaHighwayExt>(putExtend);
expect(extend.uKey).toBe('ukey-image-2');
expect(extend.fileUuid).toBe('uuid-2');
expect(extend.msgInfoBody?.[0]?.index?.fileUuid).toBe('uuid-2');
expect(extend.network?.ipv4s?.length).toBeGreaterThan(0);
});
it('image 2 fetches its own fresh session (no leftover from a prior call)', async () => {
// Both images hit the highway PUT path. The pipeline must call
// fetchHighwaySession once PER runNtv2Upload (not share session
// across calls), matching NapCat's per-context lifetime semantics
// would actually be a future optimisation, but for now correctness
// = "session is non-null when PUT runs".
const ok = encodeOidbResponse({
upload: {
uKey: 'ukey',
ipv4s: [{ outIp: 1, outPort: 80 }],
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'u' } }], extBizInfo: {} },
},
});
const bridge = makeBridge([ok, ok]);
const uploads: MediaSubFileUpload[] = [
{
source: 'top',
cmdId: 1004,
bytes: Buffer.from([1, 2, 3]),
md5: new Uint8Array(16),
sha1: new Uint8Array(20),
fastOnlyError: 'image fast-upload not available',
},
];
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [{ fileInfo: { fileName: 'a.jpg' }, subFileType: 0 }],
uploads,
label: 'image',
});
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [{ fileInfo: { fileName: 'b.jpg' }, subFileType: 0 }],
uploads,
label: 'image',
});
expect(highway.fetchHighwaySession).toHaveBeenCalledTimes(2);
expect(highway.uploadHighwayHttp).toHaveBeenCalledTimes(2);
// The session object passed to the PUT must be non-null both times.
for (const call of vi.mocked(highway.uploadHighwayHttp).mock.calls) {
const session = call[1];
expect(session).toBeDefined();
expect((session as any).sigSession).toBeDefined();
}
});
it("image 2's OIDB request body carries image-2's fileInfo (not image-1's)", async () => {
// Catches a hypothetical bug where someone mutates a shared body
// object across calls. Today the pipeline always builds a fresh
// body, but the regression is cheap to guard.
const ok = encodeOidbResponse({
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } },
});
const bridge = makeBridge([ok, ok]);
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [{ fileInfo: { fileName: 'first.jpg', fileSize: 100 }, subFileType: 0 }],
uploads: [],
label: 'image',
});
await runNtv2Upload({
...baseParams(bridge),
uploadInfo: [{ fileInfo: { fileName: 'second.jpg', fileSize: 200 }, subFileType: 0 }],
uploads: [],
label: 'image',
});
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
const body1 = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
const body2 = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(body1.body?.upload?.uploadInfo?.[0]?.fileInfo?.fileName).toBe('first.jpg');
expect(body2.body?.upload?.uploadInfo?.[0]?.fileInfo?.fileName).toBe('second.jpg');
// And the clientRandomId is different per call (sanity).
expect(body1.body?.upload?.clientRandomId).not.toBe(body2.body?.upload?.clientRandomId);
});
});