Lightweight replacement for the upstream La Suite Numérique drive (Django/Celery/Next.js) built as a single Deno binary. Server (Deno + Hono): - S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs - WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks) - Ory Kratos session auth + CSRF protection - Ory Keto permission model (OPL namespaces, not yet wired to routes) - PostgreSQL metadata with recursive folder sizes - S3 backfill API for registering files uploaded outside the UI - OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED) Frontend (React 19 + Cunningham v4 + react-aria): - File browser with GridList, keyboard nav, multi-select - Collabora editor iframe (full-screen, form POST, postMessage) - Profile menu, waffle menu, drag-drop upload, asset type badges - La Suite integration service theming (runtime CSS) Testing (549 tests): - 235 server unit tests (Deno) — 90%+ coverage - 278 UI unit tests (Vitest) — 90%+ coverage - 11 E2E tests (Playwright) - 12 integration service tests (Playwright) - 13 WOPI integration tests (Playwright + Docker Compose + Collabora) MIT licensed.
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
import {
|
|
assertEquals,
|
|
assertRejects,
|
|
} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
|
|
|
// ── Fetch mock infrastructure ────────────────────────────────────────────────
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
function mockFetch(
|
|
handler: (url: string, init?: RequestInit) => Promise<Response> | Response,
|
|
) {
|
|
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
const url = typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.toString()
|
|
: input.url;
|
|
return Promise.resolve(handler(url, init));
|
|
}) as typeof globalThis.fetch;
|
|
}
|
|
|
|
function restoreFetch() {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
// We need to set env vars before importing the module
|
|
Deno.env.set("KETO_READ_URL", "http://keto-read:4466");
|
|
Deno.env.set("KETO_WRITE_URL", "http://keto-write:4467");
|
|
|
|
// Dynamic import so env vars are picked up (module-level const reads at import time).
|
|
async function importKeto() {
|
|
const mod = await import(`../../server/keto.ts?v=${Date.now()}`);
|
|
return mod;
|
|
}
|
|
|
|
// Since Deno caches module imports, we import once and re-use.
|
|
const keto = await importKeto();
|
|
|
|
// ── checkPermission tests ───────────────────────────────────────────────────
|
|
|
|
Deno.test("checkPermission — returns true when Keto says allowed", async () => {
|
|
mockFetch((url, init) => {
|
|
assertEquals(url, "http://keto-read:4466/relation-tuples/check/openapi");
|
|
assertEquals(init?.method, "POST");
|
|
const body = JSON.parse(init?.body as string);
|
|
assertEquals(body.namespace, "files");
|
|
assertEquals(body.object, "file-123");
|
|
assertEquals(body.relation, "read");
|
|
assertEquals(body.subject_id, "user-abc");
|
|
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
|
|
});
|
|
|
|
try {
|
|
const result = await keto.checkPermission("files", "file-123", "read", "user-abc");
|
|
assertEquals(result, true);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("checkPermission — returns false when Keto denies", async () => {
|
|
mockFetch(() => new Response(JSON.stringify({ allowed: false }), { status: 200 }));
|
|
try {
|
|
const result = await keto.checkPermission("files", "file-123", "write", "user-abc");
|
|
assertEquals(result, false);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("checkPermission — returns false on network error", async () => {
|
|
mockFetch(() => {
|
|
throw new Error("network down");
|
|
});
|
|
try {
|
|
const result = await keto.checkPermission("files", "file-123", "read", "user-abc");
|
|
assertEquals(result, false);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("checkPermission — returns false on non-200 response", async () => {
|
|
mockFetch(() => new Response("error", { status: 500 }));
|
|
try {
|
|
const result = await keto.checkPermission("files", "file-123", "read", "user-abc");
|
|
assertEquals(result, false);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── createRelationship tests ────────────────────────────────────────────────
|
|
|
|
Deno.test("createRelationship — sends correct PUT request", async () => {
|
|
let capturedUrl = "";
|
|
let capturedBody: Record<string, unknown> = {};
|
|
let capturedMethod = "";
|
|
|
|
mockFetch((url, init) => {
|
|
capturedUrl = url;
|
|
capturedMethod = init?.method ?? "";
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
return new Response(JSON.stringify({}), { status: 201 });
|
|
});
|
|
|
|
try {
|
|
await keto.createRelationship("files", "file-456", "owners", "user-xyz");
|
|
assertEquals(capturedUrl, "http://keto-write:4467/admin/relation-tuples");
|
|
assertEquals(capturedMethod, "PUT");
|
|
assertEquals(capturedBody.namespace, "files");
|
|
assertEquals(capturedBody.object, "file-456");
|
|
assertEquals(capturedBody.relation, "owners");
|
|
assertEquals(capturedBody.subject_id, "user-xyz");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("createRelationship — throws on failure", async () => {
|
|
mockFetch(() => new Response("bad", { status: 500 }));
|
|
try {
|
|
await assertRejects(
|
|
() => keto.createRelationship("files", "file-456", "owners", "user-xyz"),
|
|
Error,
|
|
"Keto createRelationship failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── createRelationshipWithSubjectSet tests ──────────────────────────────────
|
|
|
|
Deno.test("createRelationshipWithSubjectSet — sends subject_set payload", async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
|
|
mockFetch((_url, init) => {
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
return new Response("{}", { status: 201 });
|
|
});
|
|
|
|
try {
|
|
await keto.createRelationshipWithSubjectSet(
|
|
"files", "file-1", "parents",
|
|
"folders", "folder-2", "",
|
|
);
|
|
assertEquals(capturedBody.subject_set, {
|
|
namespace: "folders",
|
|
object: "folder-2",
|
|
relation: "",
|
|
});
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("createRelationshipWithSubjectSet — throws on failure", async () => {
|
|
mockFetch(() => new Response("error", { status: 403 }));
|
|
try {
|
|
await assertRejects(
|
|
() => keto.createRelationshipWithSubjectSet("files", "f1", "parents", "folders", "f2", ""),
|
|
Error,
|
|
"Keto createRelationshipWithSubjectSet failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── deleteRelationship tests ────────────────────────────────────────────────
|
|
|
|
Deno.test("deleteRelationship — sends DELETE with query params", async () => {
|
|
let capturedUrl = "";
|
|
let capturedMethod = "";
|
|
|
|
mockFetch((url, init) => {
|
|
capturedUrl = url;
|
|
capturedMethod = init?.method ?? "";
|
|
return new Response(null, { status: 204 });
|
|
});
|
|
|
|
try {
|
|
await keto.deleteRelationship("files", "file-1", "owners", "user-1");
|
|
assertEquals(capturedMethod, "DELETE");
|
|
const u = new URL(capturedUrl);
|
|
assertEquals(u.searchParams.get("namespace"), "files");
|
|
assertEquals(u.searchParams.get("object"), "file-1");
|
|
assertEquals(u.searchParams.get("relation"), "owners");
|
|
assertEquals(u.searchParams.get("subject_id"), "user-1");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("deleteRelationship — throws on failure", async () => {
|
|
mockFetch(() => new Response("error", { status: 500 }));
|
|
try {
|
|
await assertRejects(
|
|
() => keto.deleteRelationship("files", "file-1", "owners", "user-1"),
|
|
Error,
|
|
"Keto deleteRelationship failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── batchWriteRelationships tests ───────────────────────────────────────────
|
|
|
|
Deno.test("batchWriteRelationships — formats patches correctly", async () => {
|
|
let capturedBody: unknown[] = [];
|
|
let capturedMethod = "";
|
|
|
|
mockFetch((_url, init) => {
|
|
capturedMethod = init?.method ?? "";
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
return new Response(null, { status: 204 });
|
|
});
|
|
|
|
try {
|
|
await keto.batchWriteRelationships([
|
|
{
|
|
action: "insert",
|
|
relation_tuple: {
|
|
namespace: "files",
|
|
object: "file-1",
|
|
relation: "owners",
|
|
subject_id: "user-1",
|
|
},
|
|
},
|
|
{
|
|
action: "delete",
|
|
relation_tuple: {
|
|
namespace: "files",
|
|
object: "file-1",
|
|
relation: "viewers",
|
|
subject_id: "user-2",
|
|
},
|
|
},
|
|
]);
|
|
assertEquals(capturedMethod, "PATCH");
|
|
assertEquals(capturedBody.length, 2);
|
|
assertEquals((capturedBody[0] as Record<string, unknown>).action, "insert");
|
|
assertEquals((capturedBody[1] as Record<string, unknown>).action, "delete");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("batchWriteRelationships — throws on failure", async () => {
|
|
mockFetch(() => new Response("error", { status: 500 }));
|
|
try {
|
|
await assertRejects(
|
|
() =>
|
|
keto.batchWriteRelationships([
|
|
{
|
|
action: "insert",
|
|
relation_tuple: {
|
|
namespace: "files",
|
|
object: "file-1",
|
|
relation: "owners",
|
|
subject_id: "user-1",
|
|
},
|
|
},
|
|
]),
|
|
Error,
|
|
"Keto batchWriteRelationships failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── listRelationships tests ─────────────────────────────────────────────────
|
|
|
|
Deno.test("listRelationships — sends GET with query params", async () => {
|
|
let capturedUrl = "";
|
|
|
|
mockFetch((url) => {
|
|
capturedUrl = url;
|
|
return new Response(
|
|
JSON.stringify({
|
|
relation_tuples: [
|
|
{ namespace: "files", object: "f1", relation: "owners", subject_id: "u1" },
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
});
|
|
|
|
try {
|
|
const tuples = await keto.listRelationships("files", "f1", "owners");
|
|
assertEquals(tuples.length, 1);
|
|
assertEquals(tuples[0].subject_id, "u1");
|
|
const u = new URL(capturedUrl);
|
|
assertEquals(u.searchParams.get("namespace"), "files");
|
|
assertEquals(u.searchParams.get("object"), "f1");
|
|
assertEquals(u.searchParams.get("relation"), "owners");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("listRelationships — with only namespace param", async () => {
|
|
let capturedUrl = "";
|
|
|
|
mockFetch((url) => {
|
|
capturedUrl = url;
|
|
return new Response(
|
|
JSON.stringify({ relation_tuples: [] }),
|
|
{ status: 200 },
|
|
);
|
|
});
|
|
|
|
try {
|
|
const tuples = await keto.listRelationships("files");
|
|
assertEquals(tuples.length, 0);
|
|
const u = new URL(capturedUrl);
|
|
assertEquals(u.searchParams.get("namespace"), "files");
|
|
assertEquals(u.searchParams.has("object"), false);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("listRelationships — with subject_id param", async () => {
|
|
let capturedUrl = "";
|
|
|
|
mockFetch((url) => {
|
|
capturedUrl = url;
|
|
return new Response(
|
|
JSON.stringify({ relation_tuples: [] }),
|
|
{ status: 200 },
|
|
);
|
|
});
|
|
|
|
try {
|
|
await keto.listRelationships("files", undefined, undefined, "user-1");
|
|
const u = new URL(capturedUrl);
|
|
assertEquals(u.searchParams.get("subject_id"), "user-1");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("listRelationships — throws on failure", async () => {
|
|
mockFetch(() => new Response("error", { status: 500 }));
|
|
try {
|
|
await assertRejects(
|
|
() => keto.listRelationships("files"),
|
|
Error,
|
|
"Keto listRelationships failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("listRelationships — handles empty relation_tuples", async () => {
|
|
mockFetch(() =>
|
|
new Response(JSON.stringify({}), { status: 200 })
|
|
);
|
|
|
|
try {
|
|
const tuples = await keto.listRelationships("files");
|
|
assertEquals(tuples.length, 0);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
// ── expandPermission tests ──────────────────────────────────────────────────
|
|
|
|
Deno.test("expandPermission — sends POST with correct body", async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
|
|
mockFetch((_url, init) => {
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
return new Response(JSON.stringify({ type: "union", children: [] }), { status: 200 });
|
|
});
|
|
|
|
try {
|
|
const result = await keto.expandPermission("files", "file-1", "read", 5);
|
|
assertEquals(capturedBody.namespace, "files");
|
|
assertEquals(capturedBody.max_depth, 5);
|
|
assertEquals((result as Record<string, unknown>).type, "union");
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("expandPermission — uses default max_depth of 3", async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
|
|
mockFetch((_url, init) => {
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
return new Response(JSON.stringify({ type: "leaf" }), { status: 200 });
|
|
});
|
|
|
|
try {
|
|
await keto.expandPermission("files", "file-1", "read");
|
|
assertEquals(capturedBody.max_depth, 3);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|
|
|
|
Deno.test("expandPermission — throws on failure", async () => {
|
|
mockFetch(() => new Response("error", { status: 500 }));
|
|
try {
|
|
await assertRejects(
|
|
() => keto.expandPermission("files", "file-1", "read"),
|
|
Error,
|
|
"Keto expandPermission failed",
|
|
);
|
|
} finally {
|
|
restoreFetch();
|
|
}
|
|
});
|