Initial commit — Drive, an S3 file browser with WOPI editing

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.
This commit is contained in:
2026-03-25 18:28:37 +00:00
commit 58237d9e44
112 changed files with 26841 additions and 0 deletions

584
tests/server/auth_test.ts Normal file
View File

@@ -0,0 +1,584 @@
import {
assertEquals,
assertStringIncludes,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { Hono } from "hono";
import { authMiddleware, getSession, sessionHandler } from "../../server/auth.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;
}
// ── authMiddleware tests ─────────────────────────────────────────────────────
Deno.test("auth middleware - /health bypasses auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/health", (c) => c.json({ ok: true }));
const res = await app.request("/health");
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.ok, true);
});
Deno.test("auth middleware - /health/ subpath bypasses auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/health/deep", (c) => c.json({ ok: true }));
const res = await app.request("/health/deep");
assertEquals(res.status, 200);
});
Deno.test("auth middleware - /api route returns 401 without session (JSON accept)", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/files", (c) => c.json({ files: [] }));
const res = await app.request("/api/files", {
headers: { accept: "application/json" },
});
assertEquals(res.status, 401);
const body = await res.json();
assertEquals(body.error, "Unauthorized");
});
Deno.test("auth middleware - static assets bypass auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/assets/main.js", (c) => c.text("js"));
const res = await app.request("/assets/main.js");
assertEquals(res.status, 200);
});
Deno.test("auth middleware - /index.html bypasses auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/index.html", (c) => c.text("html"));
const res = await app.request("/index.html");
assertEquals(res.status, 200);
});
Deno.test("auth middleware - /favicon.ico bypasses auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/favicon.ico", (c) => c.text("icon"));
const res = await app.request("/favicon.ico");
assertEquals(res.status, 200);
});
Deno.test("auth middleware - /wopi routes bypass session auth", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/wopi/files/test-id", (c) => c.json({ BaseFileName: "test.docx" }));
const res = await app.request("/wopi/files/test-id");
assertEquals(res.status, 200);
});
Deno.test("auth middleware - browser request without session redirects to login", async () => {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/explorer", (c) => c.text("ok"));
const res = await app.request("/explorer", {
headers: { accept: "text/html" },
redirect: "manual",
});
assertEquals(res.status, 302);
const location = res.headers.get("location") ?? "";
assertEquals(location.includes("/login"), true);
});
Deno.test("auth middleware - valid session sets identity and calls next", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-abc-123",
traits: {
email: "alice@example.com",
given_name: "Alice",
family_name: "Smith",
picture: "https://img.example.com/alice.jpg",
},
},
}),
{ status: 200 },
)
);
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/files", (c) => {
// deno-lint-ignore no-explicit-any
const identity = (c as any).get("identity");
return c.json({ identity });
});
const res = await app.request("/api/files", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "application/json",
},
});
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.identity.id, "user-abc-123");
assertEquals(body.identity.email, "alice@example.com");
assertEquals(body.identity.name, "Alice Smith");
assertEquals(body.identity.picture, "https://img.example.com/alice.jpg");
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - 403 from Kratos with JSON accept returns AAL2 required", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
redirect_browser_to: "https://kratos.example.com/aal2",
}),
{ status: 403 },
)
);
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "application/json",
},
});
assertEquals(res.status, 403);
const body = await res.json();
assertEquals(body.error, "AAL2 required");
assertEquals(body.redirectTo, "https://kratos.example.com/aal2");
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - 403 from Kratos redirects browser to aal2 URL", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
redirect_browser_to: "https://kratos.example.com/aal2",
}),
{ status: 403 },
)
);
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/explorer", (c) => c.text("ok"));
const res = await app.request("/explorer", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "text/html",
},
redirect: "manual",
});
assertEquals(res.status, 302);
const location = res.headers.get("location") ?? "";
assertEquals(location, "https://kratos.example.com/aal2");
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - 403 from Kratos without redirect URL falls back to login URL", async () => {
mockFetch(() =>
new Response(
JSON.stringify({}),
{ status: 403 },
)
);
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/explorer", (c) => c.text("ok"));
const res = await app.request("/explorer", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "text/html",
},
redirect: "manual",
});
assertEquals(res.status, 302);
const location = res.headers.get("location") ?? "";
assertStringIncludes(location, "aal2");
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - 403 with error.details.redirect_browser_to is extracted", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
error: {
details: {
redirect_browser_to: "https://kratos.example.com/aal2-from-details",
},
},
}),
{ status: 403 },
)
);
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/data", (c) => c.json({ ok: true }));
const res = await app.request("/api/data", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "application/json",
},
});
assertEquals(res.status, 403);
const body = await res.json();
assertEquals(body.redirectTo, "https://kratos.example.com/aal2-from-details");
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - non-200 non-403 from Kratos returns 401", async () => {
mockFetch(() => new Response("error", { status: 500 }));
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "application/json",
},
});
assertEquals(res.status, 401);
} finally {
restoreFetch();
}
});
Deno.test("auth middleware - network failure to Kratos returns 401", async () => {
mockFetch(() => {
throw new Error("network error");
});
try {
const app = new Hono();
app.use("/*", authMiddleware);
app.get("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files", {
headers: {
cookie: "ory_session_abc=some-session-value",
accept: "application/json",
},
});
assertEquals(res.status, 401);
} finally {
restoreFetch();
}
});
// ── getSession tests ─────────────────────────────────────────────────────────
Deno.test("getSession - no session cookie returns null info", async () => {
const result = await getSession("some-other-cookie=value");
assertEquals(result.info, null);
assertEquals(result.needsAal2, false);
});
Deno.test("getSession - empty cookie header returns null info", async () => {
const result = await getSession("");
assertEquals(result.info, null);
assertEquals(result.needsAal2, false);
});
Deno.test("getSession - valid session returns SessionInfo", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-xyz",
traits: {
email: "bob@example.com",
given_name: "Bob",
family_name: "Jones",
},
},
}),
{ status: 200 },
)
);
try {
const result = await getSession("ory_session_abc=token123");
assertEquals(result.info?.id, "user-xyz");
assertEquals(result.info?.email, "bob@example.com");
assertEquals(result.info?.name, "Bob Jones");
assertEquals(result.needsAal2, false);
} finally {
restoreFetch();
}
});
Deno.test("getSession - ory_kratos_session cookie also works", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-kratos",
traits: { email: "kratos@example.com" },
},
}),
{ status: 200 },
)
);
try {
const result = await getSession("ory_kratos_session=token456");
assertEquals(result.info?.id, "user-kratos");
assertEquals(result.info?.email, "kratos@example.com");
} finally {
restoreFetch();
}
});
Deno.test("getSession - legacy name.first/name.last traits", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-legacy",
traits: {
email: "legacy@example.com",
name: { first: "Legacy", last: "User" },
},
},
}),
{ status: 200 },
)
);
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info?.name, "Legacy User");
} finally {
restoreFetch();
}
});
Deno.test("getSession - falls back to email when no name parts", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-noname",
traits: {
email: "noname@example.com",
},
},
}),
{ status: 200 },
)
);
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info?.name, "noname@example.com");
} finally {
restoreFetch();
}
});
Deno.test("getSession - 403 returns needsAal2 true", async () => {
mockFetch(() =>
new Response(
JSON.stringify({ redirect_browser_to: "https://example.com/aal2" }),
{ status: 403 },
)
);
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info, null);
assertEquals(result.needsAal2, true);
assertEquals(result.redirectTo, "https://example.com/aal2");
} finally {
restoreFetch();
}
});
Deno.test("getSession - 403 with unparseable body still returns needsAal2", async () => {
mockFetch(() =>
new Response("not json", { status: 403 })
);
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info, null);
assertEquals(result.needsAal2, true);
assertEquals(result.redirectTo, undefined);
} finally {
restoreFetch();
}
});
Deno.test("getSession - non-200 non-403 returns null info", async () => {
mockFetch(() => new Response("error", { status: 500 }));
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info, null);
assertEquals(result.needsAal2, false);
} finally {
restoreFetch();
}
});
Deno.test("getSession - network error returns null info", async () => {
mockFetch(() => {
throw new Error("connection refused");
});
try {
const result = await getSession("ory_session_test=val");
assertEquals(result.info, null);
assertEquals(result.needsAal2, false);
} finally {
restoreFetch();
}
});
// ── sessionHandler tests ─────────────────────────────────────────────────────
Deno.test("sessionHandler - returns user info for valid session", async () => {
mockFetch(() =>
new Response(
JSON.stringify({
identity: {
id: "user-session",
traits: {
email: "session@example.com",
given_name: "Session",
family_name: "User",
},
},
}),
{ status: 200 },
)
);
try {
const app = new Hono();
app.get("/api/auth/session", sessionHandler);
const res = await app.request("/api/auth/session", {
headers: { cookie: "ory_session_abc=token" },
});
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.user.id, "user-session");
assertEquals(body.user.email, "session@example.com");
assertEquals(body.user.name, "Session User");
assertEquals(body.session !== undefined, true);
} finally {
restoreFetch();
}
});
Deno.test("sessionHandler - returns 401 without session", async () => {
const app = new Hono();
app.get("/api/auth/session", sessionHandler);
const res = await app.request("/api/auth/session");
assertEquals(res.status, 401);
const body = await res.json();
assertEquals(body.error, "Unauthorized");
});
Deno.test("sessionHandler - returns 403 when AAL2 required", async () => {
mockFetch(() =>
new Response(
JSON.stringify({ redirect_browser_to: "https://example.com/aal2" }),
{ status: 403 },
)
);
try {
const app = new Hono();
app.get("/api/auth/session", sessionHandler);
const res = await app.request("/api/auth/session", {
headers: { cookie: "ory_session_abc=token" },
});
assertEquals(res.status, 403);
const body = await res.json();
assertEquals(body.error, "AAL2 required");
assertEquals(body.needsAal2, true);
} finally {
restoreFetch();
}
});
Deno.test("getSession - extracts session cookie from multiple cookies", async () => {
mockFetch((url, init) => {
// Verify the correct cookie was sent
const cookieHeader = (init?.headers as Record<string, string>)?.cookie ?? "";
assertEquals(cookieHeader.includes("ory_session_"), true);
return new Response(
JSON.stringify({
identity: {
id: "user-multi",
traits: { email: "multi@example.com" },
},
}),
{ status: 200 },
);
});
try {
const result = await getSession("other=foo; ory_session_abc=token123; another=bar");
assertEquals(result.info?.id, "user-multi");
} finally {
restoreFetch();
}
});

View File

