diff --git a/eslint.config.mjs b/eslint.config.mjs index d7376a6..5b5f8f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,7 +30,12 @@ export default tseslint.config( after: "readonly", beforeEach: "readonly", afterEach: "readonly", + __dirname: "readonly", + console: "readonly", }, }, + rules: { + "@typescript-eslint/no-unused-expressions": "off", + }, } ); diff --git a/src/config.ts b/src/config.ts index b1c35ee..3889507 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,7 +43,7 @@ const config: Config = { GITHUB_TOKEN: "", DEFAULT_QUOTA: 2 * 1024 * 1024 * 1024 * 8, MAX_FILE_FOLDER: 1000, - MAX_FILE_SIZE: 100 * 1024 * 1024, // in b, 10MB + MAX_FILE_SIZE: 100 * 1024 * 1024, // in b, 100MB MAX_REPO_SIZE: 60000, // in kb, 60MB AUTO_DOWNLOAD_REPO_SIZE: 150, // in kb, 150kb FREE_DOWNLOAD_REPO_SIZE: 150, // in kb, 150kb @@ -80,8 +80,20 @@ const config: Config = { }; for (const conf in process.env) { - if ((config as unknown as Record)[conf] !== undefined) { - (config as unknown as Record)[conf] = process.env[conf]; + const configRecord = config as unknown as Record; + if (configRecord[conf] !== undefined) { + const currentValue = configRecord[conf]; + const envValue = process.env[conf] as string; + if (typeof currentValue === "number") { + const parsed = Number(envValue); + if (!isNaN(parsed)) { + configRecord[conf] = parsed; + } + } else if (typeof currentValue === "boolean") { + configRecord[conf] = envValue === "true" || envValue === "1"; + } else { + configRecord[conf] = envValue; + } } } diff --git a/src/core/Conference.ts b/src/core/Conference.ts index c81cfbb..d2f8199 100644 --- a/src/core/Conference.ts +++ b/src/core/Conference.ts @@ -5,7 +5,7 @@ import { ConferenceStatus } from "./types"; export default class Conference { private _data: IConferenceDocument; - private _repositories: Repository[] = []; + private _repositories: Repository[] | null = null; constructor(data: IConferenceDocument) { this._data = data; diff --git a/src/core/PullRequest.ts b/src/core/PullRequest.ts index 4bdfabd..a1c1757 100644 --- a/src/core/PullRequest.ts +++ b/src/core/PullRequest.ts @@ -98,14 +98,14 @@ export default class PullRequest { /** * Check the status of the pullRequest */ - check() { + async check() { if ( this._model.options.expirationMode !== "never" && this.status == "ready" && this._model.options.expirationDate ) { if (this._model.options.expirationDate <= new Date()) { - this.expire(); + await this.expire(); } } if ( diff --git a/src/core/Repository.ts b/src/core/Repository.ts index aceaf69..3c9a8d2 100644 --- a/src/core/Repository.ts +++ b/src/core/Repository.ts @@ -60,7 +60,6 @@ export default class Repository { constructor(data: IAnonymizedRepositoryDocument) { this._model = data; this.owner = new User(new UserModel({ _id: data.owner })); - this.owner = new User(new UserModel({ _id: data.owner })); this.owner.model.isNew = false; } @@ -169,11 +168,14 @@ export default class Repository { opt.path = await f.originalPath(); } - let pathQuery: string | RegExp | undefined = opt.path - ? new RegExp(`^${opt.path}`) + const escapedPath = opt.path + ? opt.path.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + : undefined; + let pathQuery: string | RegExp | undefined = escapedPath + ? new RegExp(`^${escapedPath}`) : undefined; if (opt.recursive === false) { - pathQuery = opt.path ? new RegExp(`^${opt.path}$`) : ""; + pathQuery = escapedPath ? new RegExp(`^${escapedPath}$`) : ""; } const query: FilterQuery = { @@ -328,6 +330,18 @@ export default class Repository { this.model.source.branch || ghRepo.model.defaultBranch; const newCommit = branches.filter((f) => f.name == branchName)[0] ?.commit; + + if (!newCommit) { + console.error( + `${branchName} for ${this.model.source.repositoryName} is not found` + ); + await this.updateStatus(RepositoryStatus.ERROR, "branch_not_found"); + await this.resetSate(); + throw new AnonymousError("branch_not_found", { + object: this, + httpStatus: 404, + }); + } if ( this.model.source.commit == newCommit && this.status == RepositoryStatus.READY @@ -348,18 +362,6 @@ export default class Repository { this._model.source.commitDate = new Date(d); } this.model.source.commit = newCommit; - - if (!newCommit) { - console.error( - `${branchName} for ${this.model.source.repositoryName} is not found` - ); - await this.updateStatus(RepositoryStatus.ERROR, "branch_not_found"); - await this.resetSate(); - throw new AnonymousError("branch_not_found", { - object: this, - httpStatus: 404, - }); - } this._model.anonymizeDate = new Date(); console.log( `[UPDATE] ${this._model.repoId} will be updated to ${newCommit}` diff --git a/src/core/source/GitHubDownload.ts b/src/core/source/GitHubDownload.ts index 9b11f1d..76424aa 100644 --- a/src/core/source/GitHubDownload.ts +++ b/src/core/source/GitHubDownload.ts @@ -102,8 +102,6 @@ export default class GitHubDownload extends GitHubBase { function humanFileSize(bytes: number, si = false, dp = 1) { const thresh = si ? 1000 : 1024; - bytes = bytes / 8; - if (Math.abs(bytes) < thresh) { return bytes + "B"; } diff --git a/src/core/storage/FileSystem.ts b/src/core/storage/FileSystem.ts index 14f6842..507ec65 100644 --- a/src/core/storage/FileSystem.ts +++ b/src/core/storage/FileSystem.ts @@ -70,8 +70,9 @@ export default class FileSystem extends StorageBase { }); } return await fs.promises.writeFile(fullPath, data, "utf-8"); - } catch { - // write error ignored + } catch (err) { + console.error("[ERROR] FileSystem.write failed:", err); + throw err; } } diff --git a/src/server/routes/admin.ts b/src/server/routes/admin.ts index bc671a8..ddd1ba5 100644 --- a/src/server/routes/admin.ts +++ b/src/server/routes/admin.ts @@ -129,10 +129,10 @@ router.get("/repos", async (req, res) => { } const query = []; if (req.query.search) { - query.push({ repoId: { $regex: req.query.search } }); + const escaped = (req.query.search as string).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + query.push({ repoId: { $regex: escaped } }); } const status: { status: string }[] = []; - query.push({ $or: status }); if (ready) { status.push({ status: "ready" }); } @@ -151,6 +151,9 @@ router.get("/repos", async (req, res) => { status.push({ status: "preparing" }); status.push({ status: "download" }); } + if (status.length > 0) { + query.push({ $or: status }); + } const skipIndex = (page - 1) * limit; const [total, results] = await Promise.all([ AnonymizedRepositoryModel.find({ @@ -199,7 +202,8 @@ router.get("/users", async (req, res) => { } let query = {}; if (req.query.search) { - query = { username: { $regex: req.query.search } }; + const escaped = (req.query.search as string).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + query = { username: { $regex: escaped } }; } res.json({ @@ -270,10 +274,11 @@ router.get("/conferences", async (req, res) => { } let query = {}; if (req.query.search) { + const escaped = (req.query.search as string).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); query = { $or: [ - { name: { $regex: req.query.search } }, - { conferenceID: { $regex: req.query.search } }, + { name: { $regex: escaped } }, + { conferenceID: { $regex: escaped } }, ], }; } diff --git a/src/server/routes/file.ts b/src/server/routes/file.ts index 1bf37e3..b20fbac 100644 --- a/src/server/routes/file.ts +++ b/src/server/routes/file.ts @@ -32,7 +32,7 @@ router.get( try { if (!(await repo.isReady())) { throw new AnonymousError("repository_not_ready", { - object: this, + object: repo, httpStatus: 503, }); } diff --git a/src/server/routes/repository-public.ts b/src/server/routes/repository-public.ts index 1c4b5e2..b7e8523 100644 --- a/src/server/routes/repository-public.ts +++ b/src/server/routes/repository-public.ts @@ -70,7 +70,7 @@ router.get( .on("error", () => { handleError( new AnonymousError("file_not_found", { - object: this, + object: req.params.repoId, httpStatus: 404, }), res diff --git a/src/server/routes/route-utils.ts b/src/server/routes/route-utils.ts index b56463e..379f312 100644 --- a/src/server/routes/route-utils.ts +++ b/src/server/routes/route-utils.ts @@ -20,12 +20,12 @@ export async function getPullRequest( pullRequest.options.expirationMode == "redirect" ) { res.redirect( - `http://github.com/${pullRequest.source.repositoryFullName}/pull/${pullRequest.source.pullRequestId}` + `https://github.com/${pullRequest.source.repositoryFullName}/pull/${pullRequest.source.pullRequestId}` ); return null; } - pullRequest.check(); + await pullRequest.check(); } return pullRequest; } catch (error) { @@ -105,6 +105,8 @@ export function handleError( let errorCode = error; if (error instanceof Error) { errorCode = error.message; + } else if (typeof error !== "string") { + errorCode = String(error); } let status = 500; if (error.httpStatus) { diff --git a/src/server/routes/webview.ts b/src/server/routes/webview.ts index 2ebb34d..d70cd53 100644 --- a/src/server/routes/webview.ts +++ b/src/server/routes/webview.ts @@ -8,6 +8,15 @@ import * as sanitizeHtml from "sanitize-html"; import { streamToString } from "../../core/anonymize-utils"; import { IFile } from "../../core/model/files/files.types"; +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + const sanitizeOptions: sanitizeHtml.IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat([ "img", @@ -120,12 +129,12 @@ async function webView(req: express.Request, res: express.Response) { }); } else { // print list of files in the root repository - const body = `

Content of ${filePath}

${candidates + const body = `

Content of ${escapeHtml(filePath)}

${candidates .map( (c) => `${c.name + (c.size == null ? "/" : "")}` + encodeURI(c.name) + (c.size == null ? "/" : "") + }">${escapeHtml(c.name) + (c.size == null ? "/" : "")}` ) .join("")}
`; const html = `Content${body}`; @@ -142,7 +151,7 @@ async function webView(req: express.Request, res: express.Response) { if (f.extension() == "md") { const content = await streamToString(await f.anonymizedContent()); const body = sanitizeHtml(marked.marked(content, { headerIds: false, mangle: false }), sanitizeOptions); - const html = `Content
${body}
`; + const html = `Content
${body}
`; res.contentType("text/html").send(html); } else { f.send(res); diff --git a/test/anonymize-utils.test.js b/test/anonymize-utils.test.js new file mode 100644 index 0000000..6127153 --- /dev/null +++ b/test/anonymize-utils.test.js @@ -0,0 +1,444 @@ +const { expect } = require("chai"); + +/** + * Tests for the core anonymization utilities. + * + * Because anonymize-utils.ts is TypeScript that imports config (which reads + * process.env at module load time), we replicate the pure logic here so the + * tests run without compiling the full project or connecting to a database. + */ + +// --------------------------------------------------------------------------- +// Minimal replica of the anonymization logic under test +// (mirrors src/core/anonymize-utils.ts) +// --------------------------------------------------------------------------- + +const ANONYMIZATION_MASK = "XXXX"; + +const urlRegex = + /?/g; + +class ContentAnonimizer { + constructor(opt) { + this.opt = opt || {}; + this.wasAnonymized = false; + } + + removeImage(content) { + if (this.opt.image !== false) { + return content; + } + return content.replace( + /!\[[^\]]*\]\((?.*?)(?="|\))(?".*")?\)/g, + () => { + this.wasAnonymized = true; + return ANONYMIZATION_MASK; + } + ); + } + + removeLink(content) { + if (this.opt.link !== false) { + return content; + } + return content.replace(urlRegex, () => { + this.wasAnonymized = true; + return ANONYMIZATION_MASK; + }); + } + + replaceGitHubSelfLinks(content) { + if (!this.opt.repoName || !this.opt.branchName) { + return content; + } + const repoName = this.opt.repoName; + const branchName = this.opt.branchName; + const APP_HOSTNAME = "anonymous.4open.science"; + + const replaceCallback = () => { + this.wasAnonymized = true; + return `https://${APP_HOSTNAME}/r/${this.opt.repoId}`; + }; + content = content.replace( + new RegExp( + `https://raw.githubusercontent.com/${repoName}/${branchName}\\b`, + "gi" + ), + replaceCallback + ); + content = content.replace( + new RegExp( + `https://github.com/${repoName}/blob/${branchName}\\b`, + "gi" + ), + replaceCallback + ); + content = content.replace( + new RegExp( + `https://github.com/${repoName}/tree/${branchName}\\b`, + "gi" + ), + replaceCallback + ); + return content.replace( + new RegExp(`https://github.com/${repoName}`, "gi"), + replaceCallback + ); + } + + replaceTerms(content) { + const terms = this.opt.terms || []; + for (let i = 0; i < terms.length; i++) { + let term = terms[i]; + if (term.trim() == "") { + continue; + } + const mask = ANONYMIZATION_MASK + "-" + (i + 1); + try { + new RegExp(term, "gi"); + } catch { + term = term.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&"); + } + content = content.replace(urlRegex, (match) => { + if (new RegExp(`\\b${term}\\b`, "gi").test(match)) { + this.wasAnonymized = true; + return mask; + } + return match; + }); + content = content.replace(new RegExp(`\\b${term}\\b`, "gi"), () => { + this.wasAnonymized = true; + return mask; + }); + } + return content; + } + + anonymize(content) { + content = this.removeImage(content); + content = this.removeLink(content); + content = this.replaceGitHubSelfLinks(content); + content = this.replaceTerms(content); + return content; + } +} + +function anonymizePath(path, terms) { + for (let i = 0; i < terms.length; i++) { + let term = terms[i]; + if (term.trim() == "") { + continue; + } + try { + new RegExp(term, "gi"); + } catch { + term = term.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&"); + } + path = path.replace( + new RegExp(term, "gi"), + ANONYMIZATION_MASK + "-" + (i + 1) + ); + } + return path; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ContentAnonimizer", function () { + // --------------------------------------------------------------- + // Term replacement + // --------------------------------------------------------------- + describe("replaceTerms", function () { + it("replaces a single term with a numbered mask", function () { + const anon = new ContentAnonimizer({ terms: ["secret"] }); + const result = anon.anonymize("this is a secret value"); + expect(result).to.equal("this is a XXXX-1 value"); + expect(anon.wasAnonymized).to.be.true; + }); + + it("replaces multiple terms with distinct masks", function () { + const anon = new ContentAnonimizer({ terms: ["alice", "bob"] }); + const result = anon.anonymize("alice met bob"); + expect(result).to.equal("XXXX-1 met XXXX-2"); + }); + + it("is case-insensitive", function () { + const anon = new ContentAnonimizer({ terms: ["Secret"] }); + const result = anon.anonymize("a SECRET message and a secret one"); + expect(result).to.not.include("SECRET"); + expect(result).to.not.include("secret"); + }); + + it("respects word boundaries", function () { + const anon = new ContentAnonimizer({ terms: ["cat"] }); + const result = anon.anonymize("the cat sat on a category"); + expect(result).to.include("XXXX-1"); + // "category" should NOT be replaced because \b prevents partial match + expect(result).to.include("category"); + }); + + it("skips empty/whitespace-only terms", function () { + const anon = new ContentAnonimizer({ terms: ["", " ", "real"] }); + const result = anon.anonymize("a real term"); + expect(result).to.equal("a XXXX-3 term"); + }); + + it("handles terms that are invalid regex by escaping them", function () { + const anon = new ContentAnonimizer({ terms: ["foo(bar"] }); + // "foo(bar" is invalid regex; the code should escape it + // Since \b won't match around '(' properly, the replacement may not fire + // on the raw term, but crucially it must not throw + expect(() => anon.anonymize("some foo(bar here")).to.not.throw(); + }); + + it("replaces terms inside URLs", function () { + const anon = new ContentAnonimizer({ terms: ["myuser"] }); + const result = anon.anonymize( + "visit https://github.com/myuser/project for details" + ); + expect(result).to.not.include("myuser"); + }); + + it("does not modify content when no terms provided", function () { + const anon = new ContentAnonimizer({ terms: [] }); + const original = "nothing changes here"; + const result = anon.anonymize(original); + expect(result).to.equal(original); + expect(anon.wasAnonymized).to.be.false; + }); + }); + + // --------------------------------------------------------------- + // Image removal + // --------------------------------------------------------------- + describe("removeImage", function () { + it("removes markdown images when image option is false", function () { + const anon = new ContentAnonimizer({ image: false }); + const result = anon.anonymize("![alt](http://example.com/img.png)"); + expect(result).to.equal(ANONYMIZATION_MASK); + expect(anon.wasAnonymized).to.be.true; + }); + + it("keeps markdown images when image option is true", function () { + const anon = new ContentAnonimizer({ image: true }); + const result = anon.anonymize("![alt](http://example.com/img.png)"); + expect(result).to.include("![alt]"); + }); + + it("keeps markdown images when image option is undefined (default)", function () { + const anon = new ContentAnonimizer({}); + const result = anon.anonymize("![alt](http://example.com/img.png)"); + expect(result).to.include("![alt]"); + }); + + it("removes multiple images in the same content", function () { + const anon = new ContentAnonimizer({ image: false }); + const result = anon.anonymize( + "![a](img1.png) text ![b](img2.jpg)" + ); + expect(result).to.not.include("!["); + }); + }); + + // --------------------------------------------------------------- + // Link removal + // --------------------------------------------------------------- + describe("removeLink", function () { + it("removes URLs when link option is false", function () { + const anon = new ContentAnonimizer({ link: false }); + const result = anon.anonymize("visit https://example.com for info"); + expect(result).to.not.include("https://example.com"); + expect(result).to.include(ANONYMIZATION_MASK); + expect(anon.wasAnonymized).to.be.true; + }); + + it("keeps URLs when link option is true", function () { + const anon = new ContentAnonimizer({ link: true }); + const result = anon.anonymize("visit https://example.com for info"); + expect(result).to.include("https://example.com"); + }); + + it("keeps URLs when link option is undefined (default)", function () { + const anon = new ContentAnonimizer({}); + const result = anon.anonymize("visit https://example.com for info"); + expect(result).to.include("https://example.com"); + }); + + it("removes ftp and file URLs when link is false", function () { + const anon = new ContentAnonimizer({ link: false }); + const result = anon.anonymize( + "ftp://files.example.com/a and file:///home/user/doc" + ); + expect(result).to.not.include("ftp://"); + expect(result).to.not.include("file:///"); + }); + }); + + // --------------------------------------------------------------- + // GitHub self-link replacement + // --------------------------------------------------------------- + describe("replaceGitHubSelfLinks", function () { + it("replaces raw.githubusercontent.com links", function () { + const anon = new ContentAnonimizer({ + repoName: "owner/repo", + branchName: "main", + repoId: "abc123", + }); + const result = anon.anonymize( + "https://raw.githubusercontent.com/owner/repo/main/README.md" + ); + expect(result).to.include("anonymous.4open.science/r/abc123"); + expect(result).to.not.include("raw.githubusercontent.com"); + }); + + it("replaces github.com/blob links", function () { + const anon = new ContentAnonimizer({ + repoName: "owner/repo", + branchName: "main", + repoId: "abc123", + }); + const result = anon.anonymize( + "https://github.com/owner/repo/blob/main/src/file.ts" + ); + expect(result).to.include("anonymous.4open.science/r/abc123"); + }); + + it("replaces github.com/tree links", function () { + const anon = new ContentAnonimizer({ + repoName: "owner/repo", + branchName: "main", + repoId: "abc123", + }); + const result = anon.anonymize( + "https://github.com/owner/repo/tree/main/src" + ); + expect(result).to.include("anonymous.4open.science/r/abc123"); + }); + + it("replaces generic github.com repo links", function () { + const anon = new ContentAnonimizer({ + repoName: "owner/repo", + branchName: "main", + repoId: "abc123", + }); + const result = anon.anonymize("https://github.com/owner/repo"); + expect(result).to.include("anonymous.4open.science/r/abc123"); + }); + + it("is case-insensitive for repo name", function () { + const anon = new ContentAnonimizer({ + repoName: "Owner/Repo", + branchName: "main", + repoId: "abc123", + }); + const result = anon.anonymize( + "https://github.com/owner/repo/blob/main/file" + ); + expect(result).to.include("anonymous.4open.science/r/abc123"); + }); + + it("does not replace when repoName is not set", function () { + const anon = new ContentAnonimizer({ + branchName: "main", + repoId: "abc123", + }); + const original = "https://github.com/owner/repo"; + const result = anon.anonymize(original); + expect(result).to.equal(original); + }); + + it("does not replace when branchName is not set", function () { + const anon = new ContentAnonimizer({ + repoName: "owner/repo", + repoId: "abc123", + }); + const original = "https://github.com/owner/repo/blob/main/file"; + const result = anon.anonymize(original); + expect(result).to.equal(original); + }); + }); + + // --------------------------------------------------------------- + // Combined anonymization + // --------------------------------------------------------------- + describe("anonymize (combined)", function () { + it("applies all transformations in sequence", function () { + const anon = new ContentAnonimizer({ + image: false, + link: false, + terms: ["author"], + repoName: "author/project", + branchName: "main", + repoId: "xyz", + }); + const input = + "by author: ![pic](http://example.com/pic.png) see https://github.com/author/project"; + const result = anon.anonymize(input); + expect(result).to.not.include("author"); + expect(result).to.not.include("![pic]"); + expect(result).to.not.include("example.com"); + }); + + it("sets wasAnonymized to false when nothing changes", function () { + const anon = new ContentAnonimizer({ + image: true, + link: true, + terms: ["nonexistent"], + }); + anon.anonymize("plain text without any matching content"); + expect(anon.wasAnonymized).to.be.false; + }); + }); +}); + +// --------------------------------------------------------------------------- +// anonymizePath +// --------------------------------------------------------------------------- +describe("anonymizePath", function () { + it("replaces a term in a file path", function () { + const result = anonymizePath("src/myproject/index.ts", ["myproject"]); + expect(result).to.equal("src/XXXX-1/index.ts"); + }); + + it("replaces multiple terms with distinct masks", function () { + const result = anonymizePath("owner/repo/file.txt", ["owner", "repo"]); + expect(result).to.equal("XXXX-1/XXXX-2/file.txt"); + }); + + it("is case-insensitive", function () { + const result = anonymizePath("SRC/MyProject/Main.ts", ["myproject"]); + expect(result).to.include("XXXX-1"); + expect(result).to.not.include("MyProject"); + }); + + it("skips empty terms", function () { + const result = anonymizePath("src/project/file.ts", ["", "project"]); + expect(result).to.equal("src/XXXX-2/file.ts"); + }); + + it("handles terms with regex special characters", function () { + const result = anonymizePath("src/my.project/file.ts", ["my.project"]); + // "my.project" is valid regex where . matches any char, so it matches as-is + expect(result).to.include("XXXX-1"); + }); + + it("replaces all occurrences of the same term", function () { + const result = anonymizePath("lib/secret/test/secret/a.js", ["secret"]); + expect(result).to.not.include("secret"); + }); + + it("does not replace partial matches (unlike replaceTerms, anonymizePath has no word boundary)", function () { + // anonymizePath uses term directly in regex without \b, + // so "cat" inside "category" WILL be replaced in paths + const result = anonymizePath("category/cat.txt", ["cat"]); + // Both occurrences should be replaced since there are no word boundaries + expect(result).to.include("XXXX-1"); + }); + + it("returns path unchanged when terms array is empty", function () { + const result = anonymizePath("src/file.ts", []); + expect(result).to.equal("src/file.ts"); + }); +}); diff --git a/test/anonymized-file.test.js b/test/anonymized-file.test.js new file mode 100644 index 0000000..482b894 --- /dev/null +++ b/test/anonymized-file.test.js @@ -0,0 +1,221 @@ +const { expect } = require("chai"); + +/** + * Tests for AnonymizedFile pure logic: extension(), isImage(), + * isFileSupported(). + * + * These methods rely only on the file name / anonymizedPath and + * repository options, so they can be tested without a database. + */ + +// --------------------------------------------------------------------------- +// Replicated logic from src/core/AnonymizedFile.ts +// --------------------------------------------------------------------------- + +function extension(filename) { + const extensions = filename.split(".").reverse(); + return extensions[0].toLowerCase(); +} + +const IMAGE_EXTENSIONS = [ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "ico", + "bmp", + "tiff", + "tif", + "webp", + "avif", + "heif", + "heic", +]; + +function isImage(filename) { + const ext = extension(filename); + return IMAGE_EXTENSIONS.includes(ext); +} + +function isFileSupported(filename, options) { + const ext = extension(filename); + if (!options.pdf && ext === "pdf") { + return false; + } + if (!options.image && isImage(filename)) { + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AnonymizedFile.extension()", function () { + it("extracts a simple extension", function () { + expect(extension("file.txt")).to.equal("txt"); + }); + + it("extracts the last extension from multi-dot files", function () { + expect(extension("archive.tar.gz")).to.equal("gz"); + }); + + it("lowercases the extension", function () { + expect(extension("document.PDF")).to.equal("pdf"); + expect(extension("photo.JpEg")).to.equal("jpeg"); + }); + + it("handles dotfiles", function () { + expect(extension(".gitignore")).to.equal("gitignore"); + }); + + it("handles files with no extension", function () { + // "Makefile".split(".").reverse() → ["Makefile"] + // [0].toLowerCase() → "makefile" + expect(extension("Makefile")).to.equal("makefile"); + }); + + it("handles files with trailing dot", function () { + // "file.".split(".").reverse() → ["", "file"] + expect(extension("file.")).to.equal(""); + }); + + it("handles deeply nested extensions", function () { + expect(extension("a.b.c.d.e.f")).to.equal("f"); + }); + + it("handles uppercase mixed with numbers", function () { + expect(extension("data.JSON5")).to.equal("json5"); + }); +}); + +describe("AnonymizedFile.isImage()", function () { + it("recognizes png as image", function () { + expect(isImage("photo.png")).to.be.true; + }); + + it("recognizes jpg as image", function () { + expect(isImage("photo.jpg")).to.be.true; + }); + + it("recognizes jpeg as image", function () { + expect(isImage("photo.jpeg")).to.be.true; + }); + + it("recognizes gif as image", function () { + expect(isImage("anim.gif")).to.be.true; + }); + + it("recognizes svg as image", function () { + expect(isImage("icon.svg")).to.be.true; + }); + + it("recognizes ico as image", function () { + expect(isImage("favicon.ico")).to.be.true; + }); + + it("recognizes bmp as image", function () { + expect(isImage("old.bmp")).to.be.true; + }); + + it("recognizes tiff as image", function () { + expect(isImage("scan.tiff")).to.be.true; + }); + + it("recognizes tif as image", function () { + expect(isImage("scan.tif")).to.be.true; + }); + + it("recognizes webp as image", function () { + expect(isImage("web.webp")).to.be.true; + }); + + it("recognizes avif as image", function () { + expect(isImage("modern.avif")).to.be.true; + }); + + it("recognizes heif as image", function () { + expect(isImage("apple.heif")).to.be.true; + }); + + it("recognizes heic as image", function () { + expect(isImage("iphone.heic")).to.be.true; + }); + + it("is case-insensitive", function () { + expect(isImage("photo.PNG")).to.be.true; + expect(isImage("photo.Jpg")).to.be.true; + }); + + it("rejects non-image extensions", function () { + expect(isImage("file.txt")).to.be.false; + expect(isImage("file.pdf")).to.be.false; + expect(isImage("file.js")).to.be.false; + expect(isImage("file.html")).to.be.false; + expect(isImage("file.md")).to.be.false; + }); + + it("rejects files containing image extension names but with different ext", function () { + expect(isImage("my-png-converter.exe")).to.be.false; + }); +}); + +describe("AnonymizedFile.isFileSupported()", function () { + it("supports all files when all options are enabled", function () { + const opts = { pdf: true, image: true }; + expect(isFileSupported("file.pdf", opts)).to.be.true; + expect(isFileSupported("file.png", opts)).to.be.true; + expect(isFileSupported("file.txt", opts)).to.be.true; + }); + + it("rejects PDF when pdf option is false", function () { + expect(isFileSupported("file.pdf", { pdf: false, image: true })).to.be + .false; + }); + + it("accepts PDF when pdf option is true", function () { + expect(isFileSupported("file.pdf", { pdf: true, image: true })).to.be.true; + }); + + it("rejects images when image option is false", function () { + expect(isFileSupported("photo.png", { pdf: true, image: false })).to.be + .false; + expect(isFileSupported("photo.jpg", { pdf: true, image: false })).to.be + .false; + expect(isFileSupported("icon.svg", { pdf: true, image: false })).to.be + .false; + }); + + it("accepts images when image option is true", function () { + expect(isFileSupported("photo.png", { pdf: true, image: true })).to.be + .true; + }); + + it("accepts non-image, non-PDF files regardless of options", function () { + expect(isFileSupported("file.js", { pdf: false, image: false })).to.be + .true; + expect(isFileSupported("file.md", { pdf: false, image: false })).to.be + .true; + expect(isFileSupported("file.html", { pdf: false, image: false })).to.be + .true; + }); + + it("rejects both PDF and images when both are disabled", function () { + const opts = { pdf: false, image: false }; + expect(isFileSupported("doc.pdf", opts)).to.be.false; + expect(isFileSupported("pic.png", opts)).to.be.false; + expect(isFileSupported("code.ts", opts)).to.be.true; + }); + + it("is case-insensitive for PDF", function () { + expect(isFileSupported("file.PDF", { pdf: false, image: true })).to.be + .false; + }); + + it("is case-insensitive for images", function () { + expect(isFileSupported("photo.PNG", { pdf: true, image: false })).to.be + .false; + }); +}); diff --git a/test/anonymous-error.test.js b/test/anonymous-error.test.js new file mode 100644 index 0000000..fd930b4 --- /dev/null +++ b/test/anonymous-error.test.js @@ -0,0 +1,169 @@ +const { expect } = require("chai"); + +/** + * Tests for AnonymousError.toString() formatting logic. + * + * The toString() method has branching logic based on the type of the + * `value` property (Repository, AnonymizedFile, GitHubRepository, User, + * GitHubBase, or plain object). We simulate these types to test each + * branch without importing the actual classes. + */ + +// --------------------------------------------------------------------------- +// Simulated AnonymousError +// --------------------------------------------------------------------------- + +class AnonymousError extends Error { + constructor(message, opt) { + super(message); + this.value = opt?.object; + this.httpStatus = opt?.httpStatus; + this.cause = opt?.cause; + } + + toString() { + let out = ""; + let detail = this.value ? JSON.stringify(this.value) : null; + + // Simulate the instanceof checks with duck typing + if (this.value && this.value.__type === "Repository") { + detail = this.value.repoId; + } else if (this.value && this.value.__type === "AnonymizedFile") { + detail = `/r/${this.value.repository.repoId}/${this.value.anonymizedPath}`; + } else if (this.value && this.value.__type === "GitHubRepository") { + detail = `${this.value.fullName}`; + } else if (this.value && this.value.__type === "User") { + detail = `${this.value.username}`; + } else if (this.value && this.value.__type === "GitHubBase") { + detail = `GHDownload ${this.value.data.repoId}`; + } + + out += this.message; + if (detail) { + out += `: ${detail}`; + } + if (this.cause) { + out += `\n\tCause by ${this.cause}\n${this.cause.stack}`; + } + return out; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AnonymousError.toString()", function () { + describe("message formatting", function () { + it("outputs the error message", function () { + const err = new AnonymousError("repo_not_found"); + expect(err.toString()).to.equal("repo_not_found"); + }); + + it("outputs message with httpStatus on the object", function () { + const err = new AnonymousError("repo_not_found", { httpStatus: 404 }); + expect(err.httpStatus).to.equal(404); + expect(err.toString()).to.equal("repo_not_found"); + }); + }); + + describe("detail from value types", function () { + it("formats Repository value as repoId", function () { + const err = new AnonymousError("error", { + object: { __type: "Repository", repoId: "my-anon-repo" }, + }); + expect(err.toString()).to.equal("error: my-anon-repo"); + }); + + it("formats AnonymizedFile value as /r/{repoId}/{path}", function () { + const err = new AnonymousError("file_not_found", { + object: { + __type: "AnonymizedFile", + repository: { repoId: "abc123" }, + anonymizedPath: "src/XXXX-1/file.ts", + }, + }); + expect(err.toString()).to.equal( + "file_not_found: /r/abc123/src/XXXX-1/file.ts" + ); + }); + + it("formats GitHubRepository value as fullName", function () { + const err = new AnonymousError("error", { + object: { __type: "GitHubRepository", fullName: "owner/repo" }, + }); + expect(err.toString()).to.equal("error: owner/repo"); + }); + + it("formats User value as username", function () { + const err = new AnonymousError("error", { + object: { __type: "User", username: "jdoe" }, + }); + expect(err.toString()).to.equal("error: jdoe"); + }); + + it("formats GitHubBase value as GHDownload {repoId}", function () { + const err = new AnonymousError("error", { + object: { + __type: "GitHubBase", + data: { repoId: "download-123" }, + }, + }); + expect(err.toString()).to.equal("error: GHDownload download-123"); + }); + + it("formats plain object as JSON.stringify", function () { + const err = new AnonymousError("error", { + object: { key: "value" }, + }); + expect(err.toString()).to.equal('error: {"key":"value"}'); + }); + + it("formats string value as JSON string", function () { + const err = new AnonymousError("error", { + object: "some-id", + }); + expect(err.toString()).to.equal('error: "some-id"'); + }); + + it("formats number value", function () { + const err = new AnonymousError("error", { + object: 42, + }); + expect(err.toString()).to.equal("error: 42"); + }); + }); + + describe("null/undefined value", function () { + it("outputs only message when value is null", function () { + const err = new AnonymousError("error", { object: null }); + expect(err.toString()).to.equal("error"); + }); + + it("outputs only message when value is undefined", function () { + const err = new AnonymousError("error", { object: undefined }); + expect(err.toString()).to.equal("error"); + }); + + it("outputs only message when no opt is passed", function () { + const err = new AnonymousError("error"); + expect(err.toString()).to.equal("error"); + }); + }); + + describe("cause formatting", function () { + it("includes cause message and stack when cause is present", function () { + const cause = new Error("original error"); + const err = new AnonymousError("wrapper", { cause }); + const str = err.toString(); + expect(str).to.include("wrapper"); + expect(str).to.include("Cause by"); + expect(str).to.include("original error"); + }); + + it("omits cause section when no cause", function () { + const err = new AnonymousError("error", { object: "test" }); + expect(err.toString()).to.not.include("Cause by"); + }); + }); +}); diff --git a/test/conference-pricing.test.js b/test/conference-pricing.test.js new file mode 100644 index 0000000..4b75634 --- /dev/null +++ b/test/conference-pricing.test.js @@ -0,0 +1,204 @@ +const { expect } = require("chai"); + +/** + * Tests for Conference.toJSON() price calculation logic. + * + * Replicates the pricing algorithm from src/core/Conference.ts to test + * the math in isolation without needing MongoDB. + */ + +// --------------------------------------------------------------------------- +// Replicated price calculation from Conference.toJSON() +// --------------------------------------------------------------------------- + +function calculatePrice(plan, repositories, endDate) { + const pricePerHourPerRepo = plan.pricePerRepository / 30; + let price = 0; + const today = new Date() > endDate ? endDate : new Date(); + + repositories.forEach((r) => { + const removeDate = + r.removeDate && r.removeDate < today ? r.removeDate : today; + price += + (Math.max(removeDate.getTime() - r.addDate.getTime(), 0) / + 1000 / + 60 / + 60 / + 24) * + pricePerHourPerRepo; + }); + return price; +} + +function countActiveRepos(repositories) { + return repositories.filter((r) => !r.removeDate).length; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Conference price calculation", function () { + const DAY_MS = 24 * 60 * 60 * 1000; + + describe("basic pricing", function () { + it("returns 0 for no repositories", function () { + const price = calculatePrice( + { pricePerRepository: 3 }, + [], + new Date(Date.now() + 30 * DAY_MS) + ); + expect(price).to.equal(0); + }); + + it("returns 0 for free plan", function () { + const addDate = new Date(Date.now() - 10 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 0 }, + [{ addDate }], + new Date(Date.now() + 30 * DAY_MS) + ); + expect(price).to.equal(0); + }); + + it("calculates price for one repo over 10 days", function () { + const now = new Date(); + const addDate = new Date(now.getTime() - 10 * DAY_MS); + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate }], + endDate + ); + // pricePerHourPerRepo = 3/30 = 0.1 per day + // duration ≈ 10 days + // price ≈ 10 * 0.1 = 1.0 + expect(price).to.be.closeTo(1.0, 0.01); + }); + + it("calculates price for multiple repos", function () { + const now = new Date(); + const addDate1 = new Date(now.getTime() - 10 * DAY_MS); + const addDate2 = new Date(now.getTime() - 5 * DAY_MS); + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate: addDate1 }, { addDate: addDate2 }], + endDate + ); + // repo1: 10 days * 0.1 = 1.0 + // repo2: 5 days * 0.1 = 0.5 + // total ≈ 1.5 + expect(price).to.be.closeTo(1.5, 0.01); + }); + }); + + describe("removed repositories", function () { + it("uses removeDate as end for removed repos", function () { + const now = new Date(); + const addDate = new Date(now.getTime() - 10 * DAY_MS); + const removeDate = new Date(now.getTime() - 5 * DAY_MS); + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate, removeDate }], + endDate + ); + // Only charged for 5 days (add to remove), not 10 + // 5 * 0.1 = 0.5 + expect(price).to.be.closeTo(0.5, 0.01); + }); + + it("uses today if removeDate is in the future", function () { + const now = new Date(); + const addDate = new Date(now.getTime() - 10 * DAY_MS); + const removeDate = new Date(now.getTime() + 5 * DAY_MS); // future + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate, removeDate }], + endDate + ); + // removeDate is in the future, so today is used instead + // ≈ 10 days * 0.1 = 1.0 + expect(price).to.be.closeTo(1.0, 0.01); + }); + }); + + describe("expired conference", function () { + it("caps at endDate when conference is expired", function () { + const endDate = new Date(Date.now() - 5 * DAY_MS); // 5 days ago + const addDate = new Date(endDate.getTime() - 10 * DAY_MS); // 15 days ago + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate }], + endDate + ); + // Conference ended 5 days ago, repo was added 10 days before that + // Only charged for 10 days (add to end) + // 10 * 0.1 = 1.0 + expect(price).to.be.closeTo(1.0, 0.01); + }); + }); + + describe("edge cases", function () { + it("handles zero-duration repository (add and remove same time)", function () { + const now = new Date(); + const addDate = new Date(now.getTime() - 5 * DAY_MS); + const removeDate = addDate; // same time + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate, removeDate }], + endDate + ); + expect(price).to.equal(0); + }); + + it("handles removeDate before addDate via Math.max", function () { + const now = new Date(); + const addDate = new Date(now.getTime() - 5 * DAY_MS); + const removeDate = new Date(now.getTime() - 10 * DAY_MS); // before addDate + const endDate = new Date(now.getTime() + 20 * DAY_MS); + const price = calculatePrice( + { pricePerRepository: 3 }, + [{ addDate, removeDate }], + endDate + ); + // Math.max ensures negative duration becomes 0 + expect(price).to.equal(0); + }); + }); +}); + +describe("Conference active repository count", function () { + it("counts repos without removeDate", function () { + const repos = [ + { addDate: new Date() }, + { addDate: new Date(), removeDate: new Date() }, + { addDate: new Date() }, + ]; + expect(countActiveRepos(repos)).to.equal(2); + }); + + it("returns 0 when all repos are removed", function () { + const repos = [ + { addDate: new Date(), removeDate: new Date() }, + { addDate: new Date(), removeDate: new Date() }, + ]; + expect(countActiveRepos(repos)).to.equal(0); + }); + + it("returns 0 for empty list", function () { + expect(countActiveRepos([])).to.equal(0); + }); + + it("counts all repos when none are removed", function () { + const repos = [ + { addDate: new Date() }, + { addDate: new Date() }, + { addDate: new Date() }, + ]; + expect(countActiveRepos(repos)).to.equal(3); + }); +}); diff --git a/test/conference.test.js b/test/conference.test.js new file mode 100644 index 0000000..d4c0a86 --- /dev/null +++ b/test/conference.test.js @@ -0,0 +1,163 @@ +const { expect } = require("chai"); + +/** + * Tests for Conference logic bugs. + * + * The key bug was that Conference._repositories was initialized as [] + * (truthy), so repositories() always returned the empty array without + * querying the database. The fix initializes it as null. + */ + +describe("Conference._repositories initialization", function () { + it("empty array [] is truthy (demonstrates the root cause)", function () { + // This is why `if (this._repositories) return this._repositories;` + // was always short-circuiting - an empty array is truthy in JS + expect([]).to.not.be.null; + expect([]).to.not.be.undefined; + // In a boolean context, [] is truthy: + expect(!![]).to.be.true; + }); + + it("null is falsy (the fix)", function () { + // After the fix, _repositories starts as null so the DB query runs + expect(!!null).to.be.false; + }); + + it("simulates the fixed repositories() cache behavior", function () { + // Simulate the Conference class behavior + class FakeConference { + constructor() { + this._repositories = null; // fixed: was [] + } + repositories() { + if (this._repositories) return this._repositories; + // In real code this would query the DB + this._repositories = [{ id: "repo1" }, { id: "repo2" }]; + return this._repositories; + } + } + + const conf = new FakeConference(); + const repos = conf.repositories(); + expect(repos).to.have.length(2); + expect(repos[0].id).to.equal("repo1"); + + // Second call uses the cache + const repos2 = conf.repositories(); + expect(repos2).to.equal(repos); // same reference + }); + + it("demonstrates the old buggy behavior (always returned empty array)", function () { + class BuggyConference { + constructor() { + this._repositories = []; // old buggy initialization + } + repositories() { + if (this._repositories) return this._repositories; + // This line was NEVER reached because [] is truthy + this._repositories = [{ id: "repo1" }]; + return this._repositories; + } + } + + const conf = new BuggyConference(); + const repos = conf.repositories(); + // The bug: always returns empty array, DB query never runs + expect(repos).to.have.length(0); + }); +}); + +describe("PullRequest.check() async expiration", function () { + it("async check() allows awaiting expire()", async function () { + // Simulates the fix: check() is now async so expire() can be awaited + let expired = false; + const fakePR = { + status: "ready", + options: { + expirationMode: "date", + expirationDate: new Date(Date.now() - 1000), // in the past + }, + async expire() { + expired = true; + this.status = "expired"; + }, + async check() { + if ( + this.options.expirationMode !== "never" && + this.status === "ready" && + this.options.expirationDate + ) { + if (this.options.expirationDate <= new Date()) { + await this.expire(); + } + } + }, + }; + + await fakePR.check(); + expect(expired).to.be.true; + expect(fakePR.status).to.equal("expired"); + }); +}); + +describe("Admin MongoDB query safety", function () { + function escapeRegex(str) { + return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + + it("escapes regex special characters in search input", function () { + const malicious = ".*"; + const escaped = escapeRegex(malicious); + expect(escaped).to.equal("\\.\\*"); + }); + + it("escapes parentheses that could cause ReDoS", function () { + const input = "((((((((((a]))))))))"; + const escaped = escapeRegex(input); + // Escaped string should be safe to compile as regex + expect(() => new RegExp(escaped)).to.not.throw(); + }); + + it("preserves alphanumeric characters", function () { + const input = "normalSearch123"; + expect(escapeRegex(input)).to.equal("normalSearch123"); + }); + + it("escapes dots so they match literally", function () { + const input = "file.txt"; + const escaped = escapeRegex(input); + const regex = new RegExp(escaped); + expect(regex.test("file.txt")).to.be.true; + expect(regex.test("fileXtxt")).to.be.false; + }); + + describe("empty $or guard", function () { + it("empty $or array would fail in MongoDB", function () { + // MongoDB requires $or to have at least one expression + // The fix: only add { $or: status } when status.length > 0 + const status = []; + const query = []; + + // Fixed logic: + if (status.length > 0) { + query.push({ $or: status }); + } + + // When no filters are selected, query should be empty + // (no $or clause at all) + expect(query).to.have.length(0); + }); + + it("adds $or when status filters are present", function () { + const status = [{ status: "ready" }, { status: "error" }]; + const query = []; + + if (status.length > 0) { + query.push({ $or: status }); + } + + expect(query).to.have.length(1); + expect(query[0].$or).to.have.length(2); + }); + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..7de0f8d --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,197 @@ +const { expect } = require("chai"); + +/** + * Tests for the config environment variable parsing logic. + * + * The config module reads process.env at load time, so we replicate the + * parsing logic here to test it in isolation. This verifies the fix for the + * bug where numeric and boolean config values were being overwritten with + * strings from process.env. + */ + +function parseConfigFromEnv(defaults, env) { + const config = { ...defaults }; + for (const conf in env) { + if (config[conf] !== undefined) { + const currentValue = config[conf]; + const envValue = env[conf]; + if (typeof currentValue === "number") { + const parsed = Number(envValue); + if (!isNaN(parsed)) { + config[conf] = parsed; + } + } else if (typeof currentValue === "boolean") { + config[conf] = envValue === "true" || envValue === "1"; + } else { + config[conf] = envValue; + } + } + } + return config; +} + +describe("Config environment variable parsing", function () { + const defaults = { + PORT: 5000, + REDIS_PORT: 6379, + MAX_FILE_SIZE: 100 * 1024 * 1024, + MAX_REPO_SIZE: 60000, + ENABLE_DOWNLOAD: true, + RATE_LIMIT: 350, + TRUST_PROXY: 1, + SESSION_SECRET: "SESSION_SECRET", + CLIENT_ID: "CLIENT_ID", + APP_HOSTNAME: "anonymous.4open.science", + STORAGE: "filesystem", + }; + + // --------------------------------------------------------------- + // Number coercion + // --------------------------------------------------------------- + describe("numeric values", function () { + it("parses PORT from string to number", function () { + const config = parseConfigFromEnv(defaults, { PORT: "3000" }); + expect(config.PORT).to.equal(3000); + expect(config.PORT).to.be.a("number"); + }); + + it("parses REDIS_PORT from string to number", function () { + const config = parseConfigFromEnv(defaults, { REDIS_PORT: "6380" }); + expect(config.REDIS_PORT).to.equal(6380); + expect(config.REDIS_PORT).to.be.a("number"); + }); + + it("parses MAX_FILE_SIZE from string to number", function () { + const config = parseConfigFromEnv(defaults, { + MAX_FILE_SIZE: "52428800", + }); + expect(config.MAX_FILE_SIZE).to.equal(52428800); + expect(config.MAX_FILE_SIZE).to.be.a("number"); + }); + + it("parses RATE_LIMIT from string to number", function () { + const config = parseConfigFromEnv(defaults, { RATE_LIMIT: "100" }); + expect(config.RATE_LIMIT).to.equal(100); + }); + + it("ignores NaN values and keeps the default", function () { + const config = parseConfigFromEnv(defaults, { PORT: "not-a-number" }); + expect(config.PORT).to.equal(5000); + }); + + it("handles zero correctly", function () { + const config = parseConfigFromEnv(defaults, { TRUST_PROXY: "0" }); + expect(config.TRUST_PROXY).to.equal(0); + expect(config.TRUST_PROXY).to.be.a("number"); + }); + + it("handles negative numbers", function () { + const config = parseConfigFromEnv(defaults, { TRUST_PROXY: "-1" }); + expect(config.TRUST_PROXY).to.equal(-1); + }); + + it("correctly compares parsed numbers (no string comparison bug)", function () { + const config = parseConfigFromEnv(defaults, { MAX_REPO_SIZE: "150" }); + // The critical test: "150" > "60000" is true in string comparison + // but 150 > 60000 is false in number comparison + expect(config.MAX_REPO_SIZE).to.be.a("number"); + expect(config.MAX_REPO_SIZE < 60000).to.be.true; + }); + }); + + // --------------------------------------------------------------- + // Boolean coercion + // --------------------------------------------------------------- + describe("boolean values", function () { + it('parses "true" to boolean true', function () { + const config = parseConfigFromEnv(defaults, { + ENABLE_DOWNLOAD: "true", + }); + expect(config.ENABLE_DOWNLOAD).to.equal(true); + expect(config.ENABLE_DOWNLOAD).to.be.a("boolean"); + }); + + it('parses "false" to boolean false', function () { + const config = parseConfigFromEnv(defaults, { + ENABLE_DOWNLOAD: "false", + }); + expect(config.ENABLE_DOWNLOAD).to.equal(false); + expect(config.ENABLE_DOWNLOAD).to.be.a("boolean"); + }); + + it('parses "1" to boolean true', function () { + const config = parseConfigFromEnv(defaults, { + ENABLE_DOWNLOAD: "1", + }); + expect(config.ENABLE_DOWNLOAD).to.equal(true); + }); + + it('parses "0" to boolean false', function () { + const config = parseConfigFromEnv(defaults, { + ENABLE_DOWNLOAD: "0", + }); + expect(config.ENABLE_DOWNLOAD).to.equal(false); + }); + + it("parses arbitrary string to boolean false", function () { + const config = parseConfigFromEnv(defaults, { + ENABLE_DOWNLOAD: "yes", + }); + expect(config.ENABLE_DOWNLOAD).to.equal(false); + }); + }); + + // --------------------------------------------------------------- + // String values + // --------------------------------------------------------------- + describe("string values", function () { + it("overwrites string config with env string", function () { + const config = parseConfigFromEnv(defaults, { + SESSION_SECRET: "my-secret-key", + }); + expect(config.SESSION_SECRET).to.equal("my-secret-key"); + }); + + it("overwrites APP_HOSTNAME", function () { + const config = parseConfigFromEnv(defaults, { + APP_HOSTNAME: "my.domain.com", + }); + expect(config.APP_HOSTNAME).to.equal("my.domain.com"); + }); + + it("overwrites STORAGE", function () { + const config = parseConfigFromEnv(defaults, { STORAGE: "s3" }); + expect(config.STORAGE).to.equal("s3"); + }); + }); + + // --------------------------------------------------------------- + // Unknown keys + // --------------------------------------------------------------- + describe("unknown keys", function () { + it("ignores environment variables not in defaults", function () { + const config = parseConfigFromEnv(defaults, { + UNKNOWN_VAR: "some-value", + }); + expect(config.UNKNOWN_VAR).to.be.undefined; + }); + }); + + // --------------------------------------------------------------- + // Multiple overrides + // --------------------------------------------------------------- + describe("multiple overrides at once", function () { + it("applies all overrides correctly", function () { + const config = parseConfigFromEnv(defaults, { + PORT: "8080", + ENABLE_DOWNLOAD: "false", + SESSION_SECRET: "new-secret", + MAX_REPO_SIZE: "120000", + }); + expect(config.PORT).to.equal(8080); + expect(config.ENABLE_DOWNLOAD).to.equal(false); + expect(config.SESSION_SECRET).to.equal("new-secret"); + expect(config.MAX_REPO_SIZE).to.equal(120000); + }); + }); +}); diff --git a/test/database-storage.test.js b/test/database-storage.test.js new file mode 100644 index 0000000..460569e --- /dev/null +++ b/test/database-storage.test.js @@ -0,0 +1,179 @@ +const { expect } = require("chai"); +const { join } = require("path"); + +/** + * Tests for database input validation guards and Storage.repoPath(), + * plus the extractZip decodeString path-stripping logic. + * + * The database functions (getRepository, getPullRequest) validate their + * input before querying MongoDB. We replicate those guards here. + * Storage.repoPath() is a pure function we can test directly. + */ + +// --------------------------------------------------------------------------- +// Replicated database input validation +// --------------------------------------------------------------------------- + +function validateRepoId(repoId) { + if (!repoId || repoId == "undefined") { + throw new Error("repo_not_found"); + } +} + +function validatePullRequestId(pullRequestId) { + if (!pullRequestId || pullRequestId == "undefined") { + throw new Error("pull_request_not_found"); + } +} + +// --------------------------------------------------------------------------- +// Replicated Storage.repoPath() from src/core/storage/Storage.ts +// --------------------------------------------------------------------------- + +function repoPath(repoId) { + return join(repoId, "original") + "/"; +} + +// --------------------------------------------------------------------------- +// Replicated extractZip decodeString from FileSystem.ts +// --------------------------------------------------------------------------- + +function decodeZipEntryName(name) { + const newName = name.substr(name.indexOf("/") + 1); + if (newName == "") { + return "___IGNORE___"; + } + return newName; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Database input validation", function () { + describe("getRepository guard", function () { + it("rejects null repoId", function () { + expect(() => validateRepoId(null)).to.throw("repo_not_found"); + }); + + it("rejects undefined repoId", function () { + expect(() => validateRepoId(undefined)).to.throw("repo_not_found"); + }); + + it('rejects the string "undefined"', function () { + expect(() => validateRepoId("undefined")).to.throw("repo_not_found"); + }); + + it("rejects empty string", function () { + expect(() => validateRepoId("")).to.throw("repo_not_found"); + }); + + it("accepts a valid repoId string", function () { + expect(() => validateRepoId("my-repo-123")).to.not.throw(); + }); + + it("accepts a numeric-looking repoId", function () { + expect(() => validateRepoId("12345")).to.not.throw(); + }); + }); + + describe("getPullRequest guard", function () { + it("rejects null pullRequestId", function () { + expect(() => validatePullRequestId(null)).to.throw( + "pull_request_not_found" + ); + }); + + it("rejects undefined pullRequestId", function () { + expect(() => validatePullRequestId(undefined)).to.throw( + "pull_request_not_found" + ); + }); + + it('rejects the string "undefined"', function () { + expect(() => validatePullRequestId("undefined")).to.throw( + "pull_request_not_found" + ); + }); + + it("rejects empty string", function () { + expect(() => validatePullRequestId("")).to.throw( + "pull_request_not_found" + ); + }); + + it("accepts a valid pullRequestId string", function () { + expect(() => validatePullRequestId("my-pr-42")).to.not.throw(); + }); + }); +}); + +describe("Storage.repoPath()", function () { + it("returns {repoId}/original/ for a simple repoId", function () { + expect(repoPath("abc123")).to.equal("abc123/original/"); + }); + + it("joins with path separator", function () { + const result = repoPath("my-repo"); + expect(result).to.equal("my-repo/original/"); + }); + + it("handles repoId with hyphens", function () { + expect(repoPath("my-anon-repo")).to.equal("my-anon-repo/original/"); + }); + + it("handles repoId with underscores", function () { + expect(repoPath("repo_123")).to.equal("repo_123/original/"); + }); + + it("always ends with a forward slash", function () { + expect(repoPath("test").endsWith("/")).to.be.true; + }); + + it("always includes 'original' subdirectory", function () { + expect(repoPath("any-repo")).to.include("/original"); + }); +}); + +describe("extractZip decodeString (path stripping)", function () { + it("strips the root folder prefix from zip entries", function () { + // GitHub zip archives have a root folder like "owner-repo-commitsha/" + expect(decodeZipEntryName("owner-repo-abc123/src/file.ts")).to.equal( + "src/file.ts" + ); + }); + + it("strips only the first path component", function () { + expect(decodeZipEntryName("root/a/b/c.txt")).to.equal("a/b/c.txt"); + }); + + it('returns ___IGNORE___ for root directory entry (trailing /)', function () { + // Root folder entry is like "owner-repo-abc123/" + // After substr(indexOf("/")+1), the result is "" + expect(decodeZipEntryName("owner-repo-abc123/")).to.equal("___IGNORE___"); + }); + + it("handles files directly under root", function () { + expect(decodeZipEntryName("root/README.md")).to.equal("README.md"); + }); + + it("handles deeply nested paths", function () { + expect(decodeZipEntryName("root/a/b/c/d/e/f.txt")).to.equal( + "a/b/c/d/e/f.txt" + ); + }); + + it("handles entry with no slash (file at root level)", function () { + // If there's no "/", indexOf returns -1, substr(0) returns the whole string + expect(decodeZipEntryName("justfile.txt")).to.equal("justfile.txt"); + }); + + it("handles entry that is just a slash", function () { + expect(decodeZipEntryName("/")).to.equal("___IGNORE___"); + }); + + it("preserves the rest of the path structure", function () { + const result = decodeZipEntryName("prefix/src/components/App.tsx"); + expect(result).to.equal("src/components/App.tsx"); + }); +}); diff --git a/test/humanFileSize.test.js b/test/humanFileSize.test.js new file mode 100644 index 0000000..6f4f62c --- /dev/null +++ b/test/humanFileSize.test.js @@ -0,0 +1,100 @@ +const { expect } = require("chai"); + +/** + * Tests for the humanFileSize utility function used in download progress + * reporting. + * + * Replicates the fixed version of the function from + * src/core/source/GitHubDownload.ts to verify correctness. + */ + +function humanFileSize(bytes, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + "B"; + } + + const units = si + ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while ( + Math.round(Math.abs(bytes) * r) / r >= thresh && + u < units.length - 1 + ); + + return bytes.toFixed(dp) + "" + units[u]; +} + +describe("humanFileSize", function () { + describe("binary units (default, si=false)", function () { + it("returns bytes for values below 1024", function () { + expect(humanFileSize(500)).to.equal("500B"); + }); + + it("returns 0B for zero", function () { + expect(humanFileSize(0)).to.equal("0B"); + }); + + it("converts 1024 bytes to 1.0KiB", function () { + expect(humanFileSize(1024)).to.equal("1.0KiB"); + }); + + it("converts 1 MiB correctly", function () { + expect(humanFileSize(1024 * 1024)).to.equal("1.0MiB"); + }); + + it("converts 1 GiB correctly", function () { + expect(humanFileSize(1024 * 1024 * 1024)).to.equal("1.0GiB"); + }); + + it("converts 1.5 MiB correctly", function () { + expect(humanFileSize(1.5 * 1024 * 1024)).to.equal("1.5MiB"); + }); + + it("converts 10 MiB correctly", function () { + expect(humanFileSize(10 * 1024 * 1024)).to.equal("10.0MiB"); + }); + + it("does not divide bytes by 8 (regression test for bytes/8 bug)", function () { + // 8 MiB = 8388608 bytes + // The old buggy code would divide by 8 first, showing ~1 MiB + const result = humanFileSize(8 * 1024 * 1024); + expect(result).to.equal("8.0MiB"); + }); + }); + + describe("SI units (si=true)", function () { + it("returns bytes for values below 1000", function () { + expect(humanFileSize(999, true)).to.equal("999B"); + }); + + it("converts 1000 bytes to 1.0kB", function () { + expect(humanFileSize(1000, true)).to.equal("1.0kB"); + }); + + it("converts 1 MB correctly", function () { + expect(humanFileSize(1000 * 1000, true)).to.equal("1.0MB"); + }); + }); + + describe("decimal places", function () { + it("uses 1 decimal place by default", function () { + expect(humanFileSize(1536)).to.equal("1.5KiB"); + }); + + it("supports 0 decimal places", function () { + expect(humanFileSize(1536, false, 0)).to.equal("2KiB"); + }); + + it("supports 2 decimal places", function () { + expect(humanFileSize(1536, false, 2)).to.equal("1.50KiB"); + }); + }); +}); diff --git a/test/pull-request-content.test.js b/test/pull-request-content.test.js new file mode 100644 index 0000000..6e34a2e --- /dev/null +++ b/test/pull-request-content.test.js @@ -0,0 +1,323 @@ +const { expect } = require("chai"); + +/** + * Tests for PullRequest.content() option-based filtering logic. + * + * The content() method selectively includes fields in the output based on + * which options (title, body, comments, username, date, diff, origin) are + * enabled. We replicate this logic with a simplified anonymizer to test + * the filtering in isolation. + */ + +// --------------------------------------------------------------------------- +// Simplified anonymizer (mirrors ContentAnonimizer) +// --------------------------------------------------------------------------- + +function makeAnonymizer(terms) { + return { + anonymize(content) { + let result = content; + (terms || []).forEach((term, i) => { + if (term.trim() === "") return; + const mask = "XXXX-" + (i + 1); + result = result.replace(new RegExp(`\\b${term}\\b`, "gi"), mask); + }); + return result; + }, + }; +} + +// --------------------------------------------------------------------------- +// Replicated PullRequest.content() logic +// --------------------------------------------------------------------------- + +function buildPRContent(pullRequest, options, terms) { + const anonymizer = makeAnonymizer(terms); + const output = { + anonymizeDate: pullRequest.anonymizeDate, + merged: pullRequest.merged, + mergedDate: pullRequest.mergedDate, + state: pullRequest.state, + draft: pullRequest.draft, + }; + + if (options.title) { + output.title = anonymizer.anonymize(pullRequest.title); + } + if (options.body) { + output.body = anonymizer.anonymize(pullRequest.body); + } + if (options.comments) { + output.comments = (pullRequest.comments || []).map((comment) => { + const o = {}; + if (options.body) o.body = anonymizer.anonymize(comment.body); + if (options.username) o.author = anonymizer.anonymize(comment.author); + if (options.date) { + o.updatedDate = comment.updatedDate; + o.creationDate = comment.creationDate; + } + return o; + }); + } + if (options.diff) { + output.diff = anonymizer.anonymize(pullRequest.diff); + } + if (options.origin) { + output.baseRepositoryFullName = pullRequest.baseRepositoryFullName; + } + if (options.date) { + output.updatedDate = pullRequest.updatedDate; + output.creationDate = pullRequest.creationDate; + } + return output; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const samplePR = { + anonymizeDate: new Date("2024-01-15"), + merged: true, + mergedDate: new Date("2024-01-14"), + state: "closed", + draft: false, + title: "Fix bug in AuthorModule by Alice", + body: "Alice fixed the AuthorModule which was broken.", + diff: "--- a/AuthorModule.ts\n+++ b/AuthorModule.ts\n-broken code by Alice", + baseRepositoryFullName: "alice/project", + updatedDate: new Date("2024-01-14"), + creationDate: new Date("2024-01-10"), + comments: [ + { + body: "Good fix, Alice!", + author: "Alice", + updatedDate: new Date("2024-01-12"), + creationDate: new Date("2024-01-11"), + }, + { + body: "LGTM", + author: "Bob", + updatedDate: new Date("2024-01-13"), + creationDate: new Date("2024-01-13"), + }, + ], +}; + +const terms = ["Alice", "AuthorModule"]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("PullRequest.content()", function () { + describe("always-included fields", function () { + it("always includes merged, mergedDate, state, draft", function () { + const result = buildPRContent(samplePR, {}, []); + expect(result.merged).to.be.true; + expect(result.mergedDate).to.deep.equal(new Date("2024-01-14")); + expect(result.state).to.equal("closed"); + expect(result.draft).to.be.false; + expect(result.anonymizeDate).to.deep.equal(new Date("2024-01-15")); + }); + }); + + describe("title option", function () { + it("includes anonymized title when enabled", function () { + const result = buildPRContent(samplePR, { title: true }, terms); + expect(result.title).to.exist; + expect(result.title).to.not.include("Alice"); + expect(result.title).to.not.include("AuthorModule"); + expect(result.title).to.include("XXXX-1"); + expect(result.title).to.include("XXXX-2"); + }); + + it("omits title when disabled", function () { + const result = buildPRContent(samplePR, { title: false }, terms); + expect(result.title).to.be.undefined; + }); + }); + + describe("body option", function () { + it("includes anonymized body when enabled", function () { + const result = buildPRContent(samplePR, { body: true }, terms); + expect(result.body).to.exist; + expect(result.body).to.not.include("Alice"); + }); + + it("omits body when disabled", function () { + const result = buildPRContent(samplePR, { body: false }, terms); + expect(result.body).to.be.undefined; + }); + }); + + describe("comments option", function () { + it("includes comments array when enabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, body: true, username: true, date: true }, + terms + ); + expect(result.comments).to.be.an("array").with.length(2); + }); + + it("omits comments when disabled", function () { + const result = buildPRContent(samplePR, { comments: false }, terms); + expect(result.comments).to.be.undefined; + }); + + it("anonymizes comment body when body option is enabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, body: true }, + terms + ); + expect(result.comments[0].body).to.not.include("Alice"); + expect(result.comments[0].body).to.include("XXXX-1"); + }); + + it("omits comment body when body option is disabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, body: false }, + terms + ); + expect(result.comments[0].body).to.be.undefined; + }); + + it("anonymizes comment author when username option is enabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, username: true }, + terms + ); + expect(result.comments[0].author).to.not.include("Alice"); + }); + + it("omits comment author when username option is disabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, username: false }, + terms + ); + expect(result.comments[0].author).to.be.undefined; + }); + + it("includes comment dates when date option is enabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, date: true }, + terms + ); + expect(result.comments[0].creationDate).to.deep.equal( + new Date("2024-01-11") + ); + expect(result.comments[0].updatedDate).to.deep.equal( + new Date("2024-01-12") + ); + }); + + it("omits comment dates when date option is disabled", function () { + const result = buildPRContent( + samplePR, + { comments: true, date: false }, + terms + ); + expect(result.comments[0].creationDate).to.be.undefined; + expect(result.comments[0].updatedDate).to.be.undefined; + }); + }); + + describe("diff option", function () { + it("includes anonymized diff when enabled", function () { + const result = buildPRContent(samplePR, { diff: true }, terms); + expect(result.diff).to.exist; + expect(result.diff).to.not.include("Alice"); + expect(result.diff).to.not.include("AuthorModule"); + }); + + it("omits diff when disabled", function () { + const result = buildPRContent(samplePR, { diff: false }, terms); + expect(result.diff).to.be.undefined; + }); + }); + + describe("origin option", function () { + it("includes baseRepositoryFullName when enabled", function () { + const result = buildPRContent(samplePR, { origin: true }, terms); + expect(result.baseRepositoryFullName).to.equal("alice/project"); + }); + + it("omits baseRepositoryFullName when disabled", function () { + const result = buildPRContent(samplePR, { origin: false }, terms); + expect(result.baseRepositoryFullName).to.be.undefined; + }); + }); + + describe("date option", function () { + it("includes root-level dates when enabled", function () { + const result = buildPRContent(samplePR, { date: true }, terms); + expect(result.updatedDate).to.deep.equal(new Date("2024-01-14")); + expect(result.creationDate).to.deep.equal(new Date("2024-01-10")); + }); + + it("omits root-level dates when disabled", function () { + const result = buildPRContent(samplePR, { date: false }, terms); + expect(result.updatedDate).to.be.undefined; + expect(result.creationDate).to.be.undefined; + }); + }); + + describe("all options enabled", function () { + it("includes all fields, all anonymized", function () { + const result = buildPRContent( + samplePR, + { + title: true, + body: true, + comments: true, + username: true, + date: true, + diff: true, + origin: true, + }, + terms + ); + expect(result.title).to.exist; + expect(result.body).to.exist; + expect(result.comments).to.be.an("array"); + expect(result.diff).to.exist; + expect(result.baseRepositoryFullName).to.exist; + expect(result.updatedDate).to.exist; + expect(result.creationDate).to.exist; + // All sensitive terms should be masked + expect(result.title).to.not.include("Alice"); + expect(result.body).to.not.include("Alice"); + expect(result.diff).to.not.include("Alice"); + }); + }); + + describe("all options disabled", function () { + it("only includes always-present fields", function () { + const result = buildPRContent(samplePR, {}, terms); + expect(result.merged).to.exist; + expect(result.state).to.exist; + expect(result.draft).to.exist; + expect(result.title).to.be.undefined; + expect(result.body).to.be.undefined; + expect(result.comments).to.be.undefined; + expect(result.diff).to.be.undefined; + expect(result.baseRepositoryFullName).to.be.undefined; + expect(result.updatedDate).to.be.undefined; + expect(result.creationDate).to.be.undefined; + }); + }); + + describe("empty comments", function () { + it("returns empty array when PR has no comments", function () { + const pr = { ...samplePR, comments: [] }; + const result = buildPRContent(pr, { comments: true, body: true }, terms); + expect(result.comments).to.be.an("array").with.length(0); + }); + }); +}); diff --git a/test/route-utils.test.js b/test/route-utils.test.js new file mode 100644 index 0000000..1a7c82b --- /dev/null +++ b/test/route-utils.test.js @@ -0,0 +1,159 @@ +const { expect } = require("chai"); + +/** + * Tests for route utility functions. + * + * We replicate the handleError status-code logic and escapeHtml utility + * here so we can test them without starting the Express server or + * connecting to a database. + */ + +// --------------------------------------------------------------------------- +// Replicated handleError status derivation logic +// (mirrors src/server/routes/route-utils.ts) +// --------------------------------------------------------------------------- + +function deriveHttpStatus(error) { + let message = error; + if (error instanceof Error) { + message = error.message; + } else if (typeof error !== "string") { + message = String(error); + } + let status = 500; + if (error.httpStatus) { + status = error.httpStatus; + } else if (error.$metadata?.httpStatusCode) { + status = error.$metadata.httpStatusCode; + } else if ( + message && + (message.indexOf("not_found") > -1 || message.indexOf("(Not Found)") > -1) + ) { + status = 404; + } else if (message && message.indexOf("not_connected") > -1) { + status = 401; + } + return status; +} + +// --------------------------------------------------------------------------- +// Replicated escapeHtml from webview.ts +// --------------------------------------------------------------------------- + +function escapeHtml(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("deriveHttpStatus", function () { + it("returns 500 for a generic error", function () { + const status = deriveHttpStatus(new Error("something broke")); + expect(status).to.equal(500); + }); + + it("uses httpStatus when present on the error", function () { + const err = new Error("bad request"); + err.httpStatus = 400; + expect(deriveHttpStatus(err)).to.equal(400); + }); + + it("uses $metadata.httpStatusCode for AWS-style errors", function () { + const err = { $metadata: { httpStatusCode: 403 }, message: "forbidden" }; + expect(deriveHttpStatus(err)).to.equal(403); + }); + + it("returns 404 when message contains not_found", function () { + expect(deriveHttpStatus(new Error("repo_not_found"))).to.equal(404); + }); + + it("returns 404 when message contains (Not Found)", function () { + expect(deriveHttpStatus(new Error("GitHub (Not Found)"))).to.equal(404); + }); + + it("returns 401 when message contains not_connected", function () { + expect(deriveHttpStatus(new Error("not_connected"))).to.equal(401); + }); + + it("prefers httpStatus over message-based detection", function () { + const err = new Error("not_found"); + err.httpStatus = 503; + expect(deriveHttpStatus(err)).to.equal(503); + }); + + it("handles plain string error", function () { + expect(deriveHttpStatus("repo_not_found")).to.equal(404); + }); + + it("handles string error for not_connected", function () { + expect(deriveHttpStatus("not_connected")).to.equal(401); + }); + + it("returns status from httpStatus on a plain object", function () { + expect(deriveHttpStatus({ httpStatus: 429 })).to.equal(429); + }); + + it("returns 500 for a plain object without httpStatus", function () { + expect(deriveHttpStatus({})).to.equal(500); + }); +}); + +describe("escapeHtml", function () { + it("escapes ampersands", function () { + expect(escapeHtml("a&b")).to.equal("a&b"); + }); + + it("escapes less-than signs", function () { + expect(escapeHtml("'; + const escaped = escapeHtml(maliciousName); + expect(escaped).to.not.include(" validateNewRepo(validRepo())).to.not.throw(); + }); + + it("accepts repoId with hyphens and underscores", function () { + expect(() => + validateNewRepo(validRepo({ repoId: "my-test_repo-123" })) + ).to.not.throw(); + }); + + it("rejects repoId shorter than 3 characters", function () { + expect(() => validateNewRepo(validRepo({ repoId: "ab" }))).to.throw( + "invalid_repoId" + ); + }); + + it("accepts repoId of exactly 3 characters", function () { + expect(() => + validateNewRepo(validRepo({ repoId: "abc" })) + ).to.not.throw(); + }); + + it("rejects repoId with spaces", function () { + expect(() => + validateNewRepo(validRepo({ repoId: "my repo" })) + ).to.throw("invalid_repoId"); + }); + + it("rejects repoId with special characters", function () { + expect(() => + validateNewRepo(validRepo({ repoId: "repo@name" })) + ).to.throw("invalid_repoId"); + expect(() => + validateNewRepo(validRepo({ repoId: "repo/name" })) + ).to.throw("invalid_repoId"); + expect(() => + validateNewRepo(validRepo({ repoId: "repo.name" })) + ).to.throw("invalid_repoId"); + }); + + it("rejects empty repoId", function () { + expect(() => validateNewRepo(validRepo({ repoId: "" }))).to.throw( + "invalid_repoId" + ); + }); + }); + + describe("source.branch validation", function () { + it("accepts a valid branch", function () { + expect(() => validateNewRepo(validRepo())).to.not.throw(); + }); + + it("rejects missing branch", function () { + expect(() => + validateNewRepo( + validRepo({ source: { branch: "", commit: "abc123" } }) + ) + ).to.throw("branch_not_specified"); + }); + + it("rejects null branch", function () { + expect(() => + validateNewRepo( + validRepo({ source: { branch: null, commit: "abc123" } }) + ) + ).to.throw("branch_not_specified"); + }); + }); + + describe("source.commit validation", function () { + it("accepts valid hex commit", function () { + expect(() => validateNewRepo(validRepo())).to.not.throw(); + }); + + it("accepts full 40-character SHA", function () { + expect(() => + validateNewRepo( + validRepo({ + source: { + branch: "main", + commit: "abc123def456789012345678901234567890abcd", + }, + }) + ) + ).to.not.throw(); + }); + + it("accepts uppercase hex", function () { + expect(() => + validateNewRepo( + validRepo({ + source: { branch: "main", commit: "ABCDEF1234" }, + }) + ) + ).to.not.throw(); + }); + + it("rejects missing commit", function () { + expect(() => + validateNewRepo( + validRepo({ source: { branch: "main", commit: "" } }) + ) + ).to.throw("commit_not_specified"); + }); + + it("rejects non-hex commit", function () { + expect(() => + validateNewRepo( + validRepo({ + source: { branch: "main", commit: "not-a-hex-value" }, + }) + ) + ).to.throw("invalid_commit_format"); + }); + + it("rejects commit with spaces", function () { + expect(() => + validateNewRepo( + validRepo({ + source: { branch: "main", commit: "abc 123" }, + }) + ) + ).to.throw("invalid_commit_format"); + }); + + it("rejects commit with g-z letters", function () { + expect(() => + validateNewRepo( + validRepo({ + source: { branch: "main", commit: "xyz123" }, + }) + ) + ).to.throw("invalid_commit_format"); + }); + }); + + describe("options validation", function () { + it("accepts valid options", function () { + expect(() => validateNewRepo(validRepo())).to.not.throw(); + }); + + it("rejects missing options", function () { + expect(() => + validateNewRepo(validRepo({ options: null })) + ).to.throw("options_not_provided"); + }); + + it("rejects undefined options", function () { + expect(() => + validateNewRepo(validRepo({ options: undefined })) + ).to.throw("options_not_provided"); + }); + }); + + describe("terms validation", function () { + it("accepts array of terms", function () { + expect(() => validateNewRepo(validRepo())).to.not.throw(); + }); + + it("accepts empty array", function () { + expect(() => + validateNewRepo(validRepo({ terms: [] })) + ).to.not.throw(); + }); + + it("rejects string terms", function () { + expect(() => + validateNewRepo(validRepo({ terms: "not-an-array" })) + ).to.throw("invalid_terms_format"); + }); + + it("rejects null terms", function () { + expect(() => + validateNewRepo(validRepo({ terms: null })) + ).to.throw("invalid_terms_format"); + }); + + it("rejects object terms", function () { + expect(() => + validateNewRepo(validRepo({ terms: { 0: "term" } })) + ).to.throw("invalid_terms_format"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: validateNewPullRequest +// --------------------------------------------------------------------------- + +describe("validateNewPullRequest", function () { + describe("pullRequestId validation", function () { + it("accepts valid alphanumeric pullRequestId", function () { + expect(() => validateNewPullRequest(validPR())).to.not.throw(); + }); + + it("accepts pullRequestId with hyphens and underscores", function () { + expect(() => + validateNewPullRequest(validPR({ pullRequestId: "my-pr_123" })) + ).to.not.throw(); + }); + + it("rejects pullRequestId shorter than 3 characters", function () { + expect(() => + validateNewPullRequest(validPR({ pullRequestId: "ab" })) + ).to.throw("invalid_pullRequestId"); + }); + + it("rejects pullRequestId with special characters", function () { + expect(() => + validateNewPullRequest(validPR({ pullRequestId: "pr@name" })) + ).to.throw("invalid_pullRequestId"); + }); + }); + + describe("source.repositoryFullName validation", function () { + it("accepts valid repository full name", function () { + expect(() => validateNewPullRequest(validPR())).to.not.throw(); + }); + + it("rejects missing repositoryFullName", function () { + expect(() => + validateNewPullRequest( + validPR({ + source: { repositoryFullName: "", pullRequestId: 42 }, + }) + ) + ).to.throw("repository_not_specified"); + }); + }); + + describe("source.pullRequestId validation", function () { + it("accepts numeric pullRequestId", function () { + expect(() => validateNewPullRequest(validPR())).to.not.throw(); + }); + + it("rejects missing source pullRequestId", function () { + expect(() => + validateNewPullRequest( + validPR({ + source: { + repositoryFullName: "owner/repo", + pullRequestId: 0, + }, + }) + ) + ).to.throw("pullRequestId_not_specified"); + }); + + it("rejects non-numeric string pullRequestId", function () { + expect(() => + validateNewPullRequest( + validPR({ + source: { + repositoryFullName: "owner/repo", + pullRequestId: "abc", + }, + }) + ) + ).to.throw("pullRequestId_is_not_a_number"); + }); + + it("accepts numeric string that parseInt can parse", function () { + // parseInt("123") == "123" is true due to JS type coercion + expect(() => + validateNewPullRequest( + validPR({ + source: { + repositoryFullName: "owner/repo", + pullRequestId: "123", + }, + }) + ) + ).to.not.throw(); + }); + }); + + describe("options and terms validation", function () { + it("rejects missing options", function () { + expect(() => + validateNewPullRequest(validPR({ options: null })) + ).to.throw("options_not_provided"); + }); + + it("rejects non-array terms", function () { + expect(() => + validateNewPullRequest(validPR({ terms: "not-array" })) + ).to.throw("invalid_terms_format"); + }); + + it("accepts empty terms array", function () { + expect(() => + validateNewPullRequest(validPR({ terms: [] })) + ).to.not.throw(); + }); + }); +});