From 1248009e3061c0d3e0b72aa57ff3fe5bbfdaf6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Krzeslak?= Date: Fri, 2 Oct 2026 14:20:43 +0200 Subject: [PATCH] test: add tests for pages --- bunfig.toml | 1 + tests/db/db.test.ts | 38 +-- tests/pages/chooseConverter.test.ts | 49 ++++ tests/pages/convert.test.ts | 174 ++++++++++++++ tests/pages/deleteFile.test.ts | 93 ++++++++ tests/pages/deleteJob.test.ts | 160 +++++++++++++ tests/pages/download.test.ts | 168 +++++++++++++ tests/pages/firstRun.test.ts | 55 +++++ tests/pages/healthcheck.test.ts | 18 ++ tests/pages/helpers/app.ts | 208 +++++++++++++++++ tests/pages/history.test.ts | 111 +++++++++ tests/pages/listConverters.test.ts | 35 +++ tests/pages/results.test.ts | 175 +++++++++++++- tests/pages/root.test.ts | 156 +++++++++++++ tests/pages/upload.test.ts | 113 +++++++++ tests/pages/user.test.ts | 350 ++++++++++++++++++++++++++++ tests/preload.ts | 25 ++ tests/services/user.test.ts | 212 +++++++++++++++++ 18 files changed, 2104 insertions(+), 37 deletions(-) create mode 100644 tests/pages/chooseConverter.test.ts create mode 100644 tests/pages/convert.test.ts create mode 100644 tests/pages/deleteFile.test.ts create mode 100644 tests/pages/deleteJob.test.ts create mode 100644 tests/pages/download.test.ts create mode 100644 tests/pages/firstRun.test.ts create mode 100644 tests/pages/healthcheck.test.ts create mode 100644 tests/pages/helpers/app.ts create mode 100644 tests/pages/history.test.ts create mode 100644 tests/pages/listConverters.test.ts create mode 100644 tests/pages/root.test.ts create mode 100644 tests/pages/upload.test.ts create mode 100644 tests/pages/user.test.ts create mode 100644 tests/preload.ts create mode 100644 tests/services/user.test.ts diff --git a/bunfig.toml b/bunfig.toml index 082bcacd..d073e330 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,5 @@ [test] +preload = ["./tests/preload.ts"] coverage = true coveragePathIgnorePatterns = [ "tests/converters/helpers/commonTests.ts" diff --git a/tests/db/db.test.ts b/tests/db/db.test.ts index 49ec9420..29a5f903 100644 --- a/tests/db/db.test.ts +++ b/tests/db/db.test.ts @@ -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); diff --git a/tests/pages/chooseConverter.test.ts b/tests/pages/chooseConverter.test.ts new file mode 100644 index 00000000..3042febf --- /dev/null +++ b/tests/pages/chooseConverter.test.ts @@ -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(''); + }); + + 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("'); + }); + + test("returns a fragment, not a full page", async () => { + const body = await conversionsFor("vcf"); + + expect(body).toStartWith(" { + 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); + }); +}); diff --git a/tests/pages/convert.test.ts b/tests/pages/convert.test.ts new file mode 100644 index 00000000..44756a6f --- /dev/null +++ b/tests/pages/convert.test.ts @@ -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("/"); + }); +}); diff --git a/tests/pages/deleteFile.test.ts b/tests/pages/deleteFile.test.ts new file mode 100644 index 00000000..93c6dbe8 --- /dev/null +++ b/tests/pages/deleteFile.test.ts @@ -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); + }); +}); diff --git a/tests/pages/deleteJob.test.ts b/tests/pages/deleteJob.test.ts new file mode 100644 index 00000000..1ef31dbf --- /dev/null +++ b/tests/pages/deleteJob.test.ts @@ -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); + }); +}); diff --git a/tests/pages/download.test.ts b/tests/pages/download.test.ts new file mode 100644 index 00000000..9827b8ec --- /dev/null +++ b/tests/pages/download.test.ts @@ -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) { + 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"); + }); +}); diff --git a/tests/pages/firstRun.test.ts b/tests/pages/firstRun.test.ts new file mode 100644 index 00000000..4665b366 --- /dev/null +++ b/tests/pages/firstRun.test.ts @@ -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(); + }); +}); diff --git a/tests/pages/healthcheck.test.ts b/tests/pages/healthcheck.test.ts new file mode 100644 index 00000000..807a17ce --- /dev/null +++ b/tests/pages/healthcheck.test.ts @@ -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([]); + }); +}); diff --git a/tests/pages/helpers/app.ts b/tests/pages/helpers/app.ts new file mode 100644 index 00000000..ae889483 --- /dev/null +++ b/tests/pages/helpers/app.ts @@ -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) { + mock.module("../../../src/helpers/env", () => ({ ...realEnv, ...overrides })); +} + +export function resetEnv() { + mock.module("../../../src/helpers/env", () => realEnv); +} + +// --- requests -------------------------------------------------------------------- +type RequestOptions = { + method?: string; + cookies?: Record; + headers?: Record; + json?: unknown; + form?: Record; + body?: RequestInit["body"]; +}; + +export function request(path: string, options: RequestOptions = {}) { + const headers: Record = { ...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 { + 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): Promise { + 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"); +} diff --git a/tests/pages/history.test.ts b/tests/pages/history.test.ts new file mode 100644 index 00000000..7e04fdd3 --- /dev/null +++ b/tests/pages/history.test.ts @@ -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("3"); + expect(row).toContain('2'); + expect(row).toContain("pending"); + 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: "pending", + files: [ + { file_name: '">.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('">