Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/server/src/app-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FileContentResponse, PaperProject, UploadSession } from "@fastwrite/shared";
import { createApplication } from "./app";

const temporaryDirectories: string[] = [];

afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});

async function importApplication(serverAuth = true) {
const directory = await mkdtemp(join(tmpdir(), "fastwrite-import-auth-"));
temporaryDirectories.push(directory);
const app = await createApplication(directory, { features: { serverAuth } });
return { directory, request: (path: string, init?: RequestInit) => app(new Request(`http://fastwrite.test${path}`, init)) };
}

type RequestApplication = Awaited<ReturnType<typeof importApplication>>["request"];

async function register(request: RequestApplication, email: string) {
const response = await request("/api/auth/register", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: "correct-horse-battery-staple" })
});
expect(response.status).toBe(201);
return await response.json() as { token: string; user: { id: string } };
}

const sources = [
["main.tex", "\\documentclass{article}\n\\begin{document}\\input{sections/intro}\\bibliography{refs}\\end{document}"],
["sections/intro.tex", "\\section{Introduction}\nImported paper \\cite{example}."],
["refs.bib", "@article{example, title={Imported Reference}, year={2026}}"]
] as const;

function manifest(ownerUserId?: string) {
return JSON.stringify({
projectName: "Private imported paper", mainDocument: "main.tex", venue: "network-information-security", sourceName: "paper-folder",
entries: sources.map(([path, content]) => ({ path, kind: "file", size: new TextEncoder().encode(content).byteLength })),
...(ownerUserId ? { ownerUserId } : {})
});
}

describe("authenticated project imports", () => {
test("keeps upload sessions private and registers the importer as the project owner", async () => {
const { directory, request } = await importApplication();
await register(request, "admin@example.test");
const owner = await register(request, "importer@example.test");
const stranger = await register(request, "stranger@example.test");
const ownerHeaders = { authorization: `Bearer ${owner.token}` };
const strangerHeaders = { authorization: `Bearer ${stranger.token}` };
const created = await request("/api/upload-sessions", {
method: "POST", headers: { ...ownerHeaders, "content-type": "application/json" }, body: manifest(stranger.user.id)
});
expect(created.status).toBe(201);
const session = await created.json() as UploadSession;
expect(session.ownerUserId).toBe(owner.user.id);

for (const [path, method, body] of [
[`/api/upload-sessions/${session.id}`, "GET", undefined],
[`/api/upload-sessions/${session.id}/files?path=main.tex`, "PUT", new TextEncoder().encode(sources[0][1])],
[`/api/upload-sessions/${session.id}/complete`, "POST", undefined],
[`/api/upload-sessions/${session.id}`, "DELETE", undefined]
] as const) {
expect((await request(path, { method, headers: strangerHeaders, ...(body ? { body } : {}) })).status).toBe(403);
expect((await request(path, { method, ...(body ? { body } : {}) })).status).toBe(401);
}
expect((await request(`/api/upload-sessions/${session.id}`, { headers: ownerHeaders })).status).toBe(200);
expect((await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders })).status).toBe(409);
for (const [path, content] of sources) {
expect((await request(`/api/upload-sessions/${session.id}/files?path=${encodeURIComponent(path)}`, {
method: "PUT", headers: ownerHeaders, body: new TextEncoder().encode(content)
})).status).toBe(200);
}
const completed = await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders });
expect(completed.status).toBe(201);
const project = await completed.json() as PaperProject;
expect(project).toMatchObject({ personalOwnerUserId: owner.user.id, visibility: "private" });
const retried = await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: ownerHeaders });
expect(retried.status).toBe(201);
expect(await retried.json()).toMatchObject({ id: project.id, personalOwnerUserId: owner.user.id });
expect((await request(`/api/upload-sessions/${session.id}/complete`, { method: "POST", headers: strangerHeaders })).status).toBe(403);
expect(await (await request("/api/projects", { headers: ownerHeaders })).json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: project.id })]));
expect(await (await request("/api/projects", { headers: strangerHeaders })).json()).toEqual([]);
expect((await request(`/api/projects/${project.id}`, { headers: strangerHeaders })).status).toBe(403);
expect(await (await request(`/api/projects/${project.id}/members`, { headers: ownerHeaders })).json()).toMatchObject({ members: expect.arrayContaining([expect.objectContaining({ userId: owner.user.id, role: "owner" })]) });
for (const [path, content] of sources) {
const opened = await request(`/api/projects/${project.id}/file?path=${encodeURIComponent(path)}`, { headers: ownerHeaders });
expect(opened.status).toBe(200);
expect((await opened.json() as FileContentResponse).content).toBe(content);
}
const persisted = await createApplication(directory, { features: { serverAuth: true } });
expect((await persisted(new Request(`http://fastwrite.test/api/projects/${project.id}`, { headers: ownerHeaders }))).status).toBe(200);
expect((await persisted(new Request(`http://fastwrite.test/api/upload-sessions/${session.id}`, { headers: strangerHeaders }))).status).toBe(403);
});

