mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-10-28 07:44:21 +01:00
test: add tests for pages
This commit is contained in:
+2
-36
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(""><script>alert(1)</script>.vcf");
|
||||
expect(body).toContain("<b>pending</b>");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(""><img src=x onerror=alert(1)>.csv");
|
||||
expect(body).toContain("<b>Done</b>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user