@@ -0,0 +1,172 @@
import {
assertEquals,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { parseKey, backfillHandler } from "../../server/backfill.ts";
import { Hono } from "hono";
// ── parseKey tests ───────────────────────────────────────────────────────────
Deno.test("parseKey — personal file under my-files", () => {
const result = parseKey("abc-123/my-files/documents/report.docx");
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.pathParts, ["documents"]);
assertEquals(result?.filename, "report.docx");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — personal file at root of my-files", () => {
const result = parseKey("abc-123/my-files/photo.png");
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.pathParts, []);
assertEquals(result?.filename, "photo.png");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — nested personal file", () => {
const result = parseKey("abc-123/my-files/game/assets/textures/brick.png");
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.pathParts, ["game", "assets", "textures"]);
assertEquals(result?.filename, "brick.png");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — shared file", () => {
const result = parseKey("shared/project-alpha/design.odt");
assertEquals(result?.ownerId, "shared");
assertEquals(result?.pathParts, ["project-alpha"]);
assertEquals(result?.filename, "design.odt");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — folder (trailing slash)", () => {
const result = parseKey("abc-123/my-files/documents/");
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.isFolder, true);
assertEquals(result?.filename, "documents");
});
Deno.test("parseKey — shared folder", () => {
const result = parseKey("shared/assets/");
assertEquals(result?.ownerId, "shared");
assertEquals(result?.isFolder, true);
assertEquals(result?.filename, "assets");
});
Deno.test("parseKey — empty key returns null", () => {
assertEquals(parseKey(""), null);
assertEquals(parseKey("/"), null);
});
Deno.test("parseKey — root-level key without my-files", () => {
const result = parseKey("some-user/file.txt");
assertEquals(result?.ownerId, "some-user");
assertEquals(result?.filename, "file.txt");
assertEquals(result?.pathParts, []);
});
Deno.test("parseKey — UUID owner ID", () => {
const result = parseKey("e2e-test-user-00000000/my-files/SBBB Super Boujee Business Box.odt");
assertEquals(result?.ownerId, "e2e-test-user-00000000");
assertEquals(result?.filename, "SBBB Super Boujee Business Box.odt");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — owner-only key without file returns null", () => {
const result = parseKey("abc-123");
assertEquals(result, null);
});
Deno.test("parseKey — my-files root folder", () => {
const result = parseKey("abc-123/my-files/");
assertNotEquals(result, null);
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.isFolder, true);
});
Deno.test("parseKey — shared root folder", () => {
const result = parseKey("shared/");
assertNotEquals(result, null);
assertEquals(result?.ownerId, "shared");
assertEquals(result?.isFolder, true);
});
Deno.test("parseKey — deeply nested shared file", () => {
const result = parseKey("shared/a/b/c/d/deep-file.txt");
assertEquals(result?.ownerId, "shared");
assertEquals(result?.pathParts, ["a", "b", "c", "d"]);
assertEquals(result?.filename, "deep-file.txt");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — key with only owner/my-files returns null (no remaining file)", () => {
const result = parseKey("abc-123/my-files");
assertEquals(result, null);
});
Deno.test("parseKey — deeply nested folder with trailing slash", () => {
const result = parseKey("abc-123/my-files/a/b/c/");
assertEquals(result?.ownerId, "abc-123");
assertEquals(result?.isFolder, true);
assertEquals(result?.filename, "c");
assertEquals(result?.pathParts, ["a", "b"]);
});
Deno.test("parseKey — non my-files path with nested files", () => {
const result = parseKey("some-user/other-dir/sub/file.txt");
assertEquals(result?.ownerId, "some-user");
assertEquals(result?.pathParts, ["other-dir", "sub"]);
assertEquals(result?.filename, "file.txt");
});
Deno.test("parseKey — shared file at root", () => {
const result = parseKey("shared/file.txt");
assertEquals(result?.ownerId, "shared");
assertEquals(result?.filename, "file.txt");
assertEquals(result?.pathParts, []);
});
Deno.test("parseKey — file with no extension", () => {
const result = parseKey("abc-123/my-files/Makefile");
assertEquals(result?.filename, "Makefile");
assertEquals(result?.isFolder, false);
});
Deno.test("parseKey — file with multiple dots in name", () => {
const result = parseKey("abc-123/my-files/archive.tar.gz");
assertEquals(result?.filename, "archive.tar.gz");
});
// ── backfillHandler tests ────────────────────────────────────────────────────
Deno.test("backfillHandler — returns 401 when no identity", async () => {
const app = new Hono();
app.post("/api/admin/backfill", backfillHandler);
const res = await app.request("/api/admin/backfill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dry_run: true }),
});
assertEquals(res.status, 401);
const body = await res.json();
assertEquals(body.error, "Unauthorized");
});
Deno.test("backfillHandler — returns 401 when identity has no id", async () => {
const app = new Hono();
app.use("/*", async (c, next) => {
c.set("identity" as never, { email: "test@test.com" });
await next();
});
app.post("/api/admin/backfill", backfillHandler);
const res = await app.request("/api/admin/backfill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dry_run: true }),
});
assertEquals(res.status, 401);
});

208
tests/server/csrf_test.ts Normal file
View File

@@ -0,0 +1,208 @@
import {
assertEquals,
assertStringIncludes,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { Hono } from "hono";
import { csrfMiddleware, generateCsrfToken, CSRF_COOKIE_NAME } from "../../server/csrf.ts";
Deno.test("CSRF - generateCsrfToken returns token and cookie", async () => {
const { token, cookie } = await generateCsrfToken();
assertEquals(typeof token, "string");
assertEquals(token.includes("."), true);
assertEquals(cookie.includes(CSRF_COOKIE_NAME), true);
});
Deno.test("CSRF - token has UUID.signature format", async () => {
const { token } = await generateCsrfToken();
const parts = token.split(".");
assertEquals(parts.length, 2);
// UUID is 36 chars
assertEquals(parts[0].length, 36);
// Signature is a hex string (64 chars for SHA-256)
assertEquals(parts[1].length, 64);
});
Deno.test("CSRF - cookie contains correct attributes", async () => {
const { cookie } = await generateCsrfToken();
assertStringIncludes(cookie, "Path=/");
assertStringIncludes(cookie, "HttpOnly");
assertStringIncludes(cookie, "SameSite=Strict");
});
Deno.test("CSRF - GET requests bypass CSRF check", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.get("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files");
assertEquals(res.status, 200);
});
Deno.test("CSRF - HEAD requests bypass CSRF check", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.get("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files", { method: "HEAD" });
// HEAD on a GET route should pass
assertEquals(res.status >= 200 && res.status < 400, true);
});
Deno.test("CSRF - POST without token returns 403", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const res = await app.request("/api/files", { method: "POST" });
assertEquals(res.status, 403);
});
Deno.test("CSRF - PUT without token returns 403", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.put("/api/files/abc", (c) => c.json({ ok: true }));
const res = await app.request("/api/files/abc", { method: "PUT" });
assertEquals(res.status, 403);
});
Deno.test("CSRF - PATCH without token returns 403", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.patch("/api/files/abc", (c) => c.json({ ok: true }));
const res = await app.request("/api/files/abc", { method: "PATCH" });
assertEquals(res.status, 403);
});
Deno.test("CSRF - DELETE without token returns 403", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.delete("/api/files/abc", (c) => c.json({ ok: true }));
const res = await app.request("/api/files/abc", { method: "DELETE" });
assertEquals(res.status, 403);
});
Deno.test("CSRF - POST with valid token succeeds", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const { token } = await generateCsrfToken();
const res = await app.request("/api/files", {
method: "POST",
headers: {
"x-csrf-token": token,
cookie: `${CSRF_COOKIE_NAME}=${token}`,
},
});
assertEquals(res.status, 200);
});
Deno.test("CSRF - WOPI POST bypasses CSRF", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/wopi/files/abc", (c) => c.json({ ok: true }));
const res = await app.request("/wopi/files/abc", { method: "POST" });
assertEquals(res.status, 200);
});
Deno.test("CSRF - mismatched token rejected", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const { token } = await generateCsrfToken();
const { token: otherToken } = await generateCsrfToken();
const res = await app.request("/api/files", {
method: "POST",
headers: {
"x-csrf-token": token,
cookie: `${CSRF_COOKIE_NAME}=${otherToken}`,
},
});
assertEquals(res.status, 403);
});
Deno.test("CSRF - POST to non-api path bypasses CSRF", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/login", (c) => c.json({ ok: true }));
const res = await app.request("/login", { method: "POST" });
assertEquals(res.status, 200);
});
Deno.test("CSRF - token without cookie header rejected", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const { token } = await generateCsrfToken();
const res = await app.request("/api/files", {
method: "POST",
headers: {
"x-csrf-token": token,
// no cookie
},
});
assertEquals(res.status, 403);
});
Deno.test("CSRF - cookie without header token rejected", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const { token } = await generateCsrfToken();
const res = await app.request("/api/files", {
method: "POST",
headers: {
// no x-csrf-token header
cookie: `${CSRF_COOKIE_NAME}=${token}`,
},
});
assertEquals(res.status, 403);
});
Deno.test("CSRF - malformed token (no dot) rejected", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const badToken = "no-dot-in-this-token";
const res = await app.request("/api/files", {
method: "POST",
headers: {
"x-csrf-token": badToken,
cookie: `${CSRF_COOKIE_NAME}=${badToken}`,
},
});
assertEquals(res.status, 403);
});
Deno.test("CSRF - token with wrong signature rejected", async () => {
const app = new Hono();
app.use("/*", csrfMiddleware);
app.post("/api/files", (c) => c.json({ ok: true }));
const { token } = await generateCsrfToken();
const parts = token.split(".");
const tamperedToken = `${parts[0]}.${"a".repeat(64)}`;
const res = await app.request("/api/files", {
method: "POST",
headers: {
"x-csrf-token": tamperedToken,
cookie: `${CSRF_COOKIE_NAME}=${tamperedToken}`,
},
});
assertEquals(res.status, 403);
});
Deno.test("CSRF - two generated tokens are different", async () => {
const { token: t1 } = await generateCsrfToken();
const { token: t2 } = await generateCsrfToken();
assertEquals(t1 !== t2, true);
});

400
tests/server/files_test.ts Normal file
View File

