T-HUB/apps/archive-bot/src/inspector.test.ts
glass beadian d58db63fe6 Add group cloud: files index api, web UI, archive sync bot
The Telegram-like group file contract (packages/core shared-files.ts) becomes
a working subsystem, built in three parallel tracks:

API (NestJS): migration 029 group_files (chat_media_archive metadata-only
index with partial replay-unique + RLS, group_file_shares with sha256 token
persistence, archive_sync_state/outbox durable-claim seam incl. a unique
claim_key index), group-files module (list/search with cursor pagination,
direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL +
retention rechecks, share links with expiry, retention modes incl.
legalHold) — RBAC group-membership authz + audit chain events.

Web: Files section per group — group picker, drag-drop upload (20 MiB cap),
search + MIME/origin filters, cursor pagination, download via authenticated
blob fetch, share dialog with expiry + shown-once token, admin retention
dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav
wiring; MIME classes aligned to the DB CHECK (document/other).

Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring
attachments from explicitly-mapped unencrypted archive rooms — raw /sync
with durable since-cursor, outbox-claimed ingestion (hash-verified stream,
no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV
upload, indexed + replied with link, redaction→unavailable; health endpoint;
Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
2026-10-11 08:21:11 +00:00

81 lines
3.1 KiB
TypeScript

import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { inspectStream, parseContentLength, PayloadInspector, PermanentArchiveError } from "./inspector.js";
const PDF = new TextEncoder().encode("%PDF-1.7\nhello world");
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {
let total = 0;
for await (const chunk of stream) total += chunk.byteLength;
return total;
}
describe("PayloadInspector", () => {
it("hashes, counts and sniffs across chunk boundaries", () => {
const inspector = new PayloadInspector(PDF.byteLength);
inspector.update(PDF.subarray(0, 2));
inspector.update(PDF.subarray(2));
expect(inspector.finish()).toEqual({
sha256: createHash("sha256").update(PDF).digest("hex"),
byteSize: PDF.byteLength,
sniffed: { mime: "application/pdf", mimeClass: "document", extension: "pdf" },
});
});
it("rejects an oversized declared length before reading", () => {
expect(() => new PayloadInspector(21 * 1024 * 1024)).toThrow(PermanentArchiveError);
});
it("rejects streams exceeding the cap without Content-Length", () => {
const inspector = new PayloadInspector(null, 10);
expect(() => inspector.update(new Uint8Array(11))).toThrow(/exceeds/);
});
it("rejects Content-Length lies in both directions", () => {
const longer = new PayloadInspector(4);
expect(() => longer.update(new Uint8Array(5))).toThrow(/longer/);
const shorter = new PayloadInspector(10);
shorter.update(new Uint8Array(4));
expect(() => shorter.finish()).toThrow(/shorter/);
});
it("rejects empty payloads", () => {
expect(() => new PayloadInspector(null).finish()).toThrow(/empty/);
});
});
describe("inspectStream", () => {
it("passes bytes through untouched and resolves the result", async () => {
const { stream, result } = inspectStream(streamOf(PDF.subarray(0, 5), PDF.subarray(5)), PDF.byteLength);
expect(await drain(stream)).toBe(PDF.byteLength);
expect((await result).byteSize).toBe(PDF.byteLength);
});
it("errors the stream (aborting the upload) when the source lies about length", async () => {
const { stream, result } = inspectStream(streamOf(PDF), PDF.byteLength + 1);
await expect(drain(stream)).rejects.toThrow(PermanentArchiveError);
await expect(result).rejects.toThrow(/shorter/);
});
it("errors mid-stream once the cap is crossed", async () => {
const { stream } = inspectStream(streamOf(new Uint8Array(6), new Uint8Array(6)), null, 10);
await expect(drain(stream)).rejects.toThrow(/exceeds/);
});
});
describe("parseContentLength", () => {
it("parses digits and treats absence as unknown", () => {
expect(parseContentLength("42")).toBe(42);
expect(parseContentLength(null)).toBeNull();
expect(() => parseContentLength("-1")).toThrow(PermanentArchiveError);
});
});