page.tsx now calls the real backend: GET /files with groupId/name/mimeClass/ cursor/limit (origin filter dropped — the api has no origin param), shares tracked as ShareCreated (revoke by id), retention mapped to the orthogonal legalHold model, downloads by item.name, samples reshaped to the wire contract. GroupFile gains mimeClass (the indexed coarse class the row icon and the filter depend on): core contract, service row mapper, parseGroupFile guard, test fixture, UI types — one vocabulary end to end. parseGroupFile's roomId becomes null-tolerant to match direct-upload rows (mapping input stays strict). Verified: core build clean, api typecheck 0 errors + group-files 22/22, web typecheck 0 errors.
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
|
|
import { Readable } from "node:stream";
|
|
import {
|
|
decodeFileListCursor,
|
|
encodeFileListCursor,
|
|
} from "./file-cursor.js";
|
|
import {
|
|
parseGroupFile,
|
|
parseMatrixAttachmentCandidate,
|
|
parseRetention,
|
|
parseRetentionInput,
|
|
parseSharedFileOrigin,
|
|
requireMxid,
|
|
requireMxcUri,
|
|
requireRoomId,
|
|
requireSha256,
|
|
} from "./group-files.validation.js";
|
|
import { OcisVerificationError, ocisConfigFromEnv, OcisWebdavClient, verifyWhileStreaming } from "./ocis-webdav.js";
|
|
import { hashShareToken, isPlausibleShareToken, mintShareToken, sameToken } from "./share-token.js";
|
|
|
|
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
|
|
function hex64(seed: string): string {
|
|
let out = "";
|
|
while (out.length < 64) out += Buffer.from(seed).toString("hex");
|
|
return out.slice(0, 64);
|
|
}
|
|
|
|
describe("group-files runtime validation (shared contracts)", () => {
|
|
it("accepts the four SharedFileOrigin variants and rejects anything else", () => {
|
|
expect(parseSharedFileOrigin({ kind: "matrix", mxcUri: "mxc://example.org/abc" })).toEqual({
|
|
kind: "matrix",
|
|
mxcUri: "mxc://example.org/abc",
|
|
});
|
|
expect(
|
|
parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" }),
|
|
).toEqual({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" });
|
|
expect(parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "t", remoteEventId: "42" })).toEqual({
|
|
kind: "bridge",
|
|
mxcUri: "mxc://example.org/abc",
|
|
providerId: "t",
|
|
remoteEventId: "42",
|
|
});
|
|
expect(parseSharedFileOrigin({ kind: "upload" })).toEqual({ kind: "upload" });
|
|
expect(parseSharedFileOrigin({ kind: "pad-export", padId: "p", revision: "7" })).toEqual({
|
|
kind: "pad-export",
|
|
padId: "p",
|
|
revision: "7",
|
|
});
|
|
for (const bad of [null, {}, { kind: "nope" }, { kind: "matrix" }, { kind: "matrix", mxcUri: "https://evil/x" }]) {
|
|
expect(() => parseSharedFileOrigin(bad)).toThrow(BadRequestException);
|
|
}
|
|
});
|
|
|
|
it("validates MXIDs, room ids, mxc URIs and sha256 shape", () => {
|
|
expect(requireMxid("@alice:example.org")).toBe("@alice:example.org");
|
|
expect(() => requireMxid("Alice (display)")).toThrow(BadRequestException);
|
|
expect(() => requireMxid("#alias:example.org")).toThrow(BadRequestException);
|
|
expect(requireRoomId("!archive:example.org")).toBe("!archive:example.org");
|
|
expect(() => requireRoomId("#alias:example.org")).toThrow(BadRequestException); // alias, never an identity
|
|
expect(() => requireRoomId("")).toThrow(BadRequestException);
|
|
expect(requireMxcUri("mxc://example.org/AbC123")).toBe("mxc://example.org/AbC123");
|
|
expect(() => requireMxcUri("mxc://example.org/")).toThrow(BadRequestException);
|
|
expect(requireSha256(hex64("a"))).toBe(hex64("a"));
|
|
expect(() => requireSha256("nope")).toThrow(BadRequestException);
|
|
// hex digits include letters: an uppercase digest must be rejected
|
|
const upperHex = "A".repeat(64);
|
|
expect(/[A-F]/.test(upperHex)).toBe(true);
|
|
expect(() => requireSha256(upperHex)).toThrow(BadRequestException);
|
|
expect(() => requireSha256("0".repeat(63))).toThrow(BadRequestException); // wrong length
|
|
});
|
|
|
|
it("keeps retention separate from link expiry and enforces shape coupling", () => {
|
|
expect(parseRetention({ mode: "keep", legalHold: false })).toEqual({ mode: "keep", legalHold: false });
|
|
// legalHold is a FLAG alongside the mode, not a third mode (FileRetention contract)
|
|
expect(parseRetention({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
|
|
expect(parseRetention({ mode: "until", legalHold: true, expiresAt: "2030-01-01T00:00:00Z" })).toEqual({
|
|
mode: "until",
|
|
legalHold: true,
|
|
expiresAt: "2030-01-01T00:00:00Z",
|
|
});
|
|
expect(() => parseRetention({ mode: "burn", legalHold: false })).toThrow(BadRequestException);
|
|
expect(() => parseRetention({ mode: "until", legalHold: false })).toThrow(BadRequestException);
|
|
expect(() => parseRetention({ mode: "keep", legalHold: false, expiresAt: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
|
|
expect(() => parseRetention({ mode: "until", legalHold: false, expiresAt: "next tuesday" })).toThrow(BadRequestException);
|
|
// input variant
|
|
expect(parseRetentionInput({ mode: "until", until: "2030-01-01T00:00:00Z" })).toEqual({
|
|
mode: "until",
|
|
legalHold: false,
|
|
expiresAt: "2030-01-01T00:00:00Z",
|
|
});
|
|
expect(() => parseRetentionInput({ mode: "until" })).toThrow(BadRequestException);
|
|
expect(() => parseRetentionInput({ mode: "keep", until: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
|
|
expect(parseRetentionInput({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
|
|
expect(() => parseRetentionInput({ mode: "legalHold" })).toThrow(BadRequestException); // not a mode
|
|
});
|
|
|
|
it("fails closed on Matrix attachment candidates with unknown/bad state", () => {
|
|
const good = {
|
|
roomId: "!a:example.org",
|
|
eventId: "$1",
|
|
senderId: "@s:example.org",
|
|
eventType: "m.room.message",
|
|
msgtype: "m.image",
|
|
roomEncryption: "unencrypted",
|
|
encryptedFile: false,
|
|
redacted: false,
|
|
mxcUri: "mxc://example.org/x",
|
|
};
|
|
expect(parseMatrixAttachmentCandidate(good).roomEncryption).toBe("unencrypted");
|
|
// shape-level failures
|
|
for (const patch of [
|
|
{ roomId: "#alias:example.org" },
|
|
{ senderId: "Bob" },
|
|
{ mxcUri: "https://attacker/x" },
|
|
{ encryptedFile: "yes" },
|
|
]) {
|
|
expect(() => parseMatrixAttachmentCandidate({ ...good, ...patch })).toThrow(BadRequestException);
|
|
}
|
|
// state-level eligibility is the CORE helper's call, not the parser's:
|
|
// parse accepts encrypted/unknown/redacted states; canArchiveAttachment
|
|
// is what fails closed on them (core test shared-files.test.ts).
|
|
const parsed = parseMatrixAttachmentCandidate({ ...good, roomEncryption: "unknown" });
|
|
expect(parsed.roomEncryption).toBe("unknown");
|
|
});
|
|
|
|
it("round-trips a full GroupFile wire shape", () => {
|
|
const value = {
|
|
id: "b3a1c2d4-1111-4222-8333-444455556666",
|
|
workspaceId: "c3a1c2d4-1111-4222-8333-444455556666",
|
|
groupId: "d3a1c2d4-1111-4222-8333-444455556666",
|
|
roomId: "!r:example.org",
|
|
senderId: "@s:example.org",
|
|
eventId: "$evt",
|
|
origin: { kind: "matrix", mxcUri: "mxc://example.org/x" },
|
|
name: "clip.mp4",
|
|
mimeType: "video/mp4",
|
|
mimeClass: "video",
|
|
sizeBytes: 12,
|
|
checksum: { algorithm: "sha256", value: hex64("z") },
|
|
createdAt: "2026-01-02T03:04:05Z",
|
|
indexedAt: "2026-01-02T03:04:05Z",
|
|
file: { spaceId: "sp", resourceId: "res", version: "v1" },
|
|
shareLink: { url: "https://files.example.org/f/res", access: "members", expiresAt: null },
|
|
retention: { mode: "keep", legalHold: false },
|
|
status: "available",
|
|
};
|
|
expect(parseGroupFile(value)).toEqual(value);
|
|
expect(() => parseGroupFile({ ...value, sizeBytes: -1 })).toThrow(BadRequestException);
|
|
expect(() => parseGroupFile({ ...value, senderId: "Bob" })).toThrow(BadRequestException);
|
|
expect(() => parseGroupFile({ ...value, checksum: { algorithm: "sha256", value: "zz" } })).toThrow(BadRequestException);
|
|
});
|
|
});
|
|
|
|
describe("file-cursor", () => {
|
|
it("encodes/decodes (createdAt,id) and rejects malformed scopes", () => {
|
|
const cursor = { createdAt: "2026-01-02T03:04:05Z", id: "b3a1c2d4-1111-4222-8333-444455556666" };
|
|
expect(decodeFileListCursor(encodeFileListCursor(cursor))).toEqual(cursor);
|
|
for (const bad of ["", "!!!", Buffer.from(JSON.stringify({ createdAt: "nope", id: "x" })).toString("base64url")]) {
|
|
expect(() => decodeFileListCursor(bad)).toThrow(BadRequestException);
|
|
}
|
|
// a cursor from another tenant carries no scope: it is just an empty page,
|
|
// never a leak — the scope is re-bound server-side on every request.
|
|
expect(decodeFileListCursor(encodeFileListCursor({ createdAt: "2026-01-02T03:04:05Z", id: "00000000-0000-4000-8000-000000000000" })).id).toBe(
|
|
"00000000-0000-4000-8000-000000000000",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("share tokens", () => {
|
|
it("mints 256-bit capabilities and stores only sha256(token)", () => {
|
|
const token = mintShareToken();
|
|
expect(token.length).toBeGreaterThanOrEqual(40);
|
|
expect(isPlausibleShareToken(token)).toBe(true);
|
|
expect(isPlausibleShareToken("short")).toBe(false);
|
|
expect(isPlausibleShareToken("not base64url!!")).toBe(false);
|
|
const hash = hashShareToken(token);
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(hashShareToken(token)).toBe(hash); // deterministic
|
|
expect(hashShareToken(mintShareToken())).not.toBe(hash); // unique per mint
|
|
expect(sameToken(token, token)).toBe(true);
|
|
expect(sameToken(token, mintShareToken())).toBe(false);
|
|
// the plaintext can never be derived from the hash
|
|
expect(hash).not.toContain(token);
|
|
});
|
|
});
|
|
|
|
describe("ocis-webdav config + streaming verification", () => {
|
|
it("reads env lazily and throws 503 when unconfigured", () => {
|
|
expect(() => ocisConfigFromEnv({})).toThrow(ServiceUnavailableException);
|
|
expect(() => ocisConfigFromEnv({ FILES_OCID_WEBDAV_URL: "https://files.example.org" })).toThrow(ServiceUnavailableException);
|
|
const config = ocisConfigFromEnv({
|
|
FILES_OCID_WEBDAV_URL: "https://files.example.org/",
|
|
FILES_OCID_USER: "svc",
|
|
FILES_OCID_PASSWORD: "pw",
|
|
FILES_PUBLIC_BASE: "https://files.example.org/",
|
|
});
|
|
expect(config.webdavUrl).toBe("https://files.example.org");
|
|
expect(config.publicBase).toBe("https://files.example.org");
|
|
});
|
|
|
|
it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => {
|
|
const bytes = Buffer.from("group file payload");
|
|
const calls: string[] = [];
|
|
const fetcher = (async (url: string, init: RequestInit) => {
|
|
calls.push(`${init.method} ${url}`);
|
|
return new Response(null, {
|
|
status: 201,
|
|
headers: init.method === "MKCOL" ? new Headers() : { "oc-fileid": "res-1", "oc-etag": "v-9" },
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
const client = new OcisWebdavClient(
|
|
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
|
|
fetcher,
|
|
);
|
|
const result = await client.upload("group-1", "clip.mp4", Readable.from([bytes]), "video/mp4");
|
|
const { createHash } = await import("node:crypto");
|
|
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
|
|
expect(result.sizeBytes).toBe(bytes.byteLength);
|
|
expect(result.resourceId).toBe("res-1");
|
|
expect(result.version).toBe("v-9");
|
|
expect(calls[0]).toBe("MKCOL https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1");
|
|
expect(calls[1]).toBe("PUT https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1/clip.mp4");
|
|
expect(client.uploadUrl("g", "n.mp4")).toContain("/remote.php/dav/files/svc/hub-groups/g/n.mp4");
|
|
expect(client.libraryUrl("res-1")).toBe("https://files.example.org/f/res-1");
|
|
});
|
|
|
|
it("fails the upload when the group folder cannot be created", async () => {
|
|
const fetcher = (async (url: string, init: RequestInit) =>
|
|
new Response(null, { status: init.method === "MKCOL" ? 500 : 201 })) as unknown as typeof fetch;
|
|
const client = new OcisWebdavClient(
|
|
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
|
|
fetcher,
|
|
);
|
|
await expect(
|
|
client.upload("group-1", "clip.mp4", Readable.from(["x"]), "text/plain"),
|
|
).rejects.toThrow("oCIS folder creation failed with 500");
|
|
});
|
|
|
|
it("verifyWhileStreaming passes on match and aborts on tamper", async () => {
|
|
const { createHash } = await import("node:crypto");
|
|
const payload = Buffer.from("0123456789");
|
|
const good = createHash("sha256").update(payload).digest("hex");
|
|
const sinkChunks: Buffer[] = [];
|
|
const out = await verifyWhileStreaming(
|
|
{ stream: Readable.from([payload.subarray(0, 4), payload.subarray(4)]), sizeBytes: payload.byteLength, contentType: "application/octet-stream" },
|
|
good,
|
|
(chunk) => sinkChunks.push(chunk),
|
|
);
|
|
expect(out.sizeBytes).toBe(10);
|
|
expect(Buffer.concat(sinkChunks).toString()).toBe("0123456789");
|
|
await expect(
|
|
verifyWhileStreaming(
|
|
{ stream: Readable.from([Buffer.from("tampered")]), sizeBytes: 8, contentType: null },
|
|
good,
|
|
() => undefined,
|
|
),
|
|
).rejects.toBeInstanceOf(OcisVerificationError);
|
|
});
|
|
});
|