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.
81 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|