@@ -0,0 +1,400 @@
/**
* Tests for file CRUD handler response shapes with mocked DB/S3.
*
* Since the handlers depend on a live DB and S3, we test them by constructing
* Hono apps with the handlers and mocking the database module. We focus on
* testing the response shapes and status codes by intercepting at the Hono level.
*
* For unit-testability without a real DB, we test the handler contract:
* - correct status codes
* - correct JSON shapes
* - correct routing
*/
import {
assertEquals,
assertStringIncludes,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { Hono } from "hono";
// ── Mock handlers that mirror the real ones but use in-memory state ──────────
interface MockFile {
id: string;
s3_key: string;
filename: string;
mimetype: string;
size: number;
owner_id: string;
parent_id: string | null;
is_folder: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
const mockFiles: MockFile[] = [
{
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
s3_key: "user-1/my-files/report.docx",
filename: "report.docx",
mimetype: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 12345,
owner_id: "user-1",
parent_id: null,
is_folder: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
deleted_at: null,
},
{
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
s3_key: "user-1/my-files/documents/",
filename: "documents",
mimetype: "inode/directory",
size: 0,
owner_id: "user-1",
parent_id: null,
is_folder: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
deleted_at: null,
},
{
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
s3_key: "user-1/my-files/old-file.txt",
filename: "old-file.txt",
mimetype: "text/plain",
size: 100,
owner_id: "user-1",
parent_id: null,
is_folder: false,
created_at: "2023-01-01T00:00:00Z",
updated_at: "2023-06-01T00:00:00Z",
deleted_at: "2024-01-15T00:00:00Z",
},
];
function createMockApp(): Hono {
const app = new Hono();
// Simulate auth middleware
app.use("/*", async (c, next) => {
// deno-lint-ignore no-explicit-any
(c as any).set("identity", { id: "user-1", email: "test@example.com", name: "Test User" });
await next();
});
// GET /api/files
app.get("/api/files", (c) => {
const parentId = c.req.query("parent_id") || null;
const search = c.req.query("search") || "";
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
let files = mockFiles.filter(
(f) => f.owner_id === "user-1" && f.deleted_at === null,
);
if (parentId) {
files = files.filter((f) => f.parent_id === parentId);
} else {
files = files.filter((f) => f.parent_id === null);
}
if (search) {
files = files.filter((f) =>
f.filename.toLowerCase().includes(search.toLowerCase()),
);
}
return c.json({ files: files.slice(offset, offset + limit) });
});
// GET /api/files/:id
app.get("/api/files/:id", (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1",
);
if (!file) return c.json({ error: "Not found" }, 404);
return c.json({ file });
});
// POST /api/files
app.post("/api/files", async (c) => {
const body = await c.req.json();
if (!body.filename) return c.json({ error: "filename required" }, 400);
const newFile: MockFile = {
id: crypto.randomUUID(),
s3_key: `user-1/my-files/${body.filename}`,
filename: body.filename,
mimetype: body.mimetype || "application/octet-stream",
size: body.size || 0,
owner_id: "user-1",
parent_id: body.parent_id || null,
is_folder: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
};
return c.json({ file: newFile }, 201);
});
// PUT /api/files/:id
app.put("/api/files/:id", async (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1" && f.deleted_at === null,
);
if (!file) return c.json({ error: "Not found" }, 404);
const body = await c.req.json();
const updated = {
...file,
filename: body.filename ?? file.filename,
updated_at: new Date().toISOString(),
};
return c.json({ file: updated });
});
// DELETE /api/files/:id
app.delete("/api/files/:id", (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1" && f.deleted_at === null,
);
if (!file) return c.json({ error: "Not found" }, 404);
return c.json({ file: { ...file, deleted_at: new Date().toISOString() } });
});
// POST /api/files/:id/restore
app.post("/api/files/:id/restore", (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1" && f.deleted_at !== null,
);
if (!file) return c.json({ error: "Not found" }, 404);
return c.json({ file: { ...file, deleted_at: null } });
});
// GET /api/files/:id/download
app.get("/api/files/:id/download", (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1" && f.deleted_at === null,
);
if (!file) return c.json({ error: "Not found" }, 404);
if (file.is_folder) return c.json({ error: "Cannot download a folder" }, 400);
return c.json({ url: `https://s3.example.com/${file.s3_key}?signed=true` });
});
// POST /api/folders
app.post("/api/folders", async (c) => {
const body = await c.req.json();
if (!body.name) return c.json({ error: "name required" }, 400);
const folder = {
id: crypto.randomUUID(),
s3_key: `user-1/my-files/${body.name}/`,
filename: body.name,
mimetype: "inode/directory",
size: 0,
owner_id: "user-1",
parent_id: body.parent_id || null,
is_folder: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
};
return c.json({ folder }, 201);
});
// GET /api/trash
app.get("/api/trash", (c) => {
const files = mockFiles.filter(
(f) => f.owner_id === "user-1" && f.deleted_at !== null,
);
return c.json({ files });
});
// PUT /api/files/:id/favorite
app.put("/api/files/:id/favorite", (c) => {
const id = c.req.param("id");
const file = mockFiles.find(
(f) => f.id === id && f.owner_id === "user-1" && f.deleted_at === null,
);
if (!file) return c.json({ error: "Not found" }, 404);
return c.json({ favorited: true });
});
return app;
}
// ── Tests ───────────────────────────────────────────────────────────────────
Deno.test("GET /api/files returns files array", async () => {
const app = createMockApp();
const resp = await app.request("/api/files");
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(Array.isArray(body.files), true);
assertEquals(body.files.length, 2); // 2 non-deleted root files
});
Deno.test("GET /api/files with search filters results", async () => {
const app = createMockApp();
const resp = await app.request("/api/files?search=report");
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(body.files.length, 1);
assertEquals(body.files[0].filename, "report.docx");
});
Deno.test("GET /api/files/:id returns file object", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(body.file.id, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
assertEquals(body.file.filename, "report.docx");
assertNotEquals(body.file.s3_key, undefined);
assertNotEquals(body.file.mimetype, undefined);
assertNotEquals(body.file.size, undefined);
assertNotEquals(body.file.owner_id, undefined);
});
Deno.test("GET /api/files/:id returns 404 for unknown id", async () => {
const app = createMockApp();
const resp = await app.request("/api/files/nonexistent-id");
assertEquals(resp.status, 404);
const body = await resp.json();
assertEquals(body.error, "Not found");
});
Deno.test("POST /api/files creates file and returns 201", async () => {
const app = createMockApp();
const resp = await app.request("/api/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: "new-file.pdf", mimetype: "application/pdf", size: 5000 }),
});
assertEquals(resp.status, 201);
const body = await resp.json();
assertEquals(body.file.filename, "new-file.pdf");
assertEquals(body.file.mimetype, "application/pdf");
assertNotEquals(body.file.id, undefined);
});
Deno.test("POST /api/files returns 400 without filename", async () => {
const app = createMockApp();
const resp = await app.request("/api/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
assertEquals(resp.status, 400);
const body = await resp.json();
assertStringIncludes(body.error, "filename");
});
Deno.test("PUT /api/files/:id updates file", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: "renamed.docx" }),
},
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(body.file.filename, "renamed.docx");
});
Deno.test("DELETE /api/files/:id soft-deletes (sets deleted_at)", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
{ method: "DELETE" },
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertNotEquals(body.file.deleted_at, null);
});
Deno.test("POST /api/files/:id/restore restores trashed file", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/cccccccc-cccc-cccc-cccc-cccccccccccc/restore",
{ method: "POST" },
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(body.file.deleted_at, null);
});
Deno.test("GET /api/files/:id/download returns presigned URL", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/download",
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertNotEquals(body.url, undefined);
assertStringIncludes(body.url, "s3");
});
Deno.test("GET /api/files/:id/download returns 400 for folder", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/download",
);
assertEquals(resp.status, 400);
const body = await resp.json();
assertStringIncludes(body.error, "folder");
});
Deno.test("POST /api/folders creates folder with 201", async () => {
const app = createMockApp();
const resp = await app.request("/api/folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "new-folder" }),
});
assertEquals(resp.status, 201);
const body = await resp.json();
assertEquals(body.folder.filename, "new-folder");
assertEquals(body.folder.is_folder, true);
assertEquals(body.folder.mimetype, "inode/directory");
});
Deno.test("POST /api/folders returns 400 without name", async () => {
const app = createMockApp();
const resp = await app.request("/api/folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
assertEquals(resp.status, 400);
});
Deno.test("GET /api/trash returns only deleted files", async () => {
const app = createMockApp();
const resp = await app.request("/api/trash");
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(body.files.length, 1);
assertNotEquals(body.files[0].deleted_at, null);
});
Deno.test("PUT /api/files/:id/favorite returns favorited status", async () => {
const app = createMockApp();
const resp = await app.request(
"/api/files/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/favorite",
{ method: "PUT" },
);
assertEquals(resp.status, 200);
const body = await resp.json();
assertEquals(typeof body.favorited, "boolean");
});

421
tests/server/keto_test.ts Normal file
View File

@@ -0,0 +1,421 @@
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();
}
});

View File

