Opening a space link while signed out shows the sign-in page first and keeps the link in memory. The channel settings no longer offer the stranger switch, and the client supports API contract major 8.
425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { toBase64Url } from '../src/bytes.js';
|
|
import {
|
|
ChannelSession,
|
|
collectFrames,
|
|
itemJson,
|
|
itemText,
|
|
parseLogRecords,
|
|
type AuthRole,
|
|
type ItemEvent,
|
|
type ItemPlan,
|
|
type ReceivedItem,
|
|
} from '../src/index.js';
|
|
import { concatAll, ORIGIN, pseudoRandom, readAll, toHex } from './helpers.js';
|
|
|
|
/**
|
|
* 端到端:用 @webdrop/core 生成的真实密文,对着真实的服务端(NativeAOT 可执行文件,
|
|
* 没有就退回到 JIT 的 dll)走一遍完整流程。证明库产出的帧能通过服务端的全部校验,
|
|
* 并且经由真实的 HTTP / WebSocket 往返后能被正确解密。
|
|
*
|
|
* 用环境变量 WEBDROP_SERVER 指定可执行文件;WEBDROP_SERVER_DLL 指定 dll。
|
|
*/
|
|
|
|
const root = join(import.meta.dirname, '..', '..', '..', 'server');
|
|
const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.exe');
|
|
const linuxExe = join(root, 'out', 'linux-x64', 'webdrop');
|
|
const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll');
|
|
|
|
const command: [string, string[]] | undefined = !process.env.WEBDROP_SERVER && existsSync(dll)
|
|
? ['dotnet', [dll]]
|
|
: existsSync(exe)
|
|
? [exe, []]
|
|
: existsSync(linuxExe)
|
|
? [linuxExe, []]
|
|
: existsSync(dll)
|
|
? ['dotnet', [dll]]
|
|
: undefined;
|
|
|
|
let proc: ChildProcess | undefined;
|
|
let dataDir = '';
|
|
let base = '';
|
|
let accountToken = '';
|
|
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
|
|
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
|
|
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
if (!command) return;
|
|
dataDir = mkdtempSync(join(tmpdir(), 'webdrop-e2e-'));
|
|
proc = spawn(command[0], [...command[1], '--urls', 'http://127.0.0.1:0'], {
|
|
env: {
|
|
...process.env,
|
|
WebDrop__DataDir: dataDir,
|
|
WebDrop__RequestsPerMinute: '1000000',
|
|
WebDrop__ChannelCreatesPerMinute: '1000000',
|
|
WebDrop__Captcha__Provider: 'none',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
base = await new Promise<string>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('服务端没有在 20 秒内启动')), 20_000);
|
|
let buffer = '';
|
|
proc!.stdout!.on('data', (chunk: Buffer) => {
|
|
buffer += chunk.toString();
|
|
const m = /Now listening on:\s*(http:\/\/\S+)/.exec(buffer);
|
|
if (m) {
|
|
clearTimeout(timer);
|
|
resolve(m[1]!);
|
|
}
|
|
});
|
|
proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`)));
|
|
});
|
|
const verifier = crypto.getRandomValues(new Uint8Array(32));
|
|
const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey);
|
|
const signingKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
const signingPublicKey = await crypto.subtle.exportKey('jwk', signingKeys.publicKey);
|
|
const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64');
|
|
const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
|
username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)),
|
|
public_key: JSON.stringify(pubkey), signing_public_key: JSON.stringify(signingPublicKey),
|
|
}) });
|
|
expect(registered.status).toBe(204);
|
|
const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) });
|
|
expect(session.status).toBe(200); accountToken = ((await session.json()) as { token: string }).token;
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
if (proc && !proc.killed) proc.kill();
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ───────────────────────── 客户端辅助 ─────────────────────────
|
|
|
|
const api = (path: string) => `${base}/api/v1${path}`;
|
|
|
|
async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] = {}) {
|
|
const created = await ChannelSession.create({ ...opts, settings: { name: 'Core E2E', ...opts.settings } });
|
|
const res = await fetch(api('/channels'), {
|
|
method: 'POST',
|
|
headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(created.request.body),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const info = (await res.json()) as { id: string; mode: string };
|
|
created.session.verifyChannelInfo(info); // 核对服务端返回的模式与链接一致
|
|
return created.session;
|
|
}
|
|
|
|
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
|
|
const res = await fetch(api(`/channels/${session.id}/join`), { method: 'POST', headers: headersFor(session, role) });
|
|
expect(res.status, await res.clone().text()).toBe(200);
|
|
}
|
|
|
|
async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
|
|
const seqs: number[] = [];
|
|
let first = true;
|
|
for await (const frame of plan.frames) {
|
|
const res = await fetch(api(`/channels/${session.id}/frames`), {
|
|
method: 'POST',
|
|
headers: { ...headersFor(session, role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
|
|
body: frame.bytes,
|
|
});
|
|
expect(res.status, await res.clone().text()).toBeLessThan(300);
|
|
seqs.push(((await res.json()) as { seq: number }).seq);
|
|
first = false;
|
|
}
|
|
return seqs;
|
|
}
|
|
|
|
async function readLog(session: ChannelSession, role: AuthRole, from = 0) {
|
|
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: headersFor(session, role) });
|
|
expect(res.status).toBe(200);
|
|
return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
|
|
}
|
|
|
|
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
|
|
function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) {
|
|
const h = headersFor(session, role);
|
|
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
|
|
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
|
|
protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
|
|
|
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
|
|
ws.binaryType = 'arraybuffer';
|
|
const reader = session.createItemReader();
|
|
const events: ItemEvent[] = [];
|
|
|
|
const done = new Promise<ItemEvent[]>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`等待 WebSocket 事件超时,已收到 ${JSON.stringify(events.map((e) => e.type))}`)), 15_000);
|
|
let chain = Promise.resolve();
|
|
ws.onmessage = (m) => {
|
|
if (typeof m.data === 'string') return; // 控制消息(心跳等)
|
|
chain = chain.then(async () => {
|
|
events.push(...(await reader.push(new Uint8Array(m.data as ArrayBuffer))));
|
|
if (until(events)) {
|
|
clearTimeout(timer);
|
|
resolve(events);
|
|
}
|
|
});
|
|
};
|
|
ws.onerror = () => reject(new Error('WebSocket 出错'));
|
|
});
|
|
const opened = new Promise<void>((resolve) => (ws.onopen = () => resolve()));
|
|
return { ws, done, opened, events };
|
|
}
|
|
|
|
const items = (events: ItemEvent[]): ReceivedItem[] => events.flatMap((e) => (e.type === 'item' ? [e.item] : []));
|
|
|
|
const suite = command ? describe : describe.skip;
|
|
// 全局 WebSocket 从 Node 22 起提供(本包要求 >=22);更旧的 Node 上明确跳过依赖它的用例,而不是误报失败
|
|
const wsIt = typeof WebSocket === 'undefined' ? it.skip : it;
|
|
|
|
suite('端到端(真实服务端)', () => {
|
|
it('文本:所有者发送,成员经日志拉取并解密', async () => {
|
|
const owner = await createChannel();
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
owner.deviceName = '桌面';
|
|
await joinChannel(member, 'read');
|
|
|
|
await send(owner, owner.encryptText('hello 端到端', { ts: 1 }), 'owner');
|
|
|
|
const reader = member.createItemReader();
|
|
const events: ItemEvent[] = [];
|
|
for (const rec of await readLog(member, 'read')) events.push(...(await reader.push(rec)));
|
|
|
|
const [item] = items(events);
|
|
expect(itemText(item!)).toBe('hello 端到端');
|
|
expect(item!.meta).toMatchObject({ kind: 'text', dev: '桌面' });
|
|
});
|
|
|
|
wsIt('WebSocket 实时推送:成员在线时收到所有者新发的内容', async () => {
|
|
const owner = await createChannel();
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
await joinChannel(member, 'read');
|
|
|
|
const sub = subscribe(member, 'read', (ev) => items(ev).length >= 2);
|
|
await sub.opened;
|
|
await send(owner, owner.encryptText('第一条'), 'owner');
|
|
await send(owner, owner.encryptJson({ n: 2 }), 'owner');
|
|
|
|
const got = items(await sub.done);
|
|
expect(itemText(got[0]!)).toBe('第一条');
|
|
expect(itemJson(got[1]!)).toEqual({ n: 2 });
|
|
sub.ws.close();
|
|
});
|
|
|
|
wsIt('成员也能上传(写令牌由 K 派生),所有者实时收到', async () => {
|
|
const owner = await createChannel();
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
await joinChannel(member, 'write');
|
|
|
|
const sub = subscribe(owner, 'owner', (ev) => items(ev).length >= 1);
|
|
await sub.opened;
|
|
await send(member, member.encryptText('来自成员'), 'write');
|
|
expect(itemText(items(await sub.done)[0]!)).toBe('来自成员');
|
|
sub.ws.close();
|
|
});
|
|
|
|
wsIt('快照:新快照取代旧快照,订阅者先后收到 item 和 removed(superseded)', async () => {
|
|
const owner = await createChannel();
|
|
const sub = subscribe(owner, 'owner', (ev) => ev.some((e) => e.type === 'removed'));
|
|
await sub.opened;
|
|
|
|
await send(owner, owner.encryptJson({ v: 1 }, { snapshot: true }), 'owner');
|
|
await send(owner, owner.encryptJson({ v: 2 }, { snapshot: true }), 'owner');
|
|
|
|
const events = await sub.done;
|
|
expect(events.map((e) => e.type)).toEqual(['item', 'item', 'removed']);
|
|
expect(items(events).map((i) => itemJson(i))).toEqual([{ v: 1 }, { v: 2 }]);
|
|
expect(events.find((e) => e.type === 'removed')).toMatchObject({ reason: 'superseded' });
|
|
sub.ws.close();
|
|
});
|
|
|
|
it('幂等:重发同一帧返回 200 和同一个 seq(确定性加密)', async () => {
|
|
const owner = await createChannel();
|
|
const plan = owner.encryptText('retry', { ts: 5 });
|
|
const [first] = await send(owner, plan, 'owner');
|
|
|
|
const again = owner.encryptText('retry', { ts: 5, itemId: plan.idBytes });
|
|
const frame = (await collectFrames(again))[0]!;
|
|
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
|
method: 'POST',
|
|
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
|
body: frame.bytes,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(((await res.json()) as { seq: number }).seq).toBe(first);
|
|
});
|
|
|
|
it('服务端的存储里没有明文', async () => {
|
|
const owner = await createChannel();
|
|
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
|
|
await send(owner, owner.encryptText(marker), 'owner');
|
|
|
|
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(owner, 'owner') });
|
|
const wire = new Uint8Array(await res.arrayBuffer());
|
|
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
|
|
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
|
|
});
|
|
|
|
it('大文件:多帧 GATED 上传,日志里只有 announce,下载流式解密后与原文一致', async () => {
|
|
const owner = await createChannel();
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
await joinChannel(member, 'read');
|
|
|
|
const content = pseudoRandom(3_300_000, 99);
|
|
const plan = owner.encryptFile(new Blob([content]), { name: 'big.bin', mime: 'application/octet-stream' }, { maxDownloads: 5 });
|
|
await send(owner, plan, 'owner');
|
|
expect(plan.gated).toBe(true);
|
|
|
|
// 日志里只有一条 announce,没有任何文件字节
|
|
const log = await readLog(member, 'read');
|
|
expect(log.map((r) => r.kind)).toEqual(['announce']);
|
|
|
|
// 流式下载 + 流式解密
|
|
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: headersFor(member, 'read') });
|
|
expect(res.status).toBe(200);
|
|
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
|
|
const out = await readAll(stream);
|
|
|
|
expect(await result).toMatchObject({ complete: true });
|
|
expect(out.length).toBe(content.length);
|
|
expect(Buffer.compare(out, content)).toBe(0);
|
|
expect((await result).meta).toMatchObject({ name: 'big.bin', size: content.length });
|
|
}, 60_000);
|
|
|
|
it('下载上限:超过后从头下载返回 410,续传(from>0)在宽限期内仍可用', async () => {
|
|
const owner = await createChannel();
|
|
const content = pseudoRandom(2_500_000, 5);
|
|
const plan = owner.encryptFile(new Blob([content]), { name: 'once.bin' }, { maxDownloads: 1 });
|
|
await send(owner, plan, 'owner');
|
|
|
|
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
|
|
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
|
|
const decoded = owner.decryptItemStream(first.body!);
|
|
expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0);
|
|
|
|
const second = await fetch(url, { headers: headersFor(owner, 'owner') });
|
|
expect(second.status).toBe(410);
|
|
expect(((await second.json()) as { error: string }).error).toBe('gone');
|
|
|
|
const resumed = await fetch(`${url}?from=1`, { headers: headersFor(owner, 'owner') });
|
|
expect(resumed.status).toBe(200);
|
|
await resumed.arrayBuffer();
|
|
}, 60_000);
|
|
|
|
it('上传断点续传:中途中断后用 fromFrame 重新加密剩余部分,服务端得到完整条目', async () => {
|
|
const owner = await createChannel();
|
|
const content = pseudoRandom(3_300_000, 123);
|
|
const blob = new Blob([content]);
|
|
|
|
const itemId = owner.encryptFile(blob).idBytes;
|
|
const full = await collectFrames(owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 }));
|
|
expect(full.length).toBe(4);
|
|
|
|
// 第一轮只上传前两帧,然后「崩溃」
|
|
const first = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 });
|
|
let sent = 0;
|
|
let isFirst = true;
|
|
for await (const f of first.frames) {
|
|
if (sent === 2) break;
|
|
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
|
method: 'POST',
|
|
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
|
|
body: f.bytes,
|
|
});
|
|
expect(res.status).toBe(201);
|
|
isFirst = false;
|
|
sent++;
|
|
}
|
|
|
|
// 第二轮:从第 2 帧开始重新加密(字节与完整加密一致),并且重发第 1 帧也是幂等的
|
|
const rest = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9, fromFrame: 1 });
|
|
const statuses: number[] = [];
|
|
for await (const f of rest.frames) {
|
|
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
|
method: 'POST',
|
|
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
|
body: f.bytes,
|
|
});
|
|
statuses.push(res.status);
|
|
}
|
|
expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的
|
|
|
|
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: headersFor(owner, 'owner') });
|
|
const { content: stream, result } = owner.decryptItemStream(res.body!);
|
|
expect(Buffer.compare(await readAll(stream), content)).toBe(0);
|
|
expect((await result).complete).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('下载中途断线,用 nextIndex 续传,拼起来与原文一致', async () => {
|
|
const owner = await createChannel();
|
|
const content = pseudoRandom(3_300_000, 7);
|
|
const plan = owner.encryptFile(new Blob([content]), { name: 'cut.bin' });
|
|
await send(owner, plan, 'owner');
|
|
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
|
|
|
|
// 第一次:只读前约 1.5 帧就断开
|
|
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
|
|
const reader = first.body!.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let got = 0;
|
|
while (got < 1_600_000) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value);
|
|
got += value.length;
|
|
}
|
|
await reader.cancel();
|
|
|
|
const part1 = await (async () => {
|
|
const dec = owner.decryptItemStream(new ReadableStream<Uint8Array>({ start: (c) => { chunks.forEach((x) => c.enqueue(x)); c.close(); } }));
|
|
return { bytes: await readAll(dec.content), result: await dec.result };
|
|
})();
|
|
expect(part1.result.complete).toBe(false);
|
|
|
|
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: headersFor(owner, 'owner') });
|
|
expect(second.status).toBe(200);
|
|
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
|
|
const part2 = await readAll(dec2.content);
|
|
|
|
expect(toHex(concatAll([part1.bytes, part2]).subarray(0, 32))).toBe(toHex(content.subarray(0, 32)));
|
|
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
|
|
}, 60_000);
|
|
|
|
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
|
|
const owner = await createChannel({ mode: 'raw' });
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
await joinChannel(member, 'read');
|
|
|
|
await send(owner, owner.encryptText('raw 明文 visible'), 'owner');
|
|
const records = await readLog(member, 'read');
|
|
const [item] = items(await (async () => {
|
|
const reader = member.createItemReader();
|
|
const out: ItemEvent[] = [];
|
|
for (const r of records) out.push(...(await reader.push(r)));
|
|
return out;
|
|
})());
|
|
expect(item!.kind).toBe('text');
|
|
expect(itemText(item!)).toBe('raw 明文 visible');
|
|
|
|
// 对比 enc:raw 的明文确实出现在服务端返回的字节里
|
|
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(member, 'read') });
|
|
expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible');
|
|
});
|
|
|
|
it('下发给成员的链接没有管理权:成员拿不到所有者能力', async () => {
|
|
const owner = await createChannel();
|
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
|
await joinChannel(member, 'read');
|
|
|
|
const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' });
|
|
expect(res.status).toBe(403);
|
|
expect(() => headersFor(member, 'owner')).toThrow();
|
|
});
|
|
});
|