test: add tests for pages

This commit is contained in:
Jörg Krzeslak
2026-10-02 14:20:43 +02:00
committed by Laertes87
parent 411ffabd8c
commit 1248009e30
18 changed files with 2104 additions and 37 deletions
+2 -36
View File
@@ -1,11 +1,8 @@
import { test, expect, beforeEach, afterEach, afterAll } from "bun:test";
import { test, expect, beforeEach, afterEach } from "bun:test";
import { Database } from "bun:sqlite";
import { unlinkSync, existsSync, mkdirSync } from "node:fs";
// set environment variable to ensure the test database is used instead of production data
process.env.DB_PATH = "./data/test-isolated.sqlite";
// dynamic import ensures that db.ts is loaded after the env is set
// tests/preload.ts points DB_PATH at a temporary database, so production data is never used
let initializeDatabase: (db: Database) => void;
let defaultDb: Database | undefined;
await import("../../src/db/db").then((mod) => {
@@ -97,37 +94,6 @@ afterEach(() => {
}
});
afterAll(() => {
// Close the module-level default database before cleanup to prevent file lock errors
if (defaultDb) {
defaultDb.close();
}
// Cleanup of the isolated test database after the test run
if (existsSync("./data/test-isolated.sqlite")) {
unlinkSync("./data/test-isolated.sqlite");
}
if (existsSync("./data/test-isolated.sqlite-wal")) {
try {
unlinkSync("./data/test-isolated.sqlite-wal");
} catch (err) {
// WAL file cleanup error - log but don't fail test
if (err instanceof Error && err.message.includes("ENOENT")) {
// File already gone, which is fine
}
}
}
if (existsSync("./data/test-isolated.sqlite-shm")) {
try {
unlinkSync("./data/test-isolated.sqlite-shm");
} catch (err) {
// SHM file cleanup error - log but don't fail test
if (err instanceof Error && err.message.includes("ENOENT")) {
// File already gone, which is fine
}
}
}
});
test("db initializes and creates tables on first run", () => {
const tables = queryAllTables(testDb);
expect(tables.length).toBeGreaterThanOrEqual(3);
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { request } from "./helpers/app";
async function conversionsFor(fileType: string) {
return (await request("/conversions", { json: { fileType } })).text();
}
describe("POST /conversions", () => {
test("lists the targets of every converter that accepts the file type", async () => {
const body = await conversionsFor("vcf");
expect(body).toContain('data-converter="vcf"');
expect(body).toContain('data-value="csv,vcf"');
expect(body).toContain('<optgroup label="vcf"><option value="csv,vcf">csv</option></optgroup>');
});
test("normalizes the file type", async () => {
expect(await conversionsFor("VCF")).toBe(await conversionsFor("vcf"));
expect(await conversionsFor("jpg")).toBe(await conversionsFor("jpeg"));
});
test("offers no converters for an unknown file type", async () => {
const body = await conversionsFor("xyz123");
expect(body).not.toContain("convert_to_group");
expect(body).not.toContain("<optgroup");
// the placeholder of the select is still rendered
expect(body).toContain('<option selected disabled value="">');
});
test("returns a fragment, not a full page", async () => {
const body = await conversionsFor("vcf");
expect(body).toStartWith("<article");
expect(body).not.toContain("<html");
});
test("works without a session, as it only exposes the converter list", async () => {
const response = await request("/conversions", { json: { fileType: "vcf" } });
expect(response.status).toBe(200);
});
test("rejects a request without a file type", async () => {
const response = await request("/conversions", { json: {} });
expect(response.status).toBe(422);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { createJob, createUser, db, jobDirs, request, waitFor } from "./helpers/app";
type JobRow = { status: string; num_files: number };
type FileRow = { file_name: string; output_file_name: string; status: string };
function jobRow(jobId: number) {
return db.query("SELECT status, num_files FROM jobs WHERE id = ?").get(jobId) as JobRow;
}
function fileRows(jobId: number) {
return db
.query(
"SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY file_name",
)
.all(jobId) as FileRow[];
}
async function userWithJob() {
const user = await createUser();
const job = createJob(user.id);
return { user, job, dirs: jobDirs(user.id, job) };
}
function convertRequest(
cookies: { auth: string; jobId?: number },
form: { convert_to: string; file_names: string },
) {
return request("/convert", { cookies, form });
}
describe("POST /convert", () => {
test("requires a session", async () => {
const response = await request("/convert", {
headers: { accept: "application/json" },
form: { convert_to: "csv,vcf", file_names: "[]" },
});
expect(response.status).toBe(401);
});
test("redirects home without a job", async () => {
const { user } = await userWithJob();
const response = await convertRequest(
{ auth: user.token },
{ convert_to: "csv,vcf", file_names: '["a.vcf"]' },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("refuses to convert another user's job", async () => {
const { user } = await userWithJob();
const other = await userWithJob();
const response = await convertRequest(
{ auth: user.token, jobId: other.job },
{ convert_to: "csv,vcf", file_names: '["a.vcf"]' },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(jobRow(other.job).status).toBe("not started");
});
test.each([
["no converter is given", "csv"],
["the target contains a path separator", "a/b,vcf"],
["the target contains a backslash", "a\\b,vcf"],
["the target points to a parent directory", "..,vcf"],
])("redirects home without converting when %s", async (_, convertTo) => {
const { user, job } = await userWithJob();
const response = await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: convertTo, file_names: '["a.vcf"]' },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(jobRow(job).status).toBe("not started");
});
test("redirects home without converting when no files are given", async () => {
const { user, job } = await userWithJob();
const response = await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: "csv,vcf", file_names: "[]" },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(jobRow(job).status).toBe("not started");
});
test("converts the uploaded files in the background and shows the results", async () => {
const { user, job, dirs } = await userWithJob();
mkdirSync(dirs.uploads, { recursive: true });
await writeFile(`${dirs.uploads}a.vcf`, "BEGIN:VCARD\nFN:Alice\nEND:VCARD\n");
await writeFile(`${dirs.uploads}b.vcf`, "BEGIN:VCARD\nFN:Bob\nEND:VCARD\n");
const response = await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: "csv,vcf", file_names: '["a.vcf","b.vcf"]' },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`/results/${job}`);
expect(jobRow(job).num_files).toBe(2);
await waitFor(() => jobRow(job).status === "completed");
expect(fileRows(job)).toEqual([
{ file_name: "a.vcf", output_file_name: "a.csv", status: "Done" },
{ file_name: "b.vcf", output_file_name: "b.csv", status: "Done" },
]);
expect(await readFile(`${dirs.output}a.csv`, "utf-8")).toBe('Full Name\n"Alice"');
});
test("normalizes the target type before converting", async () => {
const { user, job } = await userWithJob();
await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: "JPG,doesnotexist", file_names: '["photo.png"]' },
);
await waitFor(() => jobRow(job).status === "completed");
// jpg is normalized to jpeg, whose files get the .jpg extension
expect(fileRows(job)).toEqual([
{ file_name: "photo.png", output_file_name: "photo.jpg", status: "File type not supported" },
]);
});
test("sanitizes file names so conversions cannot read outside the job directory", async () => {
const { user, job } = await userWithJob();
await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: "csv,vcf", file_names: '["../../secret.vcf"]' },
);
await waitFor(() => jobRow(job).status === "completed");
expect(fileRows(job)).toEqual([
{
file_name: "....secret.vcf",
output_file_name: "....secret.csv",
status: "Failed, check logs",
},
]);
});
// BUG: file_names is parsed and indexed before it is validated, so a value that is not a
// JSON array ends in a 500 instead of the redirect used for every other invalid input.
// Remove `.failing` once convert.tsx validates the parsed value first.
test.failing.each([
["is not JSON", "not json"],
["is a JSON string", '"a.vcf"'],
])("redirects home when file_names %s", async (_, fileNames) => {
const { user, job } = await userWithJob();
const response = await convertRequest(
{ auth: user.token, jobId: job },
{ convert_to: "csv,vcf", file_names: fileNames },
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createJob, createUser, jobDirs, request, uploadsDir } from "./helpers/app";
async function userWithUploads(...fileNames: string[]) {
const user = await createUser();
const job = createJob(user.id);
const dirs = jobDirs(user.id, job);
mkdirSync(dirs.uploads, { recursive: true });
for (const fileName of fileNames) {
writeFileSync(`${dirs.uploads}${fileName}`, fileName);
}
return { user, job, dirs };
}
describe("POST /delete", () => {
test("requires a session", async () => {
const response = await request("/delete", {
headers: { accept: "application/json" },
json: { filename: "a.txt" },
});
expect(response.status).toBe(401);
});
test("redirects home without a job", async () => {
const { user, dirs } = await userWithUploads("a.txt");
const response = await request("/delete", {
cookies: { auth: user.token },
json: { filename: "a.txt" },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(existsSync(`${dirs.uploads}a.txt`)).toBe(true);
});
test("refuses to delete from another user's job", async () => {
const { user } = await userWithUploads();
const other = await userWithUploads("a.txt");
const response = await request("/delete", {
cookies: { auth: user.token, jobId: other.job },
json: { filename: "a.txt" },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(existsSync(`${other.dirs.uploads}a.txt`)).toBe(true);
});
test("deletes only the given uploaded file", async () => {
const { user, job, dirs } = await userWithUploads("a.txt", "b.txt");
const response = await request("/delete", {
cookies: { auth: user.token, jobId: job },
json: { filename: "a.txt" },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ message: "File deleted successfully." });
expect(existsSync(`${dirs.uploads}a.txt`)).toBe(false);
expect(existsSync(`${dirs.uploads}b.txt`)).toBe(true);
});
test("sanitizes the file name so files outside the job cannot be deleted", async () => {
const { user, job } = await userWithUploads();
const other = await userWithUploads("keep.txt");
await request("/delete", {
cookies: { auth: user.token, jobId: job },
json: { filename: `../../${other.user.id}/${other.job}/keep.txt` },
});
expect(existsSync(`${other.dirs.uploads}keep.txt`)).toBe(true);
});
// BUG: unlink errors are not handled, so deleting a file that does not exist answers
// with a 500 whose body contains the absolute upload path on the server. Remove
// `.failing` once deleteFile.tsx handles the missing file.
test.failing("answers a missing file without a server error or server paths", async () => {
const { user, job } = await userWithUploads();
const response = await request("/delete", {
cookies: { auth: user.token, jobId: job },
json: { filename: "missing.txt" },
});
expect(response.status).toBeLessThan(500);
expect(await response.text()).not.toContain(uploadsDir);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createJob, createUser, db, jobDirs, request } from "./helpers/app";
async function userWithJobs(count = 1) {
const user = await createUser();
const jobs = Array.from({ length: count }, () => {
const job = createJob(user.id, { numFiles: 1 });
const dirs = jobDirs(user.id, job);
mkdirSync(dirs.uploads, { recursive: true });
mkdirSync(dirs.output, { recursive: true });
writeFileSync(`${dirs.uploads}in.vcf`, "in");
writeFileSync(`${dirs.output}out.csv`, "out");
return job;
});
return { user, jobs };
}
function jobExists(jobId: number | string) {
return db.query("SELECT id FROM jobs WHERE id = ?").get(jobId) !== null;
}
function filesExist(userId: number, jobId: number) {
const dirs = jobDirs(userId, jobId);
return existsSync(dirs.uploads) || existsSync(dirs.output);
}
describe("POST /delete/:jobId", () => {
test("requires a session", async () => {
const { user, jobs } = await userWithJobs();
const response = await request(`/delete/${jobs[0]}`, {
method: "POST",
headers: { accept: "application/json" },
});
expect(response.status).toBe(401);
expect(jobExists(jobs[0] ?? 0)).toBe(true);
expect(filesExist(user.id, jobs[0] ?? 0)).toBe(true);
});
test("deletes the job with its uploaded and converted files", async () => {
const { user, jobs } = await userWithJobs();
const job = jobs[0] ?? 0;
const response = await request(`/delete/${job}`, {
method: "POST",
cookies: { auth: user.token },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/history");
expect(jobExists(job)).toBe(false);
expect(filesExist(user.id, job)).toBe(false);
});
test("refuses to delete another user's job", async () => {
const user = await createUser();
const other = await userWithJobs();
const job = other.jobs[0] ?? 0;
const response = await request(`/delete/${job}`, {
method: "POST",
cookies: { auth: user.token },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/results");
expect(jobExists(job)).toBe(true);
expect(filesExist(other.user.id, job)).toBe(true);
});
test("cannot be triggered by a GET request (CSRF protection)", async () => {
const { user, jobs } = await userWithJobs();
const job = jobs[0] ?? 0;
const response = await request(`/delete/${job}`, { cookies: { auth: user.token } });
expect(response.status).toBe(404);
expect(jobExists(job)).toBe(true);
});
});
describe("POST /delete-multiple", () => {
const deleteMultiple = (token: string, jobIds: unknown) =>
request("/delete-multiple", { cookies: { auth: token }, json: { jobIds } });
test("requires a session", async () => {
const { jobs } = await userWithJobs();
const response = await request("/delete-multiple", {
headers: { accept: "application/json" },
json: { jobIds: jobs.map(String) },
});
expect(response.status).toBe(401);
expect(jobExists(jobs[0] ?? 0)).toBe(true);
});
test("deletes all given jobs with their files", async () => {
const { user, jobs } = await userWithJobs(2);
const response = await deleteMultiple(user.token, jobs.map(String));
expect(await response.json()).toEqual({
success: true,
deleted: 2,
failed: 0,
details: { success: jobs.map(String), failed: [] },
});
for (const job of jobs) {
expect(jobExists(job)).toBe(false);
expect(filesExist(user.id, job)).toBe(false);
}
});
test("reports jobs that do not exist or belong to another user", async () => {
const { user, jobs } = await userWithJobs();
const other = await userWithJobs();
const foreign = String(other.jobs[0]);
const response = await deleteMultiple(user.token, [String(jobs[0]), foreign, "999999"]);
expect(await response.json()).toEqual({
success: false,
deleted: 1,
failed: 2,
details: {
success: [String(jobs[0])],
failed: [
{ jobId: foreign, error: "Job not found or unauthorized" },
{ jobId: "999999", error: "Job not found or unauthorized" },
],
},
});
expect(jobExists(foreign)).toBe(true);
expect(filesExist(other.user.id, Number(foreign))).toBe(true);
});
test("rejects an empty list", async () => {
const user = await createUser();
const response = await deleteMultiple(user.token, []);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ success: false, message: "Invalid job IDs provided" });
});
test.each([
["more than 100 job ids", Array.from({ length: 101 }, (_, i) => String(i))],
["job ids that are not strings", [1, 2]],
["a value that is not a list", "1"],
])("rejects %s", async (_, jobIds) => {
const user = await createUser();
const response = await deleteMultiple(user.token, jobIds);
expect(response.status).toBe(422);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as tar from "tar";
import { createJob, createUser, jobDirs, request } from "./helpers/app";
async function userWithOutputs(files: Record<string, string>) {
const user = await createUser();
const job = createJob(user.id);
const dirs = jobDirs(user.id, job);
mkdirSync(dirs.output, { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(`${dirs.output}${name}`, content);
}
return { user, job, dirs };
}
async function tarEntries(response: Response) {
const dir = mkdtempSync(join(tmpdir(), "convertx-archive-"));
try {
const file = join(dir, "archive.tar");
writeFileSync(file, Buffer.from(await response.arrayBuffer()));
const entries: string[] = [];
await tar.list({ file, onReadEntry: (entry) => entries.push(entry.path) });
return entries.sort();
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("GET /download/:userId/:jobId/:fileName", () => {
test("redirects to the login page without a session", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "a" });
const response = await request(`/download/${user.id}/${job}/a.csv`, {
headers: { accept: "text/html" },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("serves a converted file", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "converted" });
const response = await request(`/download/${user.id}/${job}/a.csv`, {
cookies: { auth: user.token },
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toStartWith("text/csv");
expect(await response.text()).toBe("converted");
});
test("serves file names with encoded spaces and hashes", async () => {
const { user, job } = await userWithOutputs({ "my file #1.csv": "spaced" });
const response = await request(`/download/${user.id}/${job}/my%20file%20%231.csv`, {
cookies: { auth: user.token },
});
expect(await response.text()).toBe("spaced");
});
test("answers 404 for a file that does not exist", async () => {
const { user, job } = await userWithOutputs({});
const response = await request(`/download/${user.id}/${job}/missing.csv`, {
cookies: { auth: user.token },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: "Converted file not found." });
});
test("refuses files of another user's job", async () => {
const user = await createUser();
const other = await userWithOutputs({ "secret.csv": "secret" });
const response = await request(`/download/${other.user.id}/${other.job}/secret.csv`, {
cookies: { auth: user.token },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/results");
});
test("ignores the user id in the URL and only serves the caller's own files", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "mine" });
const other = await createUser();
mkdirSync(jobDirs(other.id, job).output, { recursive: true });
writeFileSync(`${jobDirs(other.id, job).output}a.csv`, "theirs");
const response = await request(`/download/${other.id}/${job}/a.csv`, {
cookies: { auth: user.token },
});
expect(await response.text()).toBe("mine");
});
test("sanitizes the file name so files outside the job cannot be read", async () => {
const { user, job } = await userWithOutputs({});
const other = await userWithOutputs({ "secret.csv": "secret" });
const traversal = encodeURIComponent(`../../${other.user.id}/${other.job}/secret.csv`);
const response = await request(`/download/${user.id}/${job}/${traversal}`, {
cookies: { auth: user.token },
});
expect(response.status).toBe(404);
});
// BUG: Elysia already decodes path parameters and download.tsx decodes them a second
// time, so file names containing "%" fail with a 500 "URI error" (and a literal "%20"
// would be turned into a space). The results page links to such files. Remove
// `.failing` once the second decodeURIComponent is dropped.
test.failing("serves file names containing a percent sign", async () => {
const { user, job } = await userWithOutputs({ "100%.csv": "percent" });
const response = await request(`/download/${user.id}/${job}/100%25.csv`, {
cookies: { auth: user.token },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe("percent");
});
});
describe("GET /archive/:jobId", () => {
test("redirects to the login page without a session", async () => {
const { job } = await userWithOutputs({ "a.csv": "a" });
const response = await request(`/archive/${job}`, { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("packs all converted files of the job into a tar archive", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "a", "b.csv": "b" });
const response = await request(`/archive/${job}`, { cookies: { auth: user.token } });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/x-tar");
expect(await tarEntries(response)).toEqual(["./", "./a.csv", "./b.csv"]);
});
test("does not include earlier archives in a new archive", async () => {
const { user, job } = await userWithOutputs({ "a.csv": "a" });
await request(`/archive/${job}`, { cookies: { auth: user.token } });
const response = await request(`/archive/${job}`, { cookies: { auth: user.token } });
expect(await tarEntries(response)).toEqual(["./", "./a.csv"]);
});
test("refuses another user's job", async () => {
const user = await createUser();
const other = await userWithOutputs({ "secret.csv": "secret" });
const response = await request(`/archive/${other.job}`, { cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/results");
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import type { User } from "../../src/db/types";
import { db, FIRST_ACCOUNT, firstRun as recordedFirstRun, SECOND_ACCOUNT } from "./helpers/app";
// helpers/app.ts walks through the first run when it loads the pages (see the comment
// there); these tests check the recorded responses.
if (!recordedFirstRun) {
throw new Error("The database already had accounts when helpers/app.ts loaded the pages");
}
const firstRun = recordedFirstRun;
function findUser(email: string) {
return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null;
}
describe("first run", () => {
test("shows the account setup form", () => {
expect(firstRun.setup.status).toBe(200);
expect(firstRun.setup.body).toContain("Welcome to ConvertX!");
expect(firstRun.setup.body).toContain('action="/register"');
});
test("sends visitors of the login page to the setup", () => {
expect(firstRun.login.status).toBe(302);
expect(firstRun.login.location).toBe("/setup");
});
test("sends visitors of the home page to the setup", () => {
expect(firstRun.home.status).toBe(302);
expect(firstRun.home.location).toBe("/setup");
});
test("registers the first account although registration is closed", async () => {
expect(firstRun.register.status).toBe(302);
expect(firstRun.register.location).toBe("/");
expect(firstRun.register.cookies.some((cookie) => cookie.startsWith("auth="))).toBe(true);
const user = findUser(FIRST_ACCOUNT.email);
expect(user).not.toBeNull();
// the password is stored as a hash, never in plain text
expect(user?.password).not.toBe(FIRST_ACCOUNT.password);
expect(await Bun.password.verify(FIRST_ACCOUNT.password, user?.password ?? "")).toBe(true);
});
test("closes the setup once the first account exists", () => {
expect(firstRun.setupAfterwards.status).toBe(302);
expect(firstRun.setupAfterwards.location).toBe("/login");
});
test("closes the registration once the first account exists", () => {
expect(firstRun.registerAfterwards.status).toBe(302);
expect(firstRun.registerAfterwards.location).toBe("/login");
expect(findUser(SECOND_ACCOUNT.email)).toBeNull();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { request } from "./helpers/app";
describe("GET /healthcheck", () => {
test("reports ok without a session", async () => {
const response = await request("/healthcheck");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: "ok" });
});
test("ignores an invalid session", async () => {
const response = await request("/healthcheck", { cookies: { auth: "not-a-jwt" } });
expect(response.status).toBe(200);
expect(response.headers.getSetCookie()).toEqual([]);
});
});
+208
View File
@@ -0,0 +1,208 @@
import { mock } from "bun:test";
import { html } from "@elysiajs/html";
import { Elysia } from "elysia";
import * as realEnvModule from "../../../src/helpers/env";
if (!process.env.CONVERTX_TEST_ROOT) {
// Without tests/preload.ts the pages would import src/index.tsx (starting the server)
// and write into ./data next to real conversions.
throw new Error("Page tests must run via `bun test` so that tests/preload.ts is loaded.");
}
// dynamic imports ensure that the guard above runs before any page module is loaded
const { default: db } = await import("../../../src/db/db");
const { uploadsDir, outputDir } = await import("../../../src/index");
const userPage = await import("../../../src/pages/user");
const { root } = await import("../../../src/pages/root");
const { upload } = await import("../../../src/pages/upload");
const { history } = await import("../../../src/pages/history");
const { convert } = await import("../../../src/pages/convert");
const { download } = await import("../../../src/pages/download");
const { deleteJob } = await import("../../../src/pages/deleteJob");
const { results } = await import("../../../src/pages/results");
const { deleteFile } = await import("../../../src/pages/deleteFile");
const { listConverters } = await import("../../../src/pages/listConverters");
const { chooseConverter } = await import("../../../src/pages/chooseConverter");
const { healthcheck } = await import("../../../src/pages/healthcheck");
export { db, uploadsDir };
// Same composition as src/index.tsx, without the server, static files and job cleanup.
export const app = new Elysia({ prefix: realEnvModule.WEBROOT })
.use(html())
.use(userPage.user)
.use(root)
.use(upload)
.use(history)
.use(convert)
.use(download)
.use(deleteJob)
.use(results)
.use(deleteFile)
.use(listConverters)
.use(chooseConverter)
.use(healthcheck);
// --- environment flags ---------------------------------------------------------
// src/helpers/env.ts reads process.env once at import, so flags are switched by
// re-mocking the module; importers see the new values through their live bindings.
type Env = typeof realEnvModule;
const realEnv: Env = { ...realEnvModule };
export function setEnv(overrides: Partial<Env>) {
mock.module("../../../src/helpers/env", () => ({ ...realEnv, ...overrides }));
}
export function resetEnv() {
mock.module("../../../src/helpers/env", () => realEnv);
}
// --- requests --------------------------------------------------------------------
type RequestOptions = {
method?: string;
cookies?: Record<string, string | number | undefined>;
headers?: Record<string, string>;
json?: unknown;
form?: Record<string, string>;
body?: RequestInit["body"];
};
export function request(path: string, options: RequestOptions = {}) {
const headers: Record<string, string> = { ...options.headers };
let body = options.body;
const cookie = Object.entries(options.cookies ?? {})
.filter(([, value]) => value !== undefined)
.map(([name, value]) => `${name}=${value}`)
.join("; ");
if (cookie) headers.cookie = cookie;
if (options.json !== undefined) {
headers["content-type"] = "application/json";
body = JSON.stringify(options.json);
} else if (options.form) {
headers["content-type"] = "application/x-www-form-urlencoded";
body = new URLSearchParams(options.form).toString();
}
return app.handle(
new Request(`http://localhost${path}`, {
method: options.method ?? (body === undefined ? "GET" : "POST"),
headers,
body,
}),
);
}
/** Returns the Set-Cookie header for one cookie, or undefined if it was not set. */
export function setCookie(response: Response, name: string) {
return response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${name}=`));
}
/** Extracts the value of a cookie set by the response. */
export function setCookieValue(response: Response, name: string) {
return setCookie(response, name)
?.split(";")[0]
?.slice(name.length + 1);
}
// --- users and jobs --------------------------------------------------------------
let userCounter = 0;
export type TestUser = { id: number; email: string; password: string; token: string };
export async function signToken(id: number | string) {
return app.decorator.jwt.sign({ id: String(id) });
}
/** Creates a user directly in the database, bypassing the registration rules. */
export async function createUser(password = "correct horse battery staple"): Promise<TestUser> {
const email = `user${++userCounter}-${crypto.randomUUID()}@example.com`;
const { id } = db
.query("INSERT INTO users (email, password) VALUES (?, ?) RETURNING id")
.get(email, await Bun.password.hash(password)) as { id: number };
return { id, email, password, token: await signToken(id) };
}
type FileRow = { file_name: string; output_file_name: string; status: string };
export function createJob(
userId: number | string,
{ numFiles = 0, status = "not started", files = [] as FileRow[] } = {},
) {
const { id } = db
.query(
"INSERT INTO jobs (user_id, date_created, num_files, status) VALUES (?, ?, ?, ?) RETURNING id",
)
.get(userId, new Date().toISOString(), numFiles, status) as { id: number };
for (const file of files) {
db.query(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?, ?, ?, ?)",
).run(id, file.file_name, file.output_file_name, file.status);
}
return id;
}
export function jobDirs(userId: number | string, jobId: number | string) {
return {
uploads: `${uploadsDir}${userId}/${jobId}/`,
output: `${outputDir}${userId}/${jobId}/`,
};
}
/** Polls until the condition holds, e.g. for conversions that run in the background. */
export async function waitFor(condition: () => boolean, timeoutMs = 5000) {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) {
throw new Error(`Condition not met within ${timeoutMs} ms`);
}
await Bun.sleep(10);
}
}
// --- first run -------------------------------------------------------------------
// FIRST_RUN is computed once when src/pages/user.tsx is loaded and only flips back when
// the first account is registered. Until then almost every page redirects to /setup.
// Walk through the setup once, like a fresh installation, and keep the responses for
// tests/pages/firstRun.test.ts; every other test then starts from a configured instance.
type Snapshot = { status: number; location: string | null; cookies: string[]; body: string };
async function snapshot(response: Promise<Response>): Promise<Snapshot> {
const { status, headers } = await response;
return {
status,
location: headers.get("location"),
cookies: headers.getSetCookie(),
body: await (await response).text(),
};
}
export const FIRST_ACCOUNT = { email: "first@example.com", password: crypto.randomUUID() };
export const SECOND_ACCOUNT = { email: "second@example.com", password: crypto.randomUUID() };
async function walkThroughFirstRun() {
// the first account must not depend on open registration
setEnv({ ACCOUNT_REGISTRATION: false });
try {
return {
setup: await snapshot(request("/setup")),
login: await snapshot(request("/login")),
home: await snapshot(request("/")),
register: await snapshot(request("/register", { form: FIRST_ACCOUNT })),
setupAfterwards: await snapshot(request("/setup")),
registerAfterwards: await snapshot(request("/register", { form: SECOND_ACCOUNT })),
};
} finally {
resetEnv();
}
}
export const firstRun = userPage.FIRST_RUN ? await walkThroughFirstRun() : undefined;
if (userPage.FIRST_RUN) {
throw new Error("Registering the first account did not complete the first run");
}
+111
View File
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createJob, createUser, request, resetEnv, setEnv } from "./helpers/app";
afterEach(() => {
resetEnv();
});
const file = (name: string, status = "Done") => ({
file_name: `${name}.vcf`,
output_file_name: `${name}.csv`,
status,
});
async function historyOf(token: string) {
return (await request("/history", { cookies: { auth: token } })).text();
}
describe("GET /history", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/history", { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("redirects home when the history is hidden", async () => {
setEnv({ HIDE_HISTORY: true });
const user = await createUser();
const response = await request("/history", { cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("lists the user's jobs, newest first", async () => {
const user = await createUser();
const older = createJob(user.id, { numFiles: 1, files: [file("old")] });
const newer = createJob(user.id, { numFiles: 1, files: [file("new")] });
const body = await historyOf(user.token);
expect(body.indexOf(`id="job-row-${newer}"`)).toBeGreaterThan(-1);
expect(body.indexOf(`id="job-row-${older}"`)).toBeGreaterThan(
body.indexOf(`id="job-row-${newer}"`),
);
expect(body).toContain(`href="/results/${newer}"`);
expect(body).toContain(`action="/delete/${newer}"`);
});
test("shows progress and file details of a job", async () => {
const user = await createUser();
const job = createJob(user.id, {
numFiles: 3,
status: "pending",
files: [file("a"), file("b", "Failed, check logs")],
});
const body = await historyOf(user.token);
const row = body.slice(
body.indexOf(`id="job-row-${job}"`),
body.indexOf(`id="details-${job}"`),
);
const details = body.slice(body.indexOf(`id="details-${job}"`));
expect(row).toContain("<td>3</td>");
expect(row).toContain('<td class="max-sm:hidden">2</td>');
expect(row).toContain("<td>pending</td>");
expect(details).toContain('title="a.vcf"');
expect(details).toContain('title="b.csv"');
});
test("hides jobs without files", async () => {
const user = await createUser();
const empty = createJob(user.id, { numFiles: 0 });
const body = await historyOf(user.token);
expect(body).not.toContain(`id="job-row-${empty}"`);
});
test("does not show other users' jobs", async () => {
const user = await createUser();
const other = await createUser();
const foreign = createJob(other.id, { numFiles: 1, files: [file("foreign")] });
const body = await historyOf(user.token);
expect(body).not.toContain(`id="job-row-${foreign}"`);
expect(body).not.toContain("foreign.vcf");
});
test("escapes file names and statuses", async () => {
const user = await createUser();
createJob(user.id, {
numFiles: 1,
status: "<b>pending</b>",
files: [
{ file_name: '"><script>alert(1)</script>.vcf', output_file_name: "x.csv", status: "Done" },
],
});
const body = await historyOf(user.token);
// neither the text nor the title attribute can be broken out of
expect(body).not.toContain('"><script>');
expect(body).not.toContain("<b>pending</b>");
expect(body).toContain("&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;.vcf");
expect(body).toContain("&lt;b&gt;pending&lt;/b&gt;");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { getAllInputs, getAllTargets } from "../../src/converters/main";
import { createUser, request } from "./helpers/app";
describe("GET /converters", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/converters", { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("lists every converter with the number of inputs and targets", async () => {
const user = await createUser();
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
for (const [converter, targets] of Object.entries(getAllTargets())) {
expect(body).toContain(
`<td>${converter}</td><td>Count: ${getAllInputs(converter).length}<ul>`,
);
expect(body).toContain(`Count: ${targets.length}<ul>`);
}
});
test("lists the inputs and targets of a converter", async () => {
const user = await createUser();
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
expect(body).toContain(
"<td>vcf</td><td>Count: 1<ul><li>vcf</li></ul></td><td>Count: 1<ul><li>csv</li></ul></td>",
);
});
});
+174 -1
View File
@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import { buildDownloadUrl } from "../../src/helpers/buildDownloadUrl";
import { createJob, createUser, request, resetEnv, setCookie, setEnv } from "./helpers/app";
afterEach(() => {
resetEnv();
});
test("encodes reserved characters in download filenames", () => {
expect(buildDownloadUrl("", "1/2/", "clip #1?.gif")).toBe("/download/1/2/clip%20%231%3F.gif");
@@ -10,3 +15,171 @@ test("preserves output path segments while encoding the filename", () => {
"/convertx/download/user/job/%E5%A0%B1%E5%91%8A%20100%25.pdf",
);
});
const done = (name: string) => ({
file_name: `${name}.vcf`,
output_file_name: `${name}.csv`,
status: "Done",
});
describe.each([
["GET", "/results"],
["POST", "/progress"],
])("%s %s/:jobId", (method, route) => {
test("requires a session", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request(`${route}/${job}`, {
method,
headers: { accept: "application/json" },
});
expect(response.status).toBe(401);
});
test.each([
["another user's job", true],
["a job that does not exist", false],
])("answers 404 for %s", async (_, exists) => {
const user = await createUser();
const other = await createUser();
const job = exists ? createJob(other.id, { numFiles: 1, files: [done("secret")] }) : 999_999;
const response = await request(`${route}/${job}`, { method, cookies: { auth: user.token } });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: "Job not found." });
});
test("lists the converted files with view and download links", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("my file #1")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain(`href="/download/${user.id}/${job}/a.csv"`);
expect(body).toContain(`href="/download/${user.id}/${job}/my%20file%20%231.csv"`);
expect(body).toContain('download="my file #1.csv"');
});
test.each(["Failed, check logs", "File type not supported"])(
"shows no links for files with status '%s'",
async (status) => {
const user = await createUser();
const job = createJob(user.id, {
numFiles: 1,
files: [{ file_name: "a.xyz", output_file_name: "a.pdf", status }],
});
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain("Unavailable");
expect(body).toContain(`<td>${status}</td>`);
expect(body).not.toContain("/download/");
},
);
test("escapes file names and statuses", async () => {
const user = await createUser();
const name = '"><img src=x onerror=alert(1)>.csv';
const job = createJob(user.id, {
numFiles: 1,
files: [{ file_name: "x", output_file_name: name, status: "<b>Done</b>" }],
});
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
// neither the text nor the download attribute can be broken out of
expect(body).not.toContain('"><img');
expect(body).not.toContain("<b>Done</b>");
expect(body).toContain("&quot;&gt;&lt;img src=x onerror=alert(1)&gt;.csv");
expect(body).toContain("&lt;b&gt;Done&lt;/b&gt;");
});
test("disables delete and tar download while files are still converting", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body.match(/aria-busy="true"/g)).toHaveLength(2);
expect(body).toMatch(/<progress max="2"(?! value)/);
});
test("enables delete and tar download once every file is converted", async () => {
const user = await createUser();
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("b")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).not.toContain('aria-busy="true"');
expect(body).toContain('<progress max="2" value="2"');
expect(body).toContain(`action="/delete/${job}"`);
expect(body).toContain(`href="/archive/${job}"`);
});
test("prefixes all links with WEBROOT", async () => {
setEnv({ WEBROOT: "/convertx" });
const user = await createUser();
const job = createJob(user.id, { numFiles: 1, files: [done("a")] });
const body = await (
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
).text();
expect(body).toContain(`href="/convertx/download/${user.id}/${job}/a.csv"`);
expect(body).toContain(`action="/convertx/delete/${job}"`);
expect(body).toContain(`href="/convertx/archive/${job}"`);
});
test("does not set cookies", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request(`${route}/${job}`, {
method,
cookies: { auth: user.token, jobId: job },
});
expect(setCookie(response, "jobId")).toBeUndefined();
expect(setCookie(response, "auth")).toBeUndefined();
});
});
describe("GET /results/:jobId", () => {
test("renders a full page with the results script", async () => {
const user = await createUser();
const job = createJob(user.id);
const body = await (await request(`/results/${job}`, { cookies: { auth: user.token } })).text();
expect(body).toStartWith("<!doctype html>");
expect(body).toContain("<title>ConvertX | Result</title>");
expect(body).toContain('<script src="/results.js" defer');
});
});
describe("POST /progress/:jobId", () => {
test("renders only the results fragment for polling", async () => {
const user = await createUser();
const job = createJob(user.id);
const body = await (
await request(`/progress/${job}`, { method: "POST", cookies: { auth: user.token } })
).text();
expect(body).toStartWith('<article class="article">');
expect(body).not.toContain("<html");
});
});
+156
View File
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
app,
createUser,
db,
request,
resetEnv,
setCookie,
setCookieValue,
setEnv,
signToken,
} from "./helpers/app";
afterEach(() => {
resetEnv();
});
type JobRow = { id: number; user_id: string; date_created: string; status: string };
function jobsOf(userId: number | string) {
return db.query("SELECT * FROM jobs WHERE user_id = ? ORDER BY id").all(userId) as JobRow[];
}
async function userIdOf(response: Response) {
const payload = await app.decorator.jwt.verify(setCookieValue(response, "auth") ?? "");
return payload ? payload.id : undefined;
}
describe("GET / with accounts", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/");
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("redirects to the login page for an invalid session", async () => {
const response = await request("/", { cookies: { auth: "not-a-jwt" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("clears the session of an account that no longer exists", async () => {
const response = await request("/", { cookies: { auth: await signToken(999_999) } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
expect(jobsOf(999_999)).toEqual([]);
});
test("starts a new job and remembers it in the jobId cookie", async () => {
const user = await createUser();
const response = await request("/", { cookies: { auth: user.token } });
expect(response.status).toBe(200);
const jobs = jobsOf(user.id);
expect(jobs).toHaveLength(1);
expect(jobs[0]?.status).toBe("not started");
expect(Date.now() - Date.parse(jobs[0]?.date_created ?? "")).toBeLessThan(60_000);
expect(setCookieValue(response, "jobId")).toBe(String(jobs[0]?.id));
expect(setCookie(response, "jobId")).toContain("HttpOnly");
expect(setCookie(response, "jobId")).toContain("Secure");
expect(setCookie(response, "jobId")).toContain("SameSite=Strict");
expect(setCookie(response, "jobId")).toContain(`Max-Age=${24 * 60 * 60}`);
});
test("starts a fresh job on every visit", async () => {
const user = await createUser();
const first = await request("/", { cookies: { auth: user.token } });
const second = await request("/", { cookies: { auth: user.token } });
expect(jobsOf(user.id)).toHaveLength(2);
expect(Number(setCookieValue(second, "jobId"))).toBeGreaterThan(
Number(setCookieValue(first, "jobId")),
);
});
test("offers every target of every converter", async () => {
const user = await createUser();
const body = await (await request("/", { cookies: { auth: user.token } })).text();
expect(body).toContain('action="/convert"');
expect(body).toContain('<option value="csv,vcf">csv</option>');
expect(body).toContain('data-value="png,inkscape"');
});
test("links the account and logoff pages in the header", async () => {
const user = await createUser();
const body = await (await request("/", { cookies: { auth: user.token } })).text();
expect(body).toContain('href="/account"');
expect(body).toContain('href="/logoff"');
});
});
describe("GET / without accounts (ALLOW_UNAUTHENTICATED)", () => {
test("creates an anonymous session and a job for visitors without a session", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const response = await request("/");
expect(response.status).toBe(200);
const userId = await userIdOf(response);
// anonymous ids start at 2^24 so they never collide with registered accounts
expect(Number(userId)).toBeGreaterThanOrEqual(2 ** 24);
expect(setCookie(response, "auth")).toContain(`Max-Age=${24 * 60 * 60}`);
expect(jobsOf(userId ?? "")).toHaveLength(1);
});
test("gives every anonymous visitor a different id", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const first = await userIdOf(await request("/"));
const second = await userIdOf(await request("/"));
expect(first).not.toBe(second);
});
test("shares one anonymous id when UNAUTHENTICATED_USER_SHARING is enabled", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true, UNAUTHENTICATED_USER_SHARING: true });
expect(await userIdOf(await request("/"))).toBe("0");
expect(await userIdOf(await request("/"))).toBe("0");
});
test("hides the account and logoff links", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const body = await (await request("/")).text();
expect(body).not.toContain('href="/account"');
expect(body).not.toContain('href="/logoff"');
});
// BUG: the ALLOW_UNAUTHENTICATED branch runs before the session check, so every visit
// replaces the existing session (even a registered account's) with a new anonymous id
// and the user loses access to their previous jobs. This regressed the fix for #114
// ("keep unauthenticated user logged in if allowed", bc4ad49) in 538c5b6. Remove
// `.failing` once root.tsx verifies an existing session first.
test.failing("keeps an existing session", async () => {
setEnv({ ALLOW_UNAUTHENTICATED: true });
const user = await createUser();
const response = await request("/", { cookies: { auth: user.token } });
expect(setCookie(response, "auth")).toBeUndefined();
expect(jobsOf(user.id)).toHaveLength(1);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";
import { existsSync, readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { createJob, createUser, jobDirs, request } from "./helpers/app";
function uploadForm(...files: File[]) {
const form = new FormData();
for (const file of files) {
form.append("file", file);
}
return form;
}
describe("POST /upload", () => {
test("requires a session", async () => {
const response = await request("/upload", {
method: "POST",
headers: { accept: "application/json" },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(401);
});
test("redirects home without a job", async () => {
const user = await createUser();
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("refuses to upload into another user's job", async () => {
const user = await createUser();
const other = await createUser();
const otherJob = createJob(other.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: otherJob },
body: uploadForm(new File(["x"], "a.txt")),
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(existsSync(jobDirs(other.id, otherJob).uploads)).toBe(false);
expect(existsSync(jobDirs(user.id, otherJob).uploads)).toBe(false);
});
test("stores a single file in the job's upload directory", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["hello world"], "notes.txt")),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ message: "Files uploaded successfully." });
expect(await readFile(`${jobDirs(user.id, job).uploads}notes.txt`, "utf-8")).toBe(
"hello world",
);
});
test("stores multiple files at once", async () => {
const user = await createUser();
const job = createJob(user.id);
await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["1"], "one.txt"), new File(["2"], "two.txt")),
});
expect(readdirSync(jobDirs(user.id, job).uploads).sort()).toEqual(["one.txt", "two.txt"]);
});
test("sanitizes file names so uploads cannot escape the job directory", async () => {
const user = await createUser();
const job = createJob(user.id);
const { uploads } = jobDirs(user.id, job);
await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: uploadForm(new File(["evil"], "../../../evil.txt")),
});
expect(readdirSync(uploads)).toEqual(["......evil.txt"]);
expect(existsSync(resolve(uploads, "../../../evil.txt"))).toBe(false);
});
test("rejects a request without files", async () => {
const user = await createUser();
const job = createJob(user.id);
const response = await request("/upload", {
method: "POST",
cookies: { auth: user.token, jobId: job },
body: new FormData(),
});
expect(response.status).toBe(422);
});
});
+350
View File
@@ -0,0 +1,350 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { User } from "../../src/db/types";
import {
app,
createUser,
db,
request,
resetEnv,
setCookie,
setCookieValue,
setEnv,
signToken,
} from "./helpers/app";
afterEach(() => {
resetEnv();
});
function findUser(email: string) {
return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null;
}
function findUserById(id: number) {
return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
}
const uniqueEmail = () => `${crypto.randomUUID()}@example.com`;
describe("GET /register", () => {
test("redirects to the login page while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const response = await request("/register");
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("shows the registration form when registration is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const response = await request("/register");
expect(response.status).toBe(200);
expect(await response.text()).toContain('value="Register"');
});
});
describe("POST /register", () => {
test("does not create an account while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const email = uniqueEmail();
const response = await request("/register", { form: { email, password: "secret" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(findUser(email)).toBeNull();
});
test("creates the account, logs it in and redirects home when registration is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const email = uniqueEmail();
const response = await request("/register", { form: { email, password: "secret" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
const user = findUser(email);
expect(user).not.toBeNull();
expect(await Bun.password.verify("secret", user?.password ?? "")).toBe(true);
const token = setCookieValue(response, "auth") ?? "";
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user?.id) });
expect(setCookie(response, "auth")).toContain("HttpOnly");
expect(setCookie(response, "auth")).toContain("Secure");
expect(setCookie(response, "auth")).toContain("SameSite=Strict");
expect(setCookie(response, "auth")).toContain(`Max-Age=${7 * 24 * 60 * 60}`);
});
test("omits the Secure cookie flag when plain HTTP is allowed", async () => {
setEnv({ ACCOUNT_REGISTRATION: true, HTTP_ALLOWED: true });
const response = await request("/register", {
form: { email: uniqueEmail(), password: "secret" },
});
expect(setCookie(response, "auth")).not.toContain("Secure");
});
test("rejects an email that is already registered", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const existing = await createUser();
const response = await request("/register", {
form: { email: existing.email, password: "another password" },
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ message: "Email already in use." });
// the existing account keeps its password
expect(
await Bun.password.verify(existing.password, findUser(existing.email)?.password ?? ""),
).toBe(true);
});
test("rejects a request without a password", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const response = await request("/register", { form: { email: uniqueEmail() } });
expect(response.status).toBe(422);
});
});
describe("GET /login", () => {
test("shows the login form without a register link while registration is closed", async () => {
setEnv({ ACCOUNT_REGISTRATION: false });
const body = await (await request("/login")).text();
expect(body).toContain('value="Login"');
expect(body).not.toContain('href="/register"');
});
test("links to the registration when it is open", async () => {
setEnv({ ACCOUNT_REGISTRATION: true });
const body = await (await request("/login")).text();
expect(body).toContain('href="/register"');
});
test("redirects users that are already logged in to the home page", async () => {
const user = await createUser();
const response = await request("/login", { cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
test("clears an invalid session and shows the login form", async () => {
const response = await request("/login", { cookies: { auth: "not-a-jwt" } });
expect(response.status).toBe(200);
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
});
describe("POST /login", () => {
test("logs in with valid credentials and redirects home", async () => {
const user = await createUser();
const response = await request("/login", {
form: { email: user.email, password: user.password },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
const token = setCookieValue(response, "auth") ?? "";
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user.id) });
expect(setCookie(response, "auth")).toContain("HttpOnly");
});
test.each([
["the password is wrong", "wrong password"],
["the password is empty", ""],
])("rejects the login when %s", async (_, password) => {
const user = await createUser();
const response = await request("/login", { form: { email: user.email, password } });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ message: "Invalid credentials." });
expect(setCookie(response, "auth")).toBeUndefined();
});
test("gives the same answer for unknown emails, so accounts cannot be enumerated", async () => {
const response = await request("/login", {
form: { email: uniqueEmail(), password: "whatever" },
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ message: "Invalid credentials." });
});
test("rejects a request without a password", async () => {
const user = await createUser();
const response = await request("/login", { form: { email: user.email } });
expect(response.status).toBe(422);
});
});
describe.each(["GET", "POST"])("%s /logoff", (method) => {
test("clears the session and redirects to the login page", async () => {
const user = await createUser();
const response = await request("/logoff", { method, cookies: { auth: user.token } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
test("redirects to the login page without a session", async () => {
const response = await request("/logoff", { method });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toBeUndefined();
});
});
describe("GET /account", () => {
test("redirects to the login page without a session", async () => {
const response = await request("/account", { headers: { accept: "text/html" } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("shows the account form with the current email", async () => {
const user = await createUser();
const response = await request("/account", { cookies: { auth: user.token } });
expect(response.status).toBe(200);
expect(await response.text()).toContain(`value="${user.email}"`);
});
test("redirects home when the account no longer exists", async () => {
const response = await request("/account", { cookies: { auth: await signToken(999_999) } });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
});
});
describe("POST /account", () => {
const updateAccount = (
token: string,
form: { email?: string; newPassword?: string; password: string },
) =>
request("/account", {
cookies: { auth: token },
form: { email: "", newPassword: "", ...form },
});
test("rejects the update when the current password is wrong", async () => {
const user = await createUser();
const response = await updateAccount(user.token, {
email: uniqueEmail(),
password: "wrong password",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ message: "Invalid credentials." });
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("changes the email", async () => {
const user = await createUser();
const newEmail = uniqueEmail();
const response = await updateAccount(user.token, { email: newEmail, password: user.password });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/");
expect(findUserById(user.id)?.email).toBe(newEmail);
});
test("allows submitting the unchanged email", async () => {
const user = await createUser();
const response = await updateAccount(user.token, {
email: user.email,
password: user.password,
});
expect(response.status).toBe(302);
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("refuses an email that belongs to another account", async () => {
const user = await createUser();
const other = await createUser();
const response = await updateAccount(user.token, {
email: other.email,
password: user.password,
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ message: "Email already in use." });
expect(findUserById(user.id)?.email).toBe(user.email);
});
test("changes the password", async () => {
const user = await createUser();
await updateAccount(user.token, { newPassword: "new password", password: user.password });
const login = await request("/login", {
form: { email: user.email, password: "new password" },
});
expect(login.status).toBe(302);
const oldLogin = await request("/login", {
form: { email: user.email, password: user.password },
});
expect(oldLogin.status).toBe(403);
});
test("keeps everything unchanged when email and new password are left blank", async () => {
const user = await createUser();
const before = findUserById(user.id);
const response = await updateAccount(user.token, { password: user.password });
expect(response.status).toBe(302);
expect(findUserById(user.id)).toEqual(before);
});
test("requires a session cookie", async () => {
const response = await request("/account", {
form: { email: "", newPassword: "", password: "secret" },
});
expect(response.status).toBe(422);
});
test("redirects to the login page for an invalid session", async () => {
const response = await updateAccount("not-a-jwt", { password: "secret" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
});
test("clears the session of an account that no longer exists", async () => {
const response = await updateAccount(await signToken(999_999), { password: "secret" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/login");
expect(setCookie(response, "auth")).toContain("Max-Age=0");
});
});
+25
View File
@@ -0,0 +1,25 @@
// Loaded before every test file (see bunfig.toml).
import { afterAll, mock } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const testRoot = mkdtempSync(join(tmpdir(), "convertx-tests-"));
// Tests must never touch ./data/mydb.sqlite or a DB_PATH from the developer's shell.
process.env.DB_PATH = join(testRoot, "test.sqlite");
process.env.CONVERTX_TEST_ROOT = testRoot;
// Several pages import uploadsDir/outputDir from src/index.tsx. Importing the real module
// would start the server, spawn every converter's --version and schedule the job cleanup,
// and its relative ./data paths would mix test files with real conversions. Provide the
// constants directly, pointing at the temp directory.
mock.module("../src/index", () => ({
uploadsDir: `${join(testRoot, "uploads")}/`,
outputDir: `${join(testRoot, "output")}/`,
}));
// a preload's afterAll runs once after all test files (and once per file with --isolate)
afterAll(() => {
rmSync(testRoot, { recursive: true, force: true });
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
import { jwt } from "@elysiajs/jwt";
import { Elysia } from "elysia";
import { WEBROOT } from "../../src/helpers/env";
import { userService } from "../../src/services/user";
let protectedHandlerCalls = 0;
const app = new Elysia()
.use(userService)
.get(
"/me",
({ user }) => {
protectedHandlerCalls++;
return user;
},
{ auth: true },
)
.post("/me", ({ user }) => user, { auth: true })
.get("/public", () => "public")
.post("/sign-in", ({ body }) => body, { body: "signIn" })
.get("/session", ({ cookie: { auth, jobId } }) => ({ auth: auth.value, jobId: jobId.value }), {
cookie: "session",
});
const signer = app.decorator.jwt;
// tokens the service must reject
const foreignToken = await new Elysia()
.use(jwt({ name: "jwt", secret: randomUUID() }))
.decorator.jwt.sign({ id: "42" });
const expiredToken = await signer.sign({ id: "42", exp: Math.floor(Date.now() / 1000) - 60 });
// payload violates the { id: string } schema
const wrongSchemaToken = await signer.sign({ id: 42 as unknown as string });
function request(path: string, init: RequestInit & { cookie?: string } = {}) {
const { cookie, headers, ...rest } = init;
return app.handle(
new Request(`http://localhost${path}`, {
...rest,
headers: { ...(cookie ? { cookie } : {}), ...headers },
}),
);
}
const htmlPage = { accept: "text/html" };
const apiCall = { accept: "application/json" };
function expectAuthCookieCleared(response: Response) {
const setCookie = response.headers.get("set-cookie") ?? "";
expect(setCookie).toMatch(/^auth=;/);
expect(setCookie).toContain("Max-Age=0");
}
describe("auth macro with a valid token", () => {
test("resolves the token payload as user", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}` });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ id: "42" });
});
test("issues tokens that expire after seven days", async () => {
const token = await signer.sign({ id: "42" });
const user = (await (await request("/me", { cookie: `auth=${token}` })).json()) as {
exp: number;
iat: number;
};
expect(user.exp - user.iat).toBe(7 * 24 * 60 * 60);
});
test("accepts an optional jobId cookie", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}; jobId=7` });
expect(response.status).toBe(200);
});
test("does not touch the auth cookie", async () => {
const token = await signer.sign({ id: "42" });
const response = await request("/me", { cookie: `auth=${token}` });
expect(response.headers.get("set-cookie")).toBeNull();
});
});
describe("auth macro without a token", () => {
test("redirects HTML page requests to the login page", async () => {
const response = await request("/me", { headers: htmlPage, redirect: "manual" });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
expect(await response.json()).toEqual({ success: false, message: "Redirecting to login" });
});
test("redirects HEAD page requests to the login page", async () => {
const response = await request("/me", { method: "HEAD", headers: htmlPage });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
});
test("returns 401 JSON for API requests", async () => {
const response = await request("/me", { headers: apiCall });
expect(response.status).toBe(401);
expect(response.headers.get("location")).toBeNull();
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
});
test("returns 401 for non-GET requests even if they accept HTML", async () => {
const response = await request("/me", { method: "POST", headers: htmlPage });
expect(response.status).toBe(401);
});
test("treats an empty auth cookie as missing", async () => {
const response = await request("/me", { cookie: "auth=", headers: htmlPage });
expect(response.status).toBe(302);
// there is no session to clear
expect(response.headers.get("set-cookie")).toBeNull();
});
test("does not run the protected handler", async () => {
const callsBefore = protectedHandlerCalls;
await request("/me", { headers: apiCall });
await request("/me", { headers: htmlPage });
expect(protectedHandlerCalls).toBe(callsBefore);
});
});
describe("auth macro with an invalid token", () => {
test.each([
["a malformed token", "not-a-jwt"],
["a token signed with another secret", foreignToken],
["an expired token", expiredToken],
["a token whose payload does not match the schema", wrongSchemaToken],
])("rejects %s and clears the auth cookie", async (_, token) => {
const response = await request("/me", { cookie: `auth=${token}`, headers: apiCall });
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
expectAuthCookieCleared(response);
});
test("redirects HTML page requests to login and clears the auth cookie", async () => {
const response = await request("/me", { cookie: "auth=not-a-jwt", headers: htmlPage });
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
expectAuthCookieCleared(response);
});
});
describe("routes without auth", () => {
test("are reachable without a token", async () => {
const response = await request("/public", { headers: htmlPage });
expect(response.status).toBe(200);
expect(await response.text()).toBe("public");
});
});
describe("models", () => {
const postJson = (body: unknown) =>
request("/sign-in", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
test("signIn accepts an email and a password", async () => {
const response = await postJson({ email: "user@example.com", password: "secret" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ email: "user@example.com", password: "secret" });
});
test.each([
["the password is missing", { email: "user@example.com" }],
["the email is missing", { password: "secret" }],
["the email is not a string", { email: 42, password: "secret" }],
])("signIn rejects the body when %s", async (_, body) => {
const response = await postJson(body);
expect(response.status).toBe(422);
});
test("session requires an auth cookie", async () => {
const response = await request("/session", { cookie: "jobId=3" });
expect(response.status).toBe(422);
});
test("session accepts an auth cookie with an optional jobId", async () => {
const withJob = await request("/session", { cookie: "auth=token; jobId=3" });
const withoutJob = await request("/session", { cookie: "auth=token" });
expect(await withJob.json()).toEqual({ auth: "token", jobId: "3" });
expect(await withoutJob.json()).toEqual({ auth: "token" });
});
});