+ + {error ? : null} + + + Email + + + + Password + + + + + {ACCOUNT_REGISTRATION ? ( + + Register + + ) : null} + + + +
+ + {error ? : null} + + + Email + + + + Password (leave blank for unchanged) + + + + Current Password + + + + + + + +
- - - - Email - - - - Password - - - - - {ACCOUNT_REGISTRATION ? ( - - Register - - ) : null} - - - -
{ + 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..1da8f61f --- /dev/null +++ b/tests/pages/deleteFile.test.ts @@ -0,0 +1,100 @@ +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"); + + const response = await request("/delete", { + cookies: { auth: user.token, jobId: job }, + json: { filename: `../../${other.user.id}/${other.job}/keep.txt` }, + }); + + // the request passed the session and job checks, so the file name was really used; + // the sanitized name does not exist, which runs into the bug tracked below + expect(response.status).not.toBeOneOf([302, 401]); + 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. This also + // happens for every sanitized traversal attempt. Remove `.failing` once deleteFile.tsx + // handles the missing file. + test.failing.each(["missing.txt", "../../1/1/keep.txt"])( + "answers a missing file (%s) without a server error or server paths", + async (filename) => { + const { user, job } = await userWithUploads(); + + const response = await request("/delete", { + cookies: { auth: user.token, jobId: job }, + json: { filename }, + }); + + 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 index 51841938..9827b8ec 100644 --- a/tests/pages/download.test.ts +++ b/tests/pages/download.test.ts @@ -1,90 +1,168 @@ -import { jwt } from "@elysiajs/jwt"; -import { expect, test } from "bun:test"; -import { Elysia } from "elysia"; -import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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 db from "../../src/db/db"; -import { download } from "../../src/pages/download"; +import { createJob, createUser, jobDirs, request } from "./helpers/app"; -test("download route blocks symlinks and serves normal files", async () => { - const testUserId = 998; - const testUserEmail = `download-test-${Date.now()}@example.com`; - - db.query("INSERT INTO users (id, email, password) VALUES (?, ?, ?)").run( - testUserId, - testUserEmail, - "hashed_pwd", - ); - - const res = db - .query( - "INSERT INTO jobs (user_id, date_created, status, num_files) VALUES (?, ?, ?, ?) RETURNING id", - ) - .get(testUserId, new Date().toISOString(), "completed", 1) as { id: number }; - const jobId = String(res.id); - - const jobDir = `./data/output/${testUserId}/${jobId}`; - mkdirSync(jobDir, { recursive: true }); - writeFileSync(`${jobDir}/normal.txt`, "safe content"); - symlinkSync("/etc/passwd", `${jobDir}/symlink.txt`); +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 tokenApp = new Elysia() - .use(jwt({ name: "jwt", secret: process.env.JWT_SECRET ?? "secret", exp: "7d" })) - .get("/token", async ({ jwt }) => jwt.sign({ id: String(testUserId) })); - const tokenRes = await tokenApp.handle(new Request("http://localhost/token")); - const token = await tokenRes.text(); + 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 }); + } +} - const app = new Elysia().use(download); +describe("GET /download/:userId/:jobId/:fileName", () => { + test("redirects to the login page without a session", async () => { + const { user, job } = await userWithOutputs({ "a.csv": "a" }); - // 1. Downloading normal file must succeed (200) - const normalRes = await app.handle( - new Request(`http://localhost/download/${testUserId}/${jobId}/normal.txt`, { - headers: { cookie: `auth=${token}` }, - }), - ); - expect(normalRes.status).toBe(200); - expect(await normalRes.text()).toBe("safe content"); - - // 2. Downloading a symlink must be blocked (403) - const symlinkRes = await app.handle( - new Request(`http://localhost/download/${testUserId}/${jobId}/symlink.txt`, { - headers: { cookie: `auth=${token}` }, - }), - ); - expect(symlinkRes.status).toBe(403); - const body = (await symlinkRes.json()) as { message?: string }; - expect(body.message).toBe("Access denied."); - - // 3. Downloading non-existent file must return 404 - const notFoundRes = await app.handle( - new Request(`http://localhost/download/${testUserId}/${jobId}/nonexistent.txt`, { - headers: { cookie: `auth=${token}` }, - }), - ); - expect(notFoundRes.status).toBe(404); - - // 4. Archive tarball must exclude symlinks - const archiveRes = await app.handle( - new Request(`http://localhost/archive/${jobId}`, { - headers: { cookie: `auth=${token}` }, - }), - ); - expect(archiveRes.status).toBe(200); - - const tarEntries: string[] = []; - await tar.t({ - file: `${jobDir}/converted_files_${jobId}.tar`, - onReadEntry: (e) => { - tarEntries.push(e.path); - }, + const response = await request(`/download/${user.id}/${job}/a.csv`, { + headers: { accept: "text/html" }, }); - expect(tarEntries).toContain("./normal.txt"); - expect(tarEntries).not.toContain("./symlink.txt"); - } finally { - rmSync(`./data/output/${testUserId}`, { recursive: true, force: true }); - db.query("DELETE FROM jobs WHERE id = ?").run(jobId); - db.query("DELETE FROM users WHERE id = ?").run(testUserId); - } + 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('">