@@ -0,0 +1,705 @@
import {
assertEquals,
} 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;
}
// Set env vars before import
Deno.env.set("KETO_READ_URL", "http://keto-read:4466");
Deno.env.set("KETO_WRITE_URL", "http://keto-write:4467");
import {
permissionMiddleware,
writeFilePermissions,
writeFolderPermissions,
deleteFilePermissions,
moveFilePermissions,
filterByPermission,
} from "../../server/permissions.ts";
// ── Hono context helpers ─────────────────────────────────────────────────────
function createMockContext(options: {
method: string;
path: string;
identity?: { id: string } | null;
}): {
ctx: {
req: { method: string; path: string };
get: (key: string) => unknown;
json: (body: unknown, status?: number) => Response;
};
getStatus: () => number;
} {
let responseStatus = 200;
const ctx = {
req: {
method: options.method,
path: options.path,
},
get(key: string): unknown {
if (key === "identity") return options.identity ?? undefined;
return undefined;
},
json(body: unknown, status?: number): Response {
responseStatus = status ?? 200;
return new Response(JSON.stringify(body), {
status: responseStatus,
headers: { "Content-Type": "application/json" },
});
},
};
return { ctx, getStatus: () => responseStatus };
}
// ── permissionMiddleware tests ───────────────────────────────────────────────
Deno.test("permissionMiddleware — allows authorized GET request", async () => {
mockFetch((url) => {
if (url.includes("/relation-tuples/check/openapi")) {
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
}
return new Response("", { status: 404 });
});
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
let nextCalled = false;
// deno-lint-ignore no-explicit-any
const result = await permissionMiddleware(ctx as any, async () => {
nextCalled = true;
});
assertEquals(nextCalled, true);
assertEquals(result, undefined);
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — blocks unauthorized request with 403", async () => {
mockFetch(() =>
new Response(JSON.stringify({ allowed: false }), { status: 200 })
);
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
let nextCalled = false;
// deno-lint-ignore no-explicit-any
const result = await permissionMiddleware(ctx as any, async () => {
nextCalled = true;
});
assertEquals(nextCalled, false);
assertEquals(result instanceof Response, true);
assertEquals(result!.status, 403);
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — returns 401 when no identity", async () => {
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: null,
});
let nextCalled = false;
// deno-lint-ignore no-explicit-any
const result = await permissionMiddleware(ctx as any, async () => {
nextCalled = true;
});
assertEquals(nextCalled, false);
assertEquals(result instanceof Response, true);
assertEquals(result!.status, 401);
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — passes through for list operations (no ID)", async () => {
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files",
identity: { id: "user-1" },
});
let nextCalled = false;
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {
nextCalled = true;
});
assertEquals(nextCalled, true);
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — checks 'read' for GET", async () => {
let checkedRelation = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedRelation = body.relation;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedRelation, "read");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — checks 'write' for PUT", async () => {
let checkedRelation = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedRelation = body.relation;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "PUT",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedRelation, "write");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — checks 'write' for POST", async () => {
let checkedRelation = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedRelation = body.relation;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "POST",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedRelation, "write");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — checks 'write' for PATCH", async () => {
let checkedRelation = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedRelation = body.relation;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "PATCH",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedRelation, "write");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — checks 'delete' for DELETE", async () => {
let checkedRelation = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedRelation = body.relation;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "DELETE",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedRelation, "delete");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — uses 'folders' namespace for folder routes", async () => {
let checkedNamespace = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedNamespace = body.namespace;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/folders/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedNamespace, "folders");
} finally {
restoreFetch();
}
});
Deno.test("permissionMiddleware — uses 'files' namespace for file routes", async () => {
let checkedNamespace = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedNamespace = body.namespace;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const { ctx } = createMockContext({
method: "GET",
path: "/api/files/550e8400-e29b-41d4-a716-446655440000",
identity: { id: "user-1" },
});
// deno-lint-ignore no-explicit-any
await permissionMiddleware(ctx as any, async () => {});
assertEquals(checkedNamespace, "files");
} finally {
restoreFetch();
}
});
// ── writeFilePermissions tests ───────────────────────────────────────────────
Deno.test("writeFilePermissions — creates owner tuple", async () => {
const calls: { url: string; body: Record<string, unknown> }[] = [];
mockFetch((url, init) => {
calls.push({ url, body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFilePermissions("file-1", "user-1");
assertEquals(calls.length, 1);
assertEquals(calls[0].body.namespace, "files");
assertEquals(calls[0].body.object, "file-1");
assertEquals(calls[0].body.relation, "owners");
assertEquals(calls[0].body.subject_id, "user-1");
} finally {
restoreFetch();
}
});
Deno.test("writeFilePermissions — creates owner + parent tuples", async () => {
const calls: { url: string; body: Record<string, unknown> }[] = [];
mockFetch((url, init) => {
calls.push({ url, body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFilePermissions("file-1", "user-1", "folder-2");
assertEquals(calls.length, 2);
assertEquals(calls[0].body.relation, "owners");
assertEquals(calls[1].body.relation, "parents");
assertEquals(calls[1].body.subject_set, {
namespace: "folders",
object: "folder-2",
relation: "",
});
} finally {
restoreFetch();
}
});
// ── writeFolderPermissions tests ─────────────────────────────────────────────
Deno.test("writeFolderPermissions — creates owner only (no parent, no bucket)", async () => {
const calls: { body: Record<string, unknown> }[] = [];
mockFetch((_url, init) => {
calls.push({ body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFolderPermissions("folder-1", "user-1");
assertEquals(calls.length, 1);
assertEquals(calls[0].body.namespace, "folders");
assertEquals(calls[0].body.relation, "owners");
assertEquals(calls[0].body.subject_id, "user-1");
} finally {
restoreFetch();
}
});
Deno.test("writeFolderPermissions — creates owner + parent folder tuples", async () => {
const calls: { body: Record<string, unknown> }[] = [];
mockFetch((_url, init) => {
calls.push({ body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFolderPermissions("folder-1", "user-1", "parent-folder-2");
assertEquals(calls.length, 2);
assertEquals(calls[0].body.relation, "owners");
assertEquals(calls[1].body.relation, "parents");
assertEquals(calls[1].body.subject_set, {
namespace: "folders",
object: "parent-folder-2",
relation: "",
});
} finally {
restoreFetch();
}
});
Deno.test("writeFolderPermissions — creates owner + bucket parent", async () => {
const calls: { body: Record<string, unknown> }[] = [];
mockFetch((_url, init) => {
calls.push({ body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFolderPermissions("folder-1", "user-1", undefined, "bucket-1");
assertEquals(calls.length, 2);
assertEquals(calls[0].body.namespace, "folders");
assertEquals(calls[0].body.relation, "owners");
assertEquals(calls[1].body.subject_set, {
namespace: "buckets",
object: "bucket-1",
relation: "",
});
} finally {
restoreFetch();
}
});
Deno.test("writeFolderPermissions — parentFolderId takes priority over bucketId", async () => {
const calls: { body: Record<string, unknown> }[] = [];
mockFetch((_url, init) => {
calls.push({ body: JSON.parse(init?.body as string) });
return new Response("{}", { status: 201 });
});
try {
await writeFolderPermissions("folder-1", "user-1", "parent-folder", "bucket-1");
assertEquals(calls.length, 2);
// Should use parent folder, not bucket
const subjectSet = calls[1].body.subject_set as Record<string, string>;
assertEquals(subjectSet.namespace, "folders");
assertEquals(subjectSet.object, "parent-folder");
} finally {
restoreFetch();
}
});
// ── deleteFilePermissions tests ──────────────────────────────────────────────
Deno.test("deleteFilePermissions — lists and batch-deletes all tuples", async () => {
let batchBody: unknown[] = [];
mockFetch((url, init) => {
// listRelationships calls
if (url.includes("/relation-tuples?") && (!init?.method || init?.method === "GET")) {
const u = new URL(url);
const relation = u.searchParams.get("relation");
if (relation === "owners") {
return new Response(
JSON.stringify({
relation_tuples: [
{ namespace: "files", object: "file-1", relation: "owners", subject_id: "user-1" },
],
}),
{ status: 200 },
);
}
if (relation === "viewers") {
return new Response(
JSON.stringify({
relation_tuples: [
{ namespace: "files", object: "file-1", relation: "viewers", subject_id: "user-2" },
],
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ relation_tuples: [] }), { status: 200 });
}
// batchWriteRelationships (PATCH)
if (init?.method === "PATCH") {
batchBody = JSON.parse(init?.body as string);
return new Response(null, { status: 204 });
}
return new Response("", { status: 200 });
});
try {
await deleteFilePermissions("file-1");
// Should have collected 2 tuples (owners + viewers) and batch-deleted
assertEquals(batchBody.length, 2);
assertEquals((batchBody[0] as Record<string, unknown>).action, "delete");
assertEquals((batchBody[1] as Record<string, unknown>).action, "delete");
} finally {
restoreFetch();
}
});
Deno.test("deleteFilePermissions — no-op when file has no tuples", async () => {
let patchCalled = false;
mockFetch((url, init) => {
if (url.includes("/relation-tuples?")) {
return new Response(JSON.stringify({ relation_tuples: [] }), { status: 200 });
}
if (init?.method === "PATCH") {
patchCalled = true;
return new Response(null, { status: 204 });
}
return new Response("", { status: 200 });
});
try {
await deleteFilePermissions("file-no-tuples");
assertEquals(patchCalled, false);
} finally {
restoreFetch();
}
});
// ── moveFilePermissions tests ────────────────────────────────────────────────
Deno.test("moveFilePermissions — deletes old parent and inserts new", async () => {
let batchBody: unknown[] = [];
mockFetch((url, init) => {
// listRelationships for existing parents
if (url.includes("/relation-tuples?") && (!init?.method || init?.method === "GET")) {
return new Response(
JSON.stringify({
relation_tuples: [
{
namespace: "files",
object: "file-1",
relation: "parents",
subject_set: { namespace: "folders", object: "old-folder", relation: "" },
},
],
}),
{ status: 200 },
);
}
// batchWriteRelationships
if (init?.method === "PATCH") {
batchBody = JSON.parse(init?.body as string);
return new Response(null, { status: 204 });
}
return new Response("", { status: 200 });
});
try {
await moveFilePermissions("file-1", "new-folder");
// Should have 2 patches: delete old parent + insert new parent
assertEquals(batchBody.length, 2);
assertEquals((batchBody[0] as Record<string, unknown>).action, "delete");
assertEquals((batchBody[1] as Record<string, unknown>).action, "insert");
const insertTuple = (batchBody[1] as Record<string, Record<string, unknown>>).relation_tuple;
assertEquals(insertTuple.object, "file-1");
assertEquals(insertTuple.relation, "parents");
const subjectSet = insertTuple.subject_set as Record<string, string>;
assertEquals(subjectSet.namespace, "folders");
assertEquals(subjectSet.object, "new-folder");
} finally {
restoreFetch();
}
});
Deno.test("moveFilePermissions — works when file has no existing parent", async () => {
let batchBody: unknown[] = [];
mockFetch((url, init) => {
if (url.includes("/relation-tuples?")) {
return new Response(JSON.stringify({ relation_tuples: [] }), { status: 200 });
}
if (init?.method === "PATCH") {
batchBody = JSON.parse(init?.body as string);
return new Response(null, { status: 204 });
}
return new Response("", { status: 200 });
});
try {
await moveFilePermissions("file-1", "new-folder");
assertEquals(batchBody.length, 1);
assertEquals((batchBody[0] as Record<string, unknown>).action, "insert");
} finally {
restoreFetch();
}
});
// ── filterByPermission tests ─────────────────────────────────────────────────
Deno.test("filterByPermission — returns only allowed files", async () => {
const allowedIds = new Set(["file-1", "file-3"]);
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
const allowed = allowedIds.has(body.object);
return new Response(JSON.stringify({ allowed }), { status: 200 });
});
try {
const files = [
{ id: "file-1", is_folder: false },
{ id: "file-2", is_folder: false },
{ id: "file-3", is_folder: false },
];
const result = await filterByPermission(files, "user-1", "read");
assertEquals(result.length, 2);
assertEquals(result[0].id, "file-1");
assertEquals(result[1].id, "file-3");
} finally {
restoreFetch();
}
});
Deno.test("filterByPermission — uses 'folders' namespace for folders", async () => {
const checkedNamespaces: string[] = [];
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedNamespaces.push(body.namespace);
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const items = [
{ id: "file-1", is_folder: false },
{ id: "folder-1", is_folder: true },
];
await filterByPermission(items, "user-1", "read");
assertEquals(checkedNamespaces.includes("files"), true);
assertEquals(checkedNamespaces.includes("folders"), true);
} finally {
restoreFetch();
}
});
Deno.test("filterByPermission — returns empty array when none allowed", async () => {
mockFetch(() =>
new Response(JSON.stringify({ allowed: false }), { status: 200 })
);
try {
const files = [
{ id: "file-1", is_folder: false },
{ id: "file-2", is_folder: false },
];
const result = await filterByPermission(files, "user-1", "read");
assertEquals(result.length, 0);
} finally {
restoreFetch();
}
});
Deno.test("filterByPermission — handles empty input array", async () => {
try {
const result = await filterByPermission([], "user-1", "read");
assertEquals(result.length, 0);
} finally {
restoreFetch();
}
});
Deno.test("filterByPermission — items without is_folder use 'files' namespace", async () => {
let checkedNamespace = "";
mockFetch((_url, init) => {
const body = JSON.parse(init?.body as string);
checkedNamespace = body.namespace;
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
});
try {
const items = [{ id: "item-1" }];
await filterByPermission(items, "user-1", "read");
assertEquals(checkedNamespace, "files");
} finally {
restoreFetch();
}
});

682
tests/server/s3_test.ts Normal file
View File

@@ -0,0 +1,682 @@
/**
* Tests for S3 signing (canonical request, signature), presign URL format,
* and HTTP operations (listObjects, headObject, getObject, putObject, deleteObject, copyObject).
*/
import {
assertEquals,
assertStringIncludes,
assertNotEquals,
assertRejects,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
hmacSha256,
sha256Hex,
toHex,
getSigningKey,
signRequest,
listObjects,
headObject,
getObject,
putObject,
deleteObject,
copyObject,
} from "../../server/s3.ts";
import { presignUrl, presignGetUrl, presignPutUrl, createMultipartUpload, presignUploadPart, completeMultipartUpload } from "../../server/s3-presign.ts";
const encoder = new TextEncoder();
// ── 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;
}
// ── Crypto helper tests ─────────────────────────────────────────────────────
Deno.test("sha256Hex produces correct hex digest", async () => {
const hash = await sha256Hex(encoder.encode(""));
assertEquals(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});
Deno.test("sha256Hex produces correct hex for 'hello'", async () => {
const hash = await sha256Hex(encoder.encode("hello"));
assertEquals(
hash,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});
Deno.test("hmacSha256 produces non-empty output", async () => {
const result = await hmacSha256(encoder.encode("key"), "data");
const hex = toHex(result);
assertNotEquals(hex, "");
assertEquals(hex.length, 64);
});
Deno.test("toHex converts ArrayBuffer to hex string", () => {
const buf = new Uint8Array([0, 1, 15, 255]).buffer;
assertEquals(toHex(buf), "00010fff");
});
// ── Signing key derivation ──────────────────────────────────────────────────
Deno.test("getSigningKey returns 32-byte key", async () => {
const key = await getSigningKey("testSecret", "20240101", "us-east-1");
assertEquals(new Uint8Array(key).length, 32);
});
Deno.test("getSigningKey is deterministic for same inputs", async () => {
const key1 = await getSigningKey("secret", "20240101", "us-east-1");
const key2 = await getSigningKey("secret", "20240101", "us-east-1");
assertEquals(toHex(key1), toHex(key2));
});
Deno.test("getSigningKey differs with different dates", async () => {
const key1 = await getSigningKey("secret", "20240101", "us-east-1");
const key2 = await getSigningKey("secret", "20240102", "us-east-1");
assertNotEquals(toHex(key1), toHex(key2));
});
// ── signRequest ─────────────────────────────────────────────────────────────
Deno.test("signRequest adds Authorization, x-amz-date, x-amz-content-sha256 headers", async () => {
const url = new URL("http://localhost:8333/bucket/test-key");
const headers: Record<string, string> = { host: "localhost:8333" };
const bodyHash = await sha256Hex(new Uint8Array(0));
const signed = await signRequest(
"GET",
url,
headers,
bodyHash,
"AKID",
"SECRET",
"us-east-1",
);
assertStringIncludes(signed["Authorization"], "AWS4-HMAC-SHA256");
assertStringIncludes(signed["Authorization"], "Credential=AKID/");
assertStringIncludes(signed["Authorization"], "SignedHeaders=");
assertStringIncludes(signed["Authorization"], "Signature=");
assertNotEquals(signed["x-amz-date"], undefined);
assertNotEquals(signed["x-amz-content-sha256"], undefined);
});
Deno.test("signRequest Authorization contains all required components", async () => {
const url = new URL("http://s3.example.com/bucket/key");
const headers: Record<string, string> = {
host: "s3.example.com",
"content-type": "application/octet-stream",
};
const bodyHash = await sha256Hex(encoder.encode("test body"));
const signed = await signRequest(
"PUT",
url,
headers,
bodyHash,
"MyAccessKey",
"MySecretKey",
"eu-west-1",
);
const auth = signed["Authorization"];
assertStringIncludes(auth, "Credential=MyAccessKey/");
assertStringIncludes(auth, "/eu-west-1/s3/aws4_request");
assertStringIncludes(auth, "content-type");
assertStringIncludes(auth, "host");
});
// ── Pre-signed URL format ───────────────────────────────────────────────────
Deno.test("presignUrl produces a valid URL with required query params", async () => {
const url = await presignUrl("GET", "test/file.txt", 3600);
const parsed = new URL(url);
assertStringIncludes(parsed.pathname, "test/file.txt");
assertNotEquals(parsed.searchParams.get("X-Amz-Algorithm"), null);
assertEquals(parsed.searchParams.get("X-Amz-Algorithm"), "AWS4-HMAC-SHA256");
assertNotEquals(parsed.searchParams.get("X-Amz-Credential"), null);
assertNotEquals(parsed.searchParams.get("X-Amz-Date"), null);
assertEquals(parsed.searchParams.get("X-Amz-Expires"), "3600");
assertNotEquals(parsed.searchParams.get("X-Amz-SignedHeaders"), null);
assertNotEquals(parsed.searchParams.get("X-Amz-Signature"), null);
});
Deno.test("presignUrl includes extra query params for multipart", async () => {
const url = await presignUrl("PUT", "test/file.txt", 3600, {
uploadId: "abc123",
partNumber: "1",
});
const parsed = new URL(url);
assertEquals(parsed.searchParams.get("uploadId"), "abc123");
assertEquals(parsed.searchParams.get("partNumber"), "1");
});
Deno.test("presignUrl signature changes with different expiry", async () => {
const url1 = await presignUrl("GET", "file.txt", 3600);
const url2 = await presignUrl("GET", "file.txt", 7200);
const sig1 = new URL(url1).searchParams.get("X-Amz-Signature");
const sig2 = new URL(url2).searchParams.get("X-Amz-Signature");
assertNotEquals(sig1, sig2);
});
// ── presignGetUrl ───────────────────────────────────────────────────────────
Deno.test("presignGetUrl produces URL with GET method params", async () => {
const url = await presignGetUrl("docs/file.pdf");
const parsed = new URL(url);
assertStringIncludes(parsed.pathname, "docs/file.pdf");
assertEquals(parsed.searchParams.get("X-Amz-Algorithm"), "AWS4-HMAC-SHA256");
assertEquals(parsed.searchParams.get("X-Amz-Expires"), "3600"); // default
assertNotEquals(parsed.searchParams.get("X-Amz-Signature"), null);
});
Deno.test("presignGetUrl respects custom expiry", async () => {
const url = await presignGetUrl("file.txt", 600);
const parsed = new URL(url);
assertEquals(parsed.searchParams.get("X-Amz-Expires"), "600");
});
// ── presignPutUrl ───────────────────────────────────────────────────────────
Deno.test("presignPutUrl includes content-type in signed headers", async () => {
const url = await presignPutUrl("upload/new.pdf", "application/pdf");
const parsed = new URL(url);
assertStringIncludes(parsed.pathname, "upload/new.pdf");
assertNotEquals(parsed.searchParams.get("X-Amz-Signature"), null);
const signedHeaders = parsed.searchParams.get("X-Amz-SignedHeaders") ?? "";
assertStringIncludes(signedHeaders, "content-type");
});
Deno.test("presignPutUrl uses default expiry", async () => {
const url = await presignPutUrl("file.txt", "text/plain");
const parsed = new URL(url);
assertEquals(parsed.searchParams.get("X-Amz-Expires"), "3600");
});
// ── listObjects ─────────────────────────────────────────────────────────────
Deno.test("listObjects parses XML with Contents", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>folder/file1.txt</Key>
<LastModified>2024-01-15T10:00:00Z</LastModified>
<Size>12345</Size>
</Contents>
<Contents>
<Key>folder/file2.pdf</Key>
<LastModified>2024-01-16T11:00:00Z</LastModified>
<Size>67890</Size>
</Contents>
</ListBucketResult>`,
{ status: 200 },
)
);
try {
const result = await listObjects("folder/");
assertEquals(result.contents.length, 2);
assertEquals(result.contents[0].key, "folder/file1.txt");
assertEquals(result.contents[0].lastModified, "2024-01-15T10:00:00Z");
assertEquals(result.contents[0].size, 12345);
assertEquals(result.contents[1].key, "folder/file2.pdf");
assertEquals(result.contents[1].size, 67890);
assertEquals(result.isTruncated, false);
assertEquals(result.commonPrefixes.length, 0);
} finally {
restoreFetch();
}
});
Deno.test("listObjects parses CommonPrefixes", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>false</IsTruncated>
<CommonPrefixes>
<Prefix>folder/subfolder1/</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>folder/subfolder2/</Prefix>
</CommonPrefixes>
</ListBucketResult>`,
{ status: 200 },
)
);
try {
const result = await listObjects("folder/", "/");
assertEquals(result.commonPrefixes.length, 2);
assertEquals(result.commonPrefixes[0], "folder/subfolder1/");
assertEquals(result.commonPrefixes[1], "folder/subfolder2/");
assertEquals(result.contents.length, 0);
} finally {
restoreFetch();
}
});
Deno.test("listObjects handles truncated results with NextContinuationToken", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>true</IsTruncated>
<NextContinuationToken>token123</NextContinuationToken>
<Contents>
<Key>file.txt</Key>
<LastModified>2024-01-01T00:00:00Z</LastModified>
<Size>100</Size>
</Contents>
</ListBucketResult>`,
{ status: 200 },
)
);
try {
const result = await listObjects("", undefined, 1);
assertEquals(result.isTruncated, true);
assertEquals(result.nextContinuationToken, "token123");
assertEquals(result.contents.length, 1);
} finally {
restoreFetch();
}
});
Deno.test("listObjects throws on non-200 response", async () => {
mockFetch(() => new Response("Access Denied", { status: 403 }));
try {
await assertRejects(
() => listObjects("test/"),
Error,
"ListObjects failed 403",
);
} finally {
restoreFetch();
}
});
Deno.test("listObjects handles empty result", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<IsTruncated>false</IsTruncated>
</ListBucketResult>`,
{ status: 200 },
)
);
try {
const result = await listObjects("nonexistent/");
assertEquals(result.contents.length, 0);
assertEquals(result.commonPrefixes.length, 0);
assertEquals(result.isTruncated, false);
} finally {
restoreFetch();
}
});
Deno.test("listObjects passes query parameters correctly", async () => {
let capturedUrl = "";
mockFetch((url) => {
capturedUrl = url;
return new Response(
`<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>`,
{ status: 200 },
);
});
try {
await listObjects("myprefix/", "/", 500, "continuation-abc");
const parsed = new URL(capturedUrl);
assertEquals(parsed.searchParams.get("list-type"), "2");
assertEquals(parsed.searchParams.get("prefix"), "myprefix/");
assertEquals(parsed.searchParams.get("delimiter"), "/");
assertEquals(parsed.searchParams.get("max-keys"), "500");
assertEquals(parsed.searchParams.get("continuation-token"), "continuation-abc");
} finally {
restoreFetch();
}
});
// ── headObject ──────────────────────────────────────────────────────────────
Deno.test("headObject returns metadata for existing object", async () => {
mockFetch(() =>
new Response(null, {
status: 200,
headers: {
"content-type": "application/pdf",
"content-length": "54321",
"last-modified": "Wed, 15 Jan 2024 10:00:00 GMT",
},
})
);
try {
const result = await headObject("user/my-files/doc.pdf");
assertNotEquals(result, null);
assertEquals(result!.contentType, "application/pdf");
assertEquals(result!.contentLength, 54321);
assertEquals(result!.lastModified, "Wed, 15 Jan 2024 10:00:00 GMT");
} finally {
restoreFetch();
}
});
Deno.test("headObject returns null for 404", async () => {
mockFetch(() => new Response(null, { status: 404 }));
try {
const result = await headObject("nonexistent-key");
assertEquals(result, null);
} finally {
restoreFetch();
}
});
Deno.test("headObject throws on non-404 error", async () => {
mockFetch(() => new Response("error", { status: 500 }));
try {
await assertRejects(
() => headObject("some-key"),
Error,
"HeadObject failed 500",
);
} finally {
restoreFetch();
}
});
Deno.test("headObject uses defaults for missing headers", async () => {
mockFetch(() => new Response(null, { status: 200, headers: {} }));
try {
const result = await headObject("key");
assertNotEquals(result, null);
assertEquals(result!.contentType, "application/octet-stream");
assertEquals(result!.contentLength, 0);
assertEquals(result!.lastModified, "");
} finally {
restoreFetch();
}
});
// ── getObject ───────────────────────────────────────────────────────────────
Deno.test("getObject returns the fetch response", async () => {
mockFetch(() => new Response("file content", { status: 200 }));
try {
const resp = await getObject("user/file.txt");
assertEquals(resp.status, 200);
const text = await resp.text();
assertEquals(text, "file content");
} finally {
restoreFetch();
}
});
Deno.test("getObject returns non-200 response without throwing", async () => {
mockFetch(() => new Response("not found", { status: 404 }));
try {
const resp = await getObject("missing.txt");
assertEquals(resp.status, 404);
await resp.text(); // drain body
} finally {
restoreFetch();
}
});
// ── putObject ───────────────────────────────────────────────────────────────
Deno.test("putObject succeeds with 200 response", async () => {
let capturedMethod = "";
let capturedUrl = "";
mockFetch((url, init) => {
capturedMethod = init?.method ?? "";
capturedUrl = url;
return new Response("", { status: 200 });
});
try {
await putObject("user/file.txt", encoder.encode("hello"), "text/plain");
assertEquals(capturedMethod, "PUT");
assertStringIncludes(capturedUrl, "user/file.txt");
} finally {
restoreFetch();
}
});
Deno.test("putObject throws on non-200 response", async () => {
mockFetch(() => new Response("Internal Error", { status: 500 }));
try {
await assertRejects(
() => putObject("key", encoder.encode("data"), "text/plain"),
Error,
"PutObject failed 500",
);
} finally {
restoreFetch();
}
});
// ── deleteObject ────────────────────────────────────────────────────────────
Deno.test("deleteObject succeeds with 204 response", async () => {
let capturedMethod = "";
mockFetch((_url, init) => {
capturedMethod = init?.method ?? "";
return new Response(null, { status: 204 });
});
try {
await deleteObject("user/file.txt");
assertEquals(capturedMethod, "DELETE");
} finally {
restoreFetch();
}
});
Deno.test("deleteObject succeeds with 404 response (idempotent)", async () => {
mockFetch(() => new Response("", { status: 404 }));
try {
await deleteObject("nonexistent.txt");
// Should not throw
} finally {
restoreFetch();
}
});
Deno.test("deleteObject throws on 500 error", async () => {
mockFetch(() => new Response("Server Error", { status: 500 }));
try {
await assertRejects(
() => deleteObject("key"),
Error,
"DeleteObject failed 500",
);
} finally {
restoreFetch();
}
});
// ── copyObject ──────────────────────────────────────────────────────────────
Deno.test("copyObject sends PUT with x-amz-copy-source header", async () => {
let capturedMethod = "";
let capturedHeaders: Record<string, string> = {};
mockFetch((_url, init) => {
capturedMethod = init?.method ?? "";
const headers = init?.headers as Record<string, string>;
capturedHeaders = headers ?? {};
return new Response("<CopyObjectResult></CopyObjectResult>", { status: 200 });
});
try {
await copyObject("source/file.txt", "dest/file.txt");
assertEquals(capturedMethod, "PUT");
// The x-amz-copy-source header should have been sent
const hasCopySource = Object.keys(capturedHeaders).some(
(k) => k.toLowerCase() === "x-amz-copy-source",
);
assertEquals(hasCopySource, true);
} finally {
restoreFetch();
}
});
Deno.test("copyObject throws on failure", async () => {
mockFetch(() => new Response("Copy failed", { status: 500 }));
try {
await assertRejects(
() => copyObject("src", "dst"),
Error,
"CopyObject failed 500",
);
} finally {
restoreFetch();
}
});
// ── createMultipartUpload ───────────────────────────────────────────────────
Deno.test("createMultipartUpload returns uploadId from XML response", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>sunbeam-driver</Bucket>
<Key>test/file.bin</Key>
<UploadId>upload-id-12345</UploadId>
</InitiateMultipartUploadResult>`,
{ status: 200 },
)
);
try {
const uploadId = await createMultipartUpload("test/file.bin", "application/octet-stream");
assertEquals(uploadId, "upload-id-12345");
} finally {
restoreFetch();
}
});
Deno.test("createMultipartUpload throws on non-200", async () => {
mockFetch(() => new Response("Error", { status: 500 }));
try {
await assertRejects(
() => createMultipartUpload("test/file.bin", "application/octet-stream"),
Error,
"CreateMultipartUpload failed",
);
} finally {
restoreFetch();
}
});
Deno.test("createMultipartUpload throws when no UploadId in response", async () => {
mockFetch(() =>
new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>sunbeam-driver</Bucket>
</InitiateMultipartUploadResult>`,
{ status: 200 },
)
);
try {
await assertRejects(
() => createMultipartUpload("test/file.bin", "application/octet-stream"),
Error,
"No UploadId",
);
} finally {
restoreFetch();
}
});
// ── presignUploadPart ───────────────────────────────────────────────────────
Deno.test("presignUploadPart includes uploadId and partNumber", async () => {
const url = await presignUploadPart("test/file.bin", "upload-123", 1);
const parsed = new URL(url);
assertEquals(parsed.searchParams.get("uploadId"), "upload-123");
assertEquals(parsed.searchParams.get("partNumber"), "1");
assertNotEquals(parsed.searchParams.get("X-Amz-Signature"), null);
});
// ── completeMultipartUpload ─────────────────────────────────────────────────
Deno.test("completeMultipartUpload sends XML body with parts", async () => {
let capturedBody = "";
mockFetch((_url, init) => {
capturedBody = typeof init?.body === "string"
? init.body
: new TextDecoder().decode(init?.body as Uint8Array);
return new Response("<CompleteMultipartUploadResult></CompleteMultipartUploadResult>", { status: 200 });
});
try {
await completeMultipartUpload("test/file.bin", "upload-123", [
{ partNumber: 1, etag: '"etag1"' },
{ partNumber: 2, etag: '"etag2"' },
]);
assertStringIncludes(capturedBody, "<CompleteMultipartUpload>");
assertStringIncludes(capturedBody, "<PartNumber>1</PartNumber>");
assertStringIncludes(capturedBody, "<ETag>\"etag1\"</ETag>");
assertStringIncludes(capturedBody, "<PartNumber>2</PartNumber>");
} finally {
restoreFetch();
}
});
Deno.test("completeMultipartUpload throws on failure", async () => {
mockFetch(() => new Response("Error", { status: 500 }));
try {
await assertRejects(
() => completeMultipartUpload("key", "upload-123", [{ partNumber: 1, etag: '"etag"' }]),
Error,
"CompleteMultipartUpload failed",
);
} finally {
restoreFetch();
}
});

View File

@@ -0,0 +1,175 @@
/**
* Tests for the telemetry module.
*
* These tests run with OTEL_ENABLED=false (the default) to verify
* that the no-op / graceful-degradation path works correctly, and
* that the public API surface behaves as expected.
*/
import {
assertEquals,
assertExists,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { Hono } from "hono";
import {
tracingMiddleware,
metricsMiddleware,
withSpan,
traceDbQuery,
OTEL_ENABLED,
shutdown,
} from "../../server/telemetry.ts";
// ---------------------------------------------------------------------------
// Sanity: OTEL_ENABLED should be false in the test environment
// ---------------------------------------------------------------------------
Deno.test("OTEL_ENABLED is false by default", () => {
assertEquals(OTEL_ENABLED, false);
});
// ---------------------------------------------------------------------------
// Middleware no-op behaviour when OTEL_ENABLED = false
// ---------------------------------------------------------------------------
Deno.test("tracingMiddleware passes through when OTEL disabled", async () => {
const app = new Hono();
app.use("/*", tracingMiddleware);
app.get("/ping", (c) => c.text("pong"));
const res = await app.request("/ping");
assertEquals(res.status, 200);
assertEquals(await res.text(), "pong");
});
Deno.test("metricsMiddleware passes through when OTEL disabled", async () => {
const app = new Hono();
app.use("/*", metricsMiddleware);
app.get("/ping", (c) => c.text("pong"));
const res = await app.request("/ping");
assertEquals(res.status, 200);
assertEquals(await res.text(), "pong");
});
Deno.test("both middlewares together pass through when OTEL disabled", async () => {
const app = new Hono();
app.use("/*", tracingMiddleware);
app.use("/*", metricsMiddleware);
app.get("/hello", (c) => c.json({ msg: "world" }));
const res = await app.request("/hello");
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.msg, "world");
});
// ---------------------------------------------------------------------------
// withSpan utility — no-op when disabled
// ---------------------------------------------------------------------------
Deno.test("withSpan executes the function and returns its result when OTEL disabled", async () => {
const result = await withSpan("test.span", { key: "val" }, async (_span) => {
return 42;
});
assertEquals(result, 42);
});
Deno.test("withSpan propagates errors from the wrapped function", async () => {
let caught = false;
try {
await withSpan("test.error", {}, async () => {
throw new Error("boom");
});
} catch (e) {
caught = true;
assertEquals((e as Error).message, "boom");
}
assertEquals(caught, true);
});
Deno.test("withSpan provides a span object to the callback", async () => {
await withSpan("test.span_object", {}, async (span) => {
assertExists(span);
// The no-op span should have standard methods
assertEquals(typeof span.end, "function");
assertEquals(typeof span.setAttribute, "function");
});
});
// ---------------------------------------------------------------------------
// traceDbQuery utility — no-op when disabled
// ---------------------------------------------------------------------------
Deno.test("traceDbQuery executes the function and returns result", async () => {
const result = await traceDbQuery("SELECT 1", async () => {
return [{ count: 1 }];
});
assertEquals(result, [{ count: 1 }]);
});
Deno.test("traceDbQuery propagates errors", async () => {
let caught = false;
try {
await traceDbQuery("SELECT bad", async () => {
throw new Error("db error");
});
} catch (e) {
caught = true;
assertEquals((e as Error).message, "db error");
}
assertEquals(caught, true);
});
// ---------------------------------------------------------------------------
// Middleware does not break error responses
// ---------------------------------------------------------------------------
Deno.test("tracingMiddleware handles 404 routes gracefully", async () => {
const app = new Hono();
app.use("/*", tracingMiddleware);
app.use("/*", metricsMiddleware);
// No routes registered for /missing
const res = await app.request("/missing");
assertEquals(res.status, 404);
});
Deno.test("tracingMiddleware handles handler errors gracefully", async () => {
const app = new Hono();
app.use("/*", tracingMiddleware);
app.use("/*", metricsMiddleware);
app.get("/explode", () => {
throw new Error("handler error");
});
const res = await app.request("/explode");
// Hono returns 500 for unhandled errors
assertEquals(res.status, 500);
});
// ---------------------------------------------------------------------------
// shutdown is safe when SDK was never initialised
// ---------------------------------------------------------------------------
Deno.test("shutdown is a no-op when OTEL disabled", async () => {
// Should not throw
await shutdown();
});
// ---------------------------------------------------------------------------
// Middleware preserves response headers from downstream handlers
// ---------------------------------------------------------------------------
Deno.test("tracingMiddleware preserves custom response headers", async () => {
const app = new Hono();
app.use("/*", tracingMiddleware);
app.get("/custom", (c) => {
c.header("X-Custom", "test-value");
return c.text("ok");
});
const res = await app.request("/custom");
assertEquals(res.status, 200);
assertEquals(res.headers.get("X-Custom"), "test-value");
});

View File

@@ -0,0 +1,286 @@
/**
* Tests for WOPI discovery XML parsing and cache behavior.
*/
import {
assertEquals,
assertNotEquals,
assertRejects,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
parseDiscoveryXml,
getCollaboraActionUrl,
clearDiscoveryCache,
} from "../../server/wopi/discovery.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;
}
// ── parseDiscoveryXml tests ─────────────────────────────────────────────────
Deno.test("parseDiscoveryXml — parses single app with single action", () => {
const xml = `
<wopi-discovery>
<net-zone name="external-http">
<app name="application/vnd.oasis.opendocument.text" favIconUrl="http://example.com/icon.png">
<action name="edit" ext="odt" urlsrc="http://collabora:9980/loleaflet/dist/loleaflet.html?" />
</app>
</net-zone>
</wopi-discovery>
`;
const result = parseDiscoveryXml(xml);
assertEquals(result.size, 1);
const actions = result.get("application/vnd.oasis.opendocument.text");
assertNotEquals(actions, undefined);
assertEquals(actions!.length, 1);
assertEquals(actions![0].name, "edit");
assertEquals(actions![0].ext, "odt");
assertEquals(actions![0].urlsrc, "http://collabora:9980/loleaflet/dist/loleaflet.html?");
});
Deno.test("parseDiscoveryXml — parses multiple apps", () => {
const xml = `
<wopi-discovery>
<net-zone name="external-http">
<app name="application/vnd.oasis.opendocument.text">
<action name="edit" ext="odt" urlsrc="http://collabora:9980/edit/odt?" />
<action name="view" ext="odt" urlsrc="http://collabora:9980/view/odt?" />
</app>
<app name="application/pdf">
<action name="view" ext="pdf" urlsrc="http://collabora:9980/view/pdf?" />
</app>
</net-zone>
</wopi-discovery>
`;
const result = parseDiscoveryXml(xml);
assertEquals(result.size, 2);
const odtActions = result.get("application/vnd.oasis.opendocument.text");
assertEquals(odtActions!.length, 2);
assertEquals(odtActions![0].name, "edit");
assertEquals(odtActions![1].name, "view");
const pdfActions = result.get("application/pdf");
assertEquals(pdfActions!.length, 1);
assertEquals(pdfActions![0].name, "view");
});
Deno.test("parseDiscoveryXml — returns empty map for empty XML", () => {
const result = parseDiscoveryXml("<wopi-discovery></wopi-discovery>");
assertEquals(result.size, 0);
});
Deno.test("parseDiscoveryXml — skips actions without name or urlsrc", () => {
const xml = `
<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="" ext="txt" urlsrc="http://example.com/view?" />
<action name="edit" ext="txt" urlsrc="" />
<action name="view" ext="txt" urlsrc="http://example.com/view?" />
</app>
</net-zone>
</wopi-discovery>
`;
const result = parseDiscoveryXml(xml);
const actions = result.get("text/plain");
assertEquals(actions!.length, 1);
assertEquals(actions![0].name, "view");
});
Deno.test("parseDiscoveryXml — handles realistic Collabora discovery XML", () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<wopi-discovery>
<net-zone name="external-http">
<app name="application/vnd.openxmlformats-officedocument.wordprocessingml.document" favIconUrl="http://collabora:9980/favicon.ico">
<action name="edit" ext="docx" urlsrc="http://collabora:9980/browser/dist/cool.html?" />
<action name="view" ext="docx" urlsrc="http://collabora:9980/browser/dist/cool.html?" />
</app>
<app name="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" favIconUrl="http://collabora:9980/favicon.ico">
<action name="edit" ext="xlsx" urlsrc="http://collabora:9980/browser/dist/cool.html?" />
</app>
<app name="application/vnd.oasis.opendocument.text" favIconUrl="http://collabora:9980/favicon.ico">
<action name="edit" ext="odt" urlsrc="http://collabora:9980/browser/dist/cool.html?" />
</app>
</net-zone>
</wopi-discovery>`;
const result = parseDiscoveryXml(xml);
assertEquals(result.size, 3);
assertNotEquals(
result.get("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
undefined,
);
assertNotEquals(
result.get("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
undefined,
);
assertNotEquals(
result.get("application/vnd.oasis.opendocument.text"),
undefined,
);
});
// ── getCollaboraActionUrl tests ─────────────────────────────────────────────
Deno.test("getCollaboraActionUrl — returns urlsrc for matching mimetype + action", async () => {
clearDiscoveryCache();
mockFetch(() =>
new Response(
`<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="edit" ext="txt" urlsrc="http://collabora:9980/edit/txt?" />
<action name="view" ext="txt" urlsrc="http://collabora:9980/view/txt?" />
</app>
</net-zone>
</wopi-discovery>`,
{ status: 200 },
)
);
try {
const url = await getCollaboraActionUrl("text/plain", "edit");
assertEquals(url, "http://collabora:9980/edit/txt?");
} finally {
restoreFetch();
clearDiscoveryCache();
}
});
Deno.test("getCollaboraActionUrl — returns null for unknown mimetype", async () => {
clearDiscoveryCache();
mockFetch(() =>
new Response(
`<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="edit" ext="txt" urlsrc="http://collabora:9980/edit/txt?" />
</app>
</net-zone>
</wopi-discovery>`,
{ status: 200 },
)
);
try {
const url = await getCollaboraActionUrl("application/unknown", "edit");
assertEquals(url, null);
} finally {
restoreFetch();
clearDiscoveryCache();
}
});
Deno.test("getCollaboraActionUrl — returns null for unknown action", async () => {
clearDiscoveryCache();
mockFetch(() =>
new Response(
`<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="edit" ext="txt" urlsrc="http://collabora:9980/edit/txt?" />
</app>
</net-zone>
</wopi-discovery>`,
{ status: 200 },
)
);
try {
const url = await getCollaboraActionUrl("text/plain", "view");
assertEquals(url, null);
} finally {
restoreFetch();
clearDiscoveryCache();
}
});
Deno.test("getCollaboraActionUrl — defaults to 'edit' action", async () => {
clearDiscoveryCache();
mockFetch(() =>
new Response(
`<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="edit" ext="txt" urlsrc="http://collabora:9980/edit/txt?" />
</app>
</net-zone>
</wopi-discovery>`,
{ status: 200 },
)
);
try {
const url = await getCollaboraActionUrl("text/plain");
assertEquals(url, "http://collabora:9980/edit/txt?");
} finally {
restoreFetch();
clearDiscoveryCache();
}
});
Deno.test("getCollaboraActionUrl — uses cache on second call", async () => {
clearDiscoveryCache();
let fetchCount = 0;
mockFetch(() => {
fetchCount++;
return new Response(
`<wopi-discovery>
<net-zone>
<app name="text/plain">
<action name="edit" ext="txt" urlsrc="http://collabora:9980/edit/txt?" />
</app>
</net-zone>
</wopi-discovery>`,
{ status: 200 },
);
});
try {
await getCollaboraActionUrl("text/plain", "edit");
await getCollaboraActionUrl("text/plain", "edit");
assertEquals(fetchCount, 1); // Should only fetch once due to cache
} finally {
restoreFetch();
clearDiscoveryCache();
}
});
Deno.test("getCollaboraActionUrl — throws after 3 failed retries", async () => {
clearDiscoveryCache();
mockFetch(() => new Response("Server Error", { status: 500 }));
try {
await assertRejects(
() => getCollaboraActionUrl("text/plain", "edit"),
Error,
"Collabora discovery fetch failed",
);
} finally {
restoreFetch();
clearDiscoveryCache();
}
});

View File

@@ -0,0 +1,291 @@
/**
* Tests for WOPI lock service using in-memory store.
*/
import {
assertEquals,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
InMemoryLockStore,
setLockStore,
acquireLock,
getLock,
refreshLock,
releaseLock,
unlockAndRelock,
} from "../../server/wopi/lock.ts";
// Use in-memory store for all tests
function setup(): InMemoryLockStore {
const store = new InMemoryLockStore();
setLockStore(store);
return store;
}
Deno.test("acquireLock succeeds on unlocked file", async () => {
setup();
const result = await acquireLock("file-1", "lock-aaa");
assertEquals(result.success, true);
assertEquals(result.existingLockId, undefined);
});
Deno.test("acquireLock fails when different lock exists", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await acquireLock("file-1", "lock-bbb");
assertEquals(result.success, false);
assertEquals(result.existingLockId, "lock-aaa");
});
Deno.test("acquireLock succeeds when same lock exists (refresh)", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await acquireLock("file-1", "lock-aaa");
assertEquals(result.success, true);
});
Deno.test("getLock returns null for unlocked file", async () => {
setup();
const lock = await getLock("file-nonexistent");
assertEquals(lock, null);
});
Deno.test("getLock returns lock id for locked file", async () => {
setup();
await acquireLock("file-1", "lock-xyz");
const lock = await getLock("file-1");
assertEquals(lock, "lock-xyz");
});
Deno.test("refreshLock succeeds with matching lock", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await refreshLock("file-1", "lock-aaa");
assertEquals(result.success, true);
});
Deno.test("refreshLock fails with mismatched lock", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await refreshLock("file-1", "lock-bbb");
assertEquals(result.success, false);
assertEquals(result.existingLockId, "lock-aaa");
});
Deno.test("refreshLock fails on unlocked file", async () => {
setup();
const result = await refreshLock("file-1", "lock-aaa");
assertEquals(result.success, false);
});
Deno.test("releaseLock succeeds with matching lock", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await releaseLock("file-1", "lock-aaa");
assertEquals(result.success, true);
// Verify lock is gone
const lock = await getLock("file-1");
assertEquals(lock, null);
});
Deno.test("releaseLock fails with mismatched lock", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await releaseLock("file-1", "lock-bbb");
assertEquals(result.success, false);
assertEquals(result.existingLockId, "lock-aaa");
// Lock should still exist
const lock = await getLock("file-1");
assertEquals(lock, "lock-aaa");
});
Deno.test("releaseLock succeeds on already unlocked file", async () => {
setup();
const result = await releaseLock("file-1", "lock-aaa");
assertEquals(result.success, true);
});
Deno.test("unlockAndRelock succeeds with matching old lock", async () => {
setup();
await acquireLock("file-1", "lock-old");
const result = await unlockAndRelock("file-1", "lock-old", "lock-new");
assertEquals(result.success, true);
// New lock should be set
const lock = await getLock("file-1");
assertEquals(lock, "lock-new");
});
Deno.test("unlockAndRelock fails with mismatched old lock", async () => {
setup();
await acquireLock("file-1", "lock-aaa");
const result = await unlockAndRelock("file-1", "lock-wrong", "lock-new");
assertEquals(result.success, false);
assertEquals(result.existingLockId, "lock-aaa");
// Original lock should remain
const lock = await getLock("file-1");
assertEquals(lock, "lock-aaa");
});
Deno.test("unlockAndRelock fails when no lock exists", async () => {
setup();
const result = await unlockAndRelock("file-1", "lock-old", "lock-new");
assertEquals(result.success, false);
assertEquals(result.existingLockId, undefined);
});
Deno.test("different files have independent locks", async () => {
setup();
await acquireLock("file-1", "lock-1");
await acquireLock("file-2", "lock-2");
assertEquals(await getLock("file-1"), "lock-1");
assertEquals(await getLock("file-2"), "lock-2");
// Releasing one doesn't affect the other
await releaseLock("file-1", "lock-1");
assertEquals(await getLock("file-1"), null);
assertEquals(await getLock("file-2"), "lock-2");
});
Deno.test("full lock lifecycle: acquire -> refresh -> release", async () => {
setup();
// Acquire
const a = await acquireLock("file-1", "lock-abc");
assertEquals(a.success, true);
// Refresh
const r = await refreshLock("file-1", "lock-abc");
assertEquals(r.success, true);
// Still locked
assertNotEquals(await getLock("file-1"), null);
// Release
const rel = await releaseLock("file-1", "lock-abc");
assertEquals(rel.success, true);
// Gone
assertEquals(await getLock("file-1"), null);
});
// ── InMemoryLockStore direct tests ──────────────────────────────────────────
Deno.test("InMemoryLockStore — get returns null for nonexistent key", async () => {
const store = new InMemoryLockStore();
assertEquals(await store.get("nonexistent"), null);
});
Deno.test("InMemoryLockStore — setNX sets value and returns true", async () => {
const store = new InMemoryLockStore();
const result = await store.setNX("key1", "value1", 60);
assertEquals(result, true);
assertEquals(await store.get("key1"), "value1");
});
Deno.test("InMemoryLockStore — setNX returns false if key exists", async () => {
const store = new InMemoryLockStore();
await store.setNX("key1", "value1", 60);
const result = await store.setNX("key1", "value2", 60);
assertEquals(result, false);
assertEquals(await store.get("key1"), "value1");
});
Deno.test("InMemoryLockStore — set overwrites unconditionally", async () => {
const store = new InMemoryLockStore();
await store.setNX("key1", "value1", 60);
await store.set("key1", "value2", 60);
assertEquals(await store.get("key1"), "value2");
});
Deno.test("InMemoryLockStore — del removes key", async () => {
const store = new InMemoryLockStore();
await store.setNX("key1", "value1", 60);
await store.del("key1");
assertEquals(await store.get("key1"), null);
});
Deno.test("InMemoryLockStore — del on nonexistent key is no-op", async () => {
const store = new InMemoryLockStore();
await store.del("nonexistent");
// Should not throw
});
Deno.test("InMemoryLockStore — expire returns false for nonexistent key", async () => {
const store = new InMemoryLockStore();
const result = await store.expire("nonexistent", 60);
assertEquals(result, false);
});
Deno.test("InMemoryLockStore — expire returns true for existing key", async () => {
const store = new InMemoryLockStore();
await store.setNX("key1", "value1", 60);
const result = await store.expire("key1", 120);
assertEquals(result, true);
assertEquals(await store.get("key1"), "value1");
});
Deno.test("InMemoryLockStore — expired key returns null on get", async () => {
const store = new InMemoryLockStore();
// Set with 0 TTL so it expires immediately
await store.set("key1", "value1", 0);
// Wait briefly to ensure expiry (0 seconds TTL)
await new Promise((r) => setTimeout(r, 10));
assertEquals(await store.get("key1"), null);
});
Deno.test("InMemoryLockStore — expired key allows setNX", async () => {
const store = new InMemoryLockStore();
await store.set("key1", "value1", 0);
await new Promise((r) => setTimeout(r, 10));
const result = await store.setNX("key1", "value2", 60);
assertEquals(result, true);
assertEquals(await store.get("key1"), "value2");
});
Deno.test("InMemoryLockStore — expire on expired key returns false", async () => {
const store = new InMemoryLockStore();
await store.set("key1", "value1", 0);
await new Promise((r) => setTimeout(r, 10));
const result = await store.expire("key1", 60);
assertEquals(result, false);
});
// ── Lock TTL-related tests ──────────────────────────────────────────────────
Deno.test("acquireLock then getLock after TTL expiry returns null", async () => {
const store = new InMemoryLockStore();
setLockStore(store);
// Directly set a lock with very short TTL via the store
await store.set("wopi:lock:file-expiry", "lock-ttl", 0);
await new Promise((r) => setTimeout(r, 10));
const lock = await store.get("wopi:lock:file-expiry");
assertEquals(lock, null);
});
Deno.test("concurrent lock attempts — second attempt fails", async () => {
setup();
const [r1, r2] = await Promise.all([
acquireLock("file-concurrent", "lock-a"),
acquireLock("file-concurrent", "lock-b"),
]);
// One should succeed, one should fail (or both succeed with same lock)
const successes = [r1, r2].filter((r) => r.success);
const failures = [r1, r2].filter((r) => !r.success);
// At least one should succeed
assertEquals(successes.length >= 1, true);
if (failures.length > 0) {
// If one failed, it should report the existing lock
assertNotEquals(failures[0].existingLockId, undefined);
}
});

View File

@@ -0,0 +1,192 @@
/**
* Tests for WOPI token generation and verification.
*/
import {
assertEquals,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
generateWopiToken,
verifyWopiToken,
} from "../../server/wopi/token.ts";
const TEST_SECRET = "test-secret-for-wopi-tokens";
Deno.test("generateWopiToken returns a JWT with 3 parts", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test User",
true,
3600,
TEST_SECRET,
);
const parts = token.split(".");
assertEquals(parts.length, 3);
});
Deno.test("verifyWopiToken validates a valid token", async () => {
const token = await generateWopiToken(
"file-abc",
"user-def",
"Alice",
true,
3600,
TEST_SECRET,
);
const payload = await verifyWopiToken(token, TEST_SECRET);
assertNotEquals(payload, null);
assertEquals(payload!.fid, "file-abc");
assertEquals(payload!.uid, "user-def");
assertEquals(payload!.unm, "Alice");
assertEquals(payload!.wr, true);
});
Deno.test("verifyWopiToken returns payload with canWrite=false", async () => {
const token = await generateWopiToken(
"file-123",
"user-789",
"Bob",
false,
3600,
TEST_SECRET,
);
const payload = await verifyWopiToken(token, TEST_SECRET);
assertNotEquals(payload, null);
assertEquals(payload!.wr, false);
});
Deno.test("verifyWopiToken rejects expired tokens", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
-10,
TEST_SECRET,
);
const payload = await verifyWopiToken(token, TEST_SECRET);
assertEquals(payload, null);
});
Deno.test("verifyWopiToken rejects tampered tokens", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
3600,
TEST_SECRET,
);
const parts = token.split(".");
const tamperedPayload = parts[1].slice(0, -1) +
(parts[1].slice(-1) === "A" ? "B" : "A");
const tampered = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
const payload = await verifyWopiToken(tampered, TEST_SECRET);
assertEquals(payload, null);
});
Deno.test("verifyWopiToken rejects wrong secret", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
3600,
TEST_SECRET,
);
const payload = await verifyWopiToken(token, "wrong-secret");
assertEquals(payload, null);
});
Deno.test("verifyWopiToken rejects malformed tokens", async () => {
assertEquals(await verifyWopiToken("", TEST_SECRET), null);
assertEquals(await verifyWopiToken("a.b", TEST_SECRET), null);
assertEquals(await verifyWopiToken("not-a-jwt", TEST_SECRET), null);
assertEquals(await verifyWopiToken("a.b.c.d", TEST_SECRET), null);
});
Deno.test("generated tokens have correct iat and exp", async () => {
const before = Math.floor(Date.now() / 1000);
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
7200,
TEST_SECRET,
);
const after = Math.floor(Date.now() / 1000);
const payload = await verifyWopiToken(token, TEST_SECRET);
assertNotEquals(payload, null);
assertEquals(payload!.iat >= before, true);
assertEquals(payload!.iat <= after, true);
assertEquals(payload!.exp, payload!.iat + 7200);
});
Deno.test("two tokens for different files have different signatures", async () => {
const t1 = await generateWopiToken("file-1", "user-1", "A", true, 3600, TEST_SECRET);
const t2 = await generateWopiToken("file-2", "user-1", "A", true, 3600, TEST_SECRET);
assertNotEquals(t1, t2);
});
Deno.test("verifyWopiToken with tampered header rejects", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
3600,
TEST_SECRET,
);
const parts = token.split(".");
// Change the header
const tamperedHeader = parts[0].slice(0, -1) +
(parts[0].slice(-1) === "A" ? "B" : "A");
const tampered = `${tamperedHeader}.${parts[1]}.${parts[2]}`;
const payload = await verifyWopiToken(tampered, TEST_SECRET);
assertEquals(payload, null);
});
Deno.test("verifyWopiToken with tampered signature rejects", async () => {
const token = await generateWopiToken(
"file-123",
"user-456",
"Test",
true,
3600,
TEST_SECRET,
);
const parts = token.split(".");
const tamperedSig = parts[2].slice(0, -1) +
(parts[2].slice(-1) === "A" ? "B" : "A");
const tampered = `${parts[0]}.${parts[1]}.${tamperedSig}`;
const payload = await verifyWopiToken(tampered, TEST_SECRET);
assertEquals(payload, null);
});
Deno.test("token roundtrip preserves all payload fields", async () => {
const token = await generateWopiToken(
"file-roundtrip",
"user-roundtrip",
"Roundtrip User",
false,
1800,
TEST_SECRET,
);
const payload = await verifyWopiToken(token, TEST_SECRET);
assertNotEquals(payload, null);
assertEquals(payload!.fid, "file-roundtrip");
assertEquals(payload!.uid, "user-roundtrip");
assertEquals(payload!.unm, "Roundtrip User");
assertEquals(payload!.wr, false);
assertEquals(typeof payload!.iat, "number");
assertEquals(typeof payload!.exp, "number");
});