test("requires authentication before creating directory or GitHub imports", async () => {
const { request } = await importApplication();
expect((await request("/api/upload-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: manifest() })).status).toBe(401);
expect((await request("/api/project-imports/github", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ repository: "https://github.com/example/paper" }) })).status).toBe(401);
expect((await request("/api/projects", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Anonymous template", initializeFromTemplate: true }) })).status).toBe(401);
});

test("does not let an authenticated user claim an ownerless legacy upload", async () => {
const { directory, request } = await importApplication(false);
const response = await request("/api/upload-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: manifest() });
expect(response.status).toBe(201);
const session = await response.json() as UploadSession;
const authenticated = await createApplication(directory, { features: { serverAuth: true } });
const authenticatedRequest: RequestApplication = (path, init) => authenticated(new Request(`http://fastwrite.test${path}`, init));
const user = await register(authenticatedRequest, "new-user@example.test");
for (const [path, method] of [
[`/api/upload-sessions/${session.id}`, "GET"],
[`/api/upload-sessions/${session.id}/files?path=main.tex`, "PUT"],
[`/api/upload-sessions/${session.id}/complete`, "POST"],
[`/api/upload-sessions/${session.id}`, "DELETE"]
] as const) {
expect((await authenticatedRequest(path, { method, headers: { authorization: `Bearer ${user.token}` } })).status).toBe(403);
}
});
});
52 changes: 52 additions & 0 deletions apps/server/src/app-web-static.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync, gunzipSync } from "node:zlib";
import { createApplication } from "./app";
import { config } from "./config";

test("serves gzip build assets through the application and keeps SPA fallbacks distinct from missing assets", async () => {
const directory = await mkdtemp(join(tmpdir(), "fastwrite-static-"));
const originalWebDirectory = config.webDirectory;
try {
const webDirectory = join(directory, "web");
await mkdir(join(webDirectory, "assets"), { recursive: true });
const source = "console.log('FastWrite static delivery');\n".repeat(100);
const compressed = gzipSync(source);
await writeFile(join(webDirectory, "assets", "app-abcdefgh.js"), source);
await writeFile(join(webDirectory, "assets", "app-abcdefgh.js.gz"), compressed);
await writeFile(join(webDirectory, "index.html"), "<!doctype html><title>FastWrite</title>");
config.webDirectory = webDirectory;
const app = await createApplication(join(directory, "data"), { features: { serverAuth: false } });
const request = (path: string, init?: RequestInit) => app(new Request(`http://fastwrite.test${path}`, init));

const asset = await request("/assets/app-abcdefgh.js", { headers: { "accept-encoding": "gzip" } });
expect(asset.status).toBe(200);
expect(asset.headers.get("content-encoding")).toBe("gzip");
expect(asset.headers.get("content-type")).toContain("text/javascript");
expect(asset.headers.get("cache-control")).toContain("immutable");
expect(asset.headers.get("vary")).toBe("Accept-Encoding");
expect(gunzipSync(await asset.arrayBuffer()).toString()).toBe(source);

const uncompressed = await request("/assets/app-abcdefgh.js", { headers: { "accept-encoding": "gzip;q=0" } });
expect(uncompressed.headers.get("content-encoding")).toBeNull();
expect(await uncompressed.text()).toBe(source);
const head = await request("/assets/app-abcdefgh.js", { method: "HEAD", headers: { "accept-encoding": "gzip" } });
expect(head.headers.get("content-length")).toBe(String(compressed.byteLength));
expect(await head.text()).toBe("");

const clientRoute = await request("/projects?invite=example");
expect(clientRoute.status).toBe(200);
expect(clientRoute.headers.get("content-type")).toContain("text/html");
expect(clientRoute.headers.get("cache-control")).toBe("no-cache");
for (const path of ["/assets/missing.js", "/worker-missing.js", "/bundles/missing.data.gz"]) {
const missing = await request(path);
expect(missing.status).toBe(404);
expect(missing.headers.get("content-type")).toContain("application/json");
}
} finally {
config.webDirectory = originalWebDirectory;
await rm(directory, { recursive: true, force: true });
}
});
1 change: 1 addition & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("workspace API", () => {
expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
expect(response.headers.get("content-security-policy")).toContain("style-src 'self' 'unsafe-inline'");
expect(response.headers.get("content-security-policy")).toContain("default-src 'self';");
expect(response.headers.get("content-security-policy")).toContain("img-src 'self' blob:");
expect(response.headers.get("x-request-id")).toMatch(/^req_/);
});

Expand Down
Loading
Loading