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
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { RKeyCache } from '../src/instance-rkey';
|
|
import type { MessageElement } from '@snowluma/protocol/events';
|
|
|
|
// The screenshot URL: a raw QQ-NT multimedia download link with no rkey yet.
|
|
// appid 1407 = group image, 1406 = private/c2c image (see instance-rkey).
|
|
const NT_IMAGE_URL = 'https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=ABC';
|
|
const NT_PRIVATE_IMAGE_URL = 'https://multimedia.nt.qq.com.cn/download?appid=1406&fileid=XYZ';
|
|
const GROUP_IMAGE_TYPE = 20;
|
|
const PRIVATE_IMAGE_TYPE = 10;
|
|
|
|
function nowSec(): number {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
|
|
function makeBridge(fetchImpl: () => Promise<unknown[]>) {
|
|
const fetchDownloadRKeys = vi.fn(fetchImpl);
|
|
return { bridge: { apis: { contacts: { fetchDownloadRKeys } } }, fetchDownloadRKeys };
|
|
}
|
|
|
|
function freshGroupRkey(value = 'GROUPKEY') {
|
|
return [
|
|
{ rkey: value, type: GROUP_IMAGE_TYPE, ttlSeconds: 3600, createTime: nowSec(), storeId: 0 },
|
|
];
|
|
}
|
|
|
|
function freshBothRkeys() {
|
|
return [
|
|
{
|
|
rkey: 'PRIVATEKEY',
|
|
type: PRIVATE_IMAGE_TYPE,
|
|
ttlSeconds: 3600,
|
|
createTime: nowSec(),
|
|
storeId: 0,
|
|
},
|
|
{
|
|
rkey: 'GROUPKEY',
|
|
type: GROUP_IMAGE_TYPE,
|
|
ttlSeconds: 3600,
|
|
createTime: nowSec(),
|
|
storeId: 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
function imageEl(): MessageElement {
|
|
return { type: 'image', imageUrl: NT_IMAGE_URL };
|
|
}
|
|
|
|
function privateImageEl(): MessageElement {
|
|
return { type: 'image', imageUrl: NT_PRIVATE_IMAGE_URL };
|
|
}
|
|
|
|
describe('RKeyCache.resolveImageUrl', () => {
|
|
it('awaits a refresh on a cold cache and appends the rkey', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=GROUPKEY`);
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('coalesces concurrent cold-cache lookups into a single fetch', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
|
|
const urls = await Promise.all([
|
|
cache.resolveImageUrl(bridge as never, imageEl(), true),
|
|
cache.resolveImageUrl(bridge as never, imageEl(), true),
|
|
cache.resolveImageUrl(bridge as never, imageEl(), true),
|
|
]);
|
|
|
|
expect(urls).toEqual(Array(3).fill(`${NT_IMAGE_URL}&rkey=GROUPKEY`));
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('serves a still-valid rkey from cache without re-fetching', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
|
|
await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('falls back to the unsigned URL (no throw) when the refresh fails', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => {
|
|
throw new Error('oidb down');
|
|
});
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(NT_IMAGE_URL);
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the private rkey type for c2c images (appid 1406)', async () => {
|
|
const { bridge } = makeBridge(async () => freshBothRkeys());
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, privateImageEl(), false);
|
|
|
|
expect(url).toBe(`${NT_PRIVATE_IMAGE_URL}&rkey=PRIVATEKEY`);
|
|
});
|
|
|
|
// The scene follows the image's own appid, not the carrying message — the
|
|
// crux of `/get_forward_msg` rkey correctness (issue #74): a c2c image
|
|
// forwarded into a group must still be signed with the private rkey.
|
|
it('signs an appid-1406 image with the private rkey even when isGroup is true', async () => {
|
|
const { bridge } = makeBridge(async () => freshBothRkeys());
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, privateImageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_PRIVATE_IMAGE_URL}&rkey=PRIVATEKEY`);
|
|
});
|
|
|
|
it('signs an appid-1407 image with the group rkey even when isGroup is false', async () => {
|
|
const { bridge } = makeBridge(async () => freshBothRkeys());
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), false);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=GROUPKEY`);
|
|
});
|
|
|
|
it('falls back to the isGroup scene when the URL carries no appid', async () => {
|
|
const noAppidUrl = 'https://multimedia.nt.qq.com.cn/download?fileid=NOAPPID';
|
|
const { bridge } = makeBridge(async () => freshBothRkeys());
|
|
const cache = new RKeyCache();
|
|
|
|
const url = await cache.resolveImageUrl(
|
|
bridge as never,
|
|
{ type: 'image', imageUrl: noAppidUrl },
|
|
true,
|
|
);
|
|
|
|
expect(url).toBe(`${noAppidUrl}&rkey=GROUPKEY`);
|
|
});
|
|
|
|
// #156: a transient empty/failed fetch must self-heal in seconds, not 30s.
|
|
// An empty cache uses the short RKEY_EMPTY_COOLDOWN (10s), so a retry ~11s
|
|
// later refetches — under the 30s steady-state backoff it would not.
|
|
it('retries before the 30s cooldown while the cache holds no usable rkey', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
|
|
try {
|
|
vi.setSystemTime(new Date(1_700_000_000_000));
|
|
let calls = 0;
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () =>
|
|
++calls === 1 ? [] : freshGroupRkey(),
|
|
);
|
|
const cache = new RKeyCache();
|
|
|
|
const url1 = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
expect(url1).toBe(NT_IMAGE_URL); // empty fetch → bare URL
|
|
|
|
vi.setSystemTime(new Date(1_700_000_000_000 + 11_000)); // +11s: past 10s empty-cooldown, inside 30s
|
|
const url2 = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url2).toBe(`${NT_IMAGE_URL}&rkey=GROUPKEY`);
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does NOT refetch within 30s once a usable rkey is cached', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
|
|
try {
|
|
vi.setSystemTime(new Date(1_700_000_000_000));
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
|
|
await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
vi.setSystemTime(new Date(1_700_000_000_000 + 15_000)); // +15s, inside 30s backoff
|
|
await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(fetchDownloadRKeys).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('leaves non-NT URLs untouched and never fetches', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
const el: MessageElement = { type: 'image', imageUrl: 'https://gchat.qpic.cn/foo.jpg' };
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, el, true);
|
|
|
|
expect(url).toBe('https://gchat.qpic.cn/foo.jpg');
|
|
expect(fetchDownloadRKeys).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('RKeyCache.resolveMediaUrl', () => {
|
|
it.each([
|
|
['video', false, 12],
|
|
['video', true, 22],
|
|
['record', false, 14],
|
|
['record', true, 24],
|
|
['file', false, 10],
|
|
['file', true, 20],
|
|
] as const)('signs %s media (group=%s) with rkey type %s', async (type, isGroup, expected) => {
|
|
const { bridge } = makeBridge(async () =>
|
|
[10, 20, 12, 22, 14, 24].map((type) => ({
|
|
rkey: `?rkey=key/${type}`,
|
|
type,
|
|
ttlSeconds: 3600,
|
|
createTime: nowSec(),
|
|
})),
|
|
);
|
|
const cache = new RKeyCache();
|
|
const url = 'https://multimedia.nt.qq.com.cn/download?fileid=MEDIA';
|
|
expect(await cache.resolveMediaUrl(bridge as never, { type, url }, isGroup)).toBe(
|
|
`${url}&rkey=key%2F${expected}`,
|
|
);
|
|
});
|
|
|
|
it('preserves signed, unrelated, and absent URLs without fetching', async () => {
|
|
const { bridge, fetchDownloadRKeys } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache();
|
|
|
|
for (const url of ['', 'https://example.com/video', `${NT_IMAGE_URL}&rkey=existing`]) {
|
|
expect(await cache.resolveMediaUrl(bridge as never, { type: 'video', url }, true)).toBe(
|
|
url,
|
|
);
|
|
}
|
|
|
|
expect(fetchDownloadRKeys).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('RKeyCache remote fallback (#156)', () => {
|
|
const SERVER = 'https://rkey.example/r';
|
|
|
|
function stubFetch(payload: unknown, status = 200) {
|
|
return vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValue(new Response(JSON.stringify(payload), { status }));
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('is off by default: never contacts a server, leaves the URL bare', async () => {
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
const { bridge } = makeBridge(async () => []); // native yields nothing
|
|
const cache = new RKeyCache(); // no config → fallback off
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(NT_IMAGE_URL);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the configured server when the native fetch is empty', async () => {
|
|
const fetchSpy = stubFetch({
|
|
group_rkey: '&rkey=FBGROUP',
|
|
private_rkey: '&rkey=FBPRIV',
|
|
expired_time: nowSec() + 3600,
|
|
});
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache({ fallbackServers: [SERVER] });
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=FBGROUP`);
|
|
expect(fetchSpy).toHaveBeenCalledWith(SERVER, expect.anything());
|
|
});
|
|
|
|
it('signs c2c images with the fallback private rkey', async () => {
|
|
stubFetch({
|
|
group_rkey: '&rkey=FBGROUP',
|
|
private_rkey: '&rkey=FBPRIV',
|
|
expired_time: nowSec() + 3600,
|
|
});
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache({ fallbackServers: [SERVER] });
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, privateImageEl(), false);
|
|
|
|
expect(url).toBe(`${NT_PRIVATE_IMAGE_URL}&rkey=FBPRIV`);
|
|
});
|
|
|
|
it('accepts an OneBot { retcode, data } wrapper and bare (unprefixed) tokens', async () => {
|
|
stubFetch({
|
|
retcode: 0,
|
|
data: { group_rkey: 'BAREKEY', private_rkey: '', expired_time: nowSec() + 3600 },
|
|
});
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache({ fallbackServers: [SERVER] });
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=BAREKEY`);
|
|
});
|
|
|
|
it('does not hit any server when the native fetch already returned a rkey', async () => {
|
|
const fetchSpy = stubFetch({ group_rkey: '&rkey=FBGROUP', expired_time: nowSec() + 3600 });
|
|
const { bridge } = makeBridge(async () => freshGroupRkey());
|
|
const cache = new RKeyCache({ fallbackServers: [SERVER] });
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=GROUPKEY`); // native rkey, not fallback
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tries the next server when the first one fails', async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockRejectedValueOnce(new Error('down'))
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ group_rkey: '&rkey=SECOND', expired_time: nowSec() + 3600 }),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache({
|
|
fallbackServers: ['https://a.example/r', 'https://b.example/r'],
|
|
});
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(`${NT_IMAGE_URL}&rkey=SECOND`);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('leaves the URL bare when every fallback server fails', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down'));
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache({ fallbackServers: [SERVER] });
|
|
|
|
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
|
|
expect(url).toBe(NT_IMAGE_URL);
|
|
});
|
|
|
|
// Hot-reload wire: manager.reloadGlobalSettings → instance.applyGlobalSettings
|
|
// → rkeyCache.setFallbackServers. Verify the new list actually takes effect.
|
|
// (Fake Date so the 2nd lookup clears the empty-cache refresh cooldown.)
|
|
it('setFallbackServers turns the fallback on for a cache that started with none', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
|
|
try {
|
|
vi.setSystemTime(new Date(1_700_000_000_000));
|
|
const fetchSpy = stubFetch({
|
|
group_rkey: '&rkey=HOTGROUP',
|
|
expired_time: 1_700_000_000 + 3600,
|
|
});
|
|
const { bridge } = makeBridge(async () => []);
|
|
const cache = new RKeyCache(); // starts OFF (no servers)
|
|
|
|
const before = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
expect(before).toBe(NT_IMAGE_URL); // bare — fallback off
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
cache.setFallbackServers([SERVER]); // hot-reload pushes a new endpoint
|
|
vi.setSystemTime(new Date(1_700_000_000_000 + 11_000)); // clear the 10s empty-cooldown
|
|
|
|
const after = await cache.resolveImageUrl(bridge as never, imageEl(), true);
|
|
expect(after).toBe(`${NT_IMAGE_URL}&rkey=HOTGROUP`);
|
|
expect(fetchSpy).toHaveBeenCalledWith(SERVER, expect.anything());
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|