Files
WebDrop/packages/core/test/reader.test.ts
T
2026-10-02 00:59:22 +08:00

250 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { concat, toBase64Url, writeU32, writeU64 } from '../src/bytes.js';
import { ChannelSession, collectFrames, itemText, parseLogRecords, type ItemEvent, type ItemPlan, type ReceivedItem } from '../src/index.js';
import { encodeMeta, parseEnvelope, sealFrame } from '../src/internal.js';
import { ORIGIN, pseudoRandom, toHex } from './helpers.js';
const GATED = 4;
const ENC = 8;
const FINAL = 1;
const SNAPSHOT = 2;
async function pair(mode: 'enc' | 'raw' = 'enc') {
const { session: owner } = await ChannelSession.create({ mode });
const member = await ChannelSession.open(owner.link(ORIGIN));
return { owner, member };
}
/** 一条 frame 日志记录的线格式:seq(8) ‖ kind(1) ‖ 封装。 */
const frameRecord = (seq: number, envelope: Uint8Array) => concat(writeU64(seq), new Uint8Array([1]), envelope);
const only = (events: ItemEvent[]): ItemEvent => {
expect(events).toHaveLength(1);
return events[0]!;
};
const itemOf = (events: ItemEvent[]): ReceivedItem => {
const ev = only(events);
if (ev.type !== 'item') throw new Error(`期望 item,得到 ${JSON.stringify(ev)}`);
return ev.item;
};
/** 构造一个多帧 GATED 条目的各帧(小帧,便于测试)。 */
async function gatedFrames(session: ChannelSession, size: number, frameData = 100) {
const content = pseudoRandom(size, 11);
const plan: ItemPlan = session.encryptBytes(content, { kind: 'file', name: 'f' }, { maxFrameData: frameData, ts: 1 });
return { content, plan, frames: await collectFrames(plan) };
}
describe('日志记录', () => {
it('frame 记录:带 seq,解密出条目', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('via log'));
const events = await member.createItemReader().push(frameRecord(42, frame!.bytes));
const item = itemOf(events);
expect(item.seq).toBe(42);
expect(itemText(item)).toBe('via log');
});
it('parseLogRecords 解析的记录可直接喂给装配器', async () => {
const { owner, member } = await pair();
const frames = [
...(await collectFrames(owner.encryptText('a'))),
...(await collectFrames(owner.encryptText('b'))),
];
const blob = concat(frameRecord(1, frames[0]!.bytes), frameRecord(2, frames[1]!.bytes));
const reader = member.createItemReader();
const texts: string[] = [];
for (const rec of parseLogRecords(blob)) for (const ev of await reader.push(rec)) if (ev.type === 'item') texts.push(itemText(ev.item));
expect(texts).toEqual(['a', 'b']);
});
it('announce:GATED 条目只通知,不含内容', async () => {
const { member } = await pair();
const id = new Uint8Array(16).fill(5);
const record = concat(writeU64(9), new Uint8Array([3]), id, new Uint8Array([GATED | ENC]), writeU64(1700000000123));
expect(only(await member.createItemReader().push(record))).toEqual({
type: 'announce',
itemId: toBase64Url(id),
seq: 9,
flags: GATED | ENC,
createdAt: 1700000000123,
});
});
it('tombstone:删除条目,并清掉装配中的缓冲', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes); // 只收到一部分
const tomb = concat(writeU64(7), new Uint8Array([2]), plan.idBytes, new Uint8Array([3]));
expect(only(await reader.push(tomb))).toEqual({ type: 'removed', itemId: plan.itemId, seq: 7, reason: 'download_limit' });
// 缓冲已清空:后面的帧从头开始装配,不会和旧状态混在一起
const ev = itemOf(await reader.pushFrame(frames[1]!.bytes));
expect(ev.complete).toBe(false);
});
it('损坏的日志记录变成 rejected,不抛异常', async () => {
const { member } = await pair();
const ev = only(await member.createItemReader().push(new Uint8Array([1, 2, 3])));
expect(ev).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
});
});
describe('重放与重复', () => {
it('同一帧重复到达:不产生新事件(日志回放不会让条目重复出现)', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('once'));
const reader = member.createItemReader();
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
});
it('装配中的条目收到内容不同的同序号帧:duplicate_mismatch,并丢弃该条目', async () => {
const { owner, member } = await pair();
const { frames } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const forged = frames[0]!.bytes.slice();
forged[forged.length - 1]! ^= 1;
expect(only(await reader.pushFrame(forged))).toMatchObject({ type: 'rejected', reason: 'duplicate_mismatch' });
});
it('条目被删除后,再来的旧帧会重新装配(回放到墓碑之前的数据时不会卡住)', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('again'));
const reader = member.createItemReader();
const { itemId } = parseEnvelopeId(frame!.bytes);
await reader.pushFrame(frame!.bytes);
await reader.push(concat(writeU64(2), new Uint8Array([2]), itemId, new Uint8Array([0])));
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
});
});
function parseEnvelopeId(envelope: Uint8Array) {
return { itemId: parseEnvelope(envelope).itemId };
}
describe('多帧条目的装配', () => {
it('乱序到达也能装配,到齐才 complete', async () => {
const { owner, member } = await pair();
const { content, frames } = await gatedFrames(owner, 450); // 约 5 帧
expect(frames.length).toBeGreaterThanOrEqual(4);
const reader = member.createItemReader();
const order = [2, 0, 3, 1, ...frames.slice(4).map((_, i) => i + 4)];
const states: boolean[] = [];
let last: ReceivedItem | undefined;
for (const i of order) {
last = itemOf(await reader.pushFrame(frames[i]!.bytes));
states.push(last.complete);
}
expect(states.slice(0, -1).every((c) => !c)).toBe(true);
expect(last!.complete).toBe(true);
expect(toHex(last!.content)).toBe(toHex(content));
expect(last!.meta?.name).toBe('f');
});
it('缺一帧时不会 complete,content 只到第一个缺口', async () => {
const { owner, member } = await pair();
const { frames } = await gatedFrames(owner, 250);
expect(frames).toHaveLength(3);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const item = itemOf(await reader.pushFrame(frames[2]!.bytes)); // 跳过第 1 帧
expect(item.complete).toBe(false);
const filled = itemOf(await reader.pushFrame(frames[1]!.bytes));
expect(filled.complete).toBe(true);
});
it('FINAL 之后又出现更大序号的帧:frame_after_final', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250); // 3 帧,序号 2 是 FINAL
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
await reader.pushFrame(frames[2]!.bytes);
const key = await owner.itemKey(plan.idBytes);
const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: 3, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(extra))).toMatchObject({ type: 'rejected', reason: 'frame_after_final' });
});
it('FINAL 出现在已有更大序号之前:final_conflict', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[2]!.bytes); // 序号 2 先到(它带 FINAL)
// 伪造一个序号 1、也带 FINAL 的帧:FINAL 位置前后矛盾
const key = await owner.itemKey(plan.idBytes);
const clash = await sealFrame(key, owner.idBytes, { flags: GATED | ENC | FINAL, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(clash))).toMatchObject({ type: 'rejected', reason: 'final_conflict' });
});
it('同一条目内标志位变化:flags_changed', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const key = await owner.itemKey(plan.idBytes);
// 第 0 帧是 GATED,这里发一个带 SNAPSHOT 的第 1 帧(密文本身是合法认证的)
const odd = await sealFrame(key, owner.idBytes, { flags: GATED | SNAPSHOT | ENC, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(odd))).toMatchObject({ type: 'rejected', reason: 'flags_changed' });
});
it('非 GATED 条目的帧序号不是 0 或没有 FINAL:拒绝(协议规则)', async () => {
const { owner, member } = await pair();
const id = new Uint8Array(16).fill(1);
const key = await owner.itemKey(id);
const noFinal = await sealFrame(key, owner.idBytes, { flags: ENC, itemId: id, index: 0, data: concat(encodeMeta({ kind: 'text', ts: 1 }), new Uint8Array(1)) });
expect(only(await member.createItemReader().pushFrame(noFinal))).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
});
it('第 0 帧的元数据不合法:bad_meta', async () => {
const { owner, member } = await pair();
const id = new Uint8Array(16).fill(2);
const key = await owner.itemKey(id);
const garbage = new TextEncoder().encode('{"v":1,"kind":123,"ts":"x"}');
const frame = await sealFrame(key, owner.idBytes, { flags: ENC | FINAL, itemId: id, index: 0, data: concat(new Uint8Array([0, garbage.length]), garbage) });
expect(only(await member.createItemReader().pushFrame(frame))).toMatchObject({ type: 'rejected', reason: 'bad_meta' });
});
});
describe('模式隔离(防降级)', () => {
it('enc 会话拒绝没有 ENC 标志的帧(恶意服务端注入明文)', async () => {
const { member } = await pair('enc');
const raw = concat(new Uint8Array([1, FINAL]), new Uint8Array(16), writeU32(0), writeU32(4), new TextEncoder().encode('evil'));
expect(only(await member.createItemReader().pushFrame(raw))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
});
it('raw 会话拒绝带 ENC 标志的帧', async () => {
const { owner: encOwner } = await pair('enc');
const { member: rawMember } = await pair('raw');
const [frame] = await collectFrames(encOwner.encryptText('x'));
expect(only(await rawMember.createItemReader().pushFrame(frame!.bytes))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
});
it('一个条目被拒绝不影响其他条目', async () => {
const { owner, member } = await pair();
const reader = member.createItemReader();
const [good1] = await collectFrames(owner.encryptText('one'));
const [bad] = await collectFrames(owner.encryptText('bad'));
const [good2] = await collectFrames(owner.encryptText('two'));
bad!.bytes[bad!.bytes.length - 1]! ^= 1;
const results = [
...(await reader.pushFrame(good1!.bytes)),
...(await reader.pushFrame(bad!.bytes)),
...(await reader.pushFrame(good2!.bytes)),
];
expect(results.map((e) => e.type)).toEqual(['item', 'rejected', 'item']);
});
});