Files
DrSmoothl c16064d7e3 refactor(packages): rename @snowluma/bridge → @snowluma/protocol
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.

This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.

Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.
2026-05-23 23:31:03 +08:00

242 lines
9.4 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]),
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, putBytes, putMd5, putExtend] = vi.mocked(highway.uploadHighwayHttp).mock.calls[0]!;
expect(cmdId).toBe(1004);
expect(putBytes).toBe(image2Bytes);
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);
});
});