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']); }); });