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:
584
tests/server/auth_test.ts
Normal file
584
tests/server/auth_test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
172
tests/server/backfill_test.ts
Normal file
172
tests/server/backfill_test.ts
Normal 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
208
tests/server/csrf_test.ts
Normal 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
400
tests/server/files_test.ts
Normal 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
421
tests/server/keto_test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
705
tests/server/permissions_test.ts
Normal file
705
tests/server/permissions_test.ts
Normal 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
682
tests/server/s3_test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
175
tests/server/telemetry_test.ts
Normal file
175
tests/server/telemetry_test.ts
Normal 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");
|
||||
});
|
||||
286
tests/server/wopi_discovery_test.ts
Normal file
286
tests/server/wopi_discovery_test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
291
tests/server/wopi_lock_test.ts
Normal file
291
tests/server/wopi_lock_test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
192
tests/server/wopi_token_test.ts
Normal file
192
tests/server/wopi_token_test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user