Fix 9 bugs and add 103 tests for core anonymization, config, and routing (#669)

This commit is contained in:
Thomas Durieux
2026-04-15 09:41:00 +02:00
committed by GitHub
parent 261eaa8d79
commit 188066e91d
23 changed files with 2630 additions and 39 deletions
+444
View File
@@ -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 =
/<?\b((https?|ftp|file):\/\/)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]\b\/?>?/g;
class ContentAnonimizer {
constructor(opt) {
this.opt = opt || {};
this.wasAnonymized = false;
}
removeImage(content) {
if (this.opt.image !== false) {
return content;
}
return content.replace(
/!\[[^\]]*\]\((?<filename>.*?)(?="|\))(?<optionalpart>".*")?\)/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");
});
});
+221
View File
@@ -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;
});
});
+169
View File
@@ -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");
});
});
});
+204
View File
@@ -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);
});
});
+163
View File
@@ -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);
});
});
});
+197
View File
@@ -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);
});
});
});
+179
View File
@@ -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");
});
});
+100
View File
@@ -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");
});
});
});
+323
View File
@@ -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);
});
});
});
+159
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// ---------------------------------------------------------------------------
// 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&amp;b");
});
it("escapes less-than signs", function () {
expect(escapeHtml("<script>")).to.equal("&lt;script&gt;");
});
it("escapes greater-than signs", function () {
expect(escapeHtml("a > b")).to.equal("a &gt; b");
});
it("escapes double quotes", function () {
expect(escapeHtml('say "hello"')).to.equal("say &quot;hello&quot;");
});
it("escapes single quotes", function () {
expect(escapeHtml("it's")).to.equal("it&#039;s");
});
it("handles a string with all special characters", function () {
expect(escapeHtml(`<a href="x" onclick='y'>&`)).to.equal(
"&lt;a href=&quot;x&quot; onclick=&#039;y&#039;&gt;&amp;"
);
});
it("returns an empty string unchanged", function () {
expect(escapeHtml("")).to.equal("");
});
it("returns a string with no special characters unchanged", function () {
expect(escapeHtml("hello world 123")).to.equal("hello world 123");
});
it("prevents XSS via file names in directory listing", function () {
const maliciousName = '<img src=x onerror="alert(1)">';
const escaped = escapeHtml(maliciousName);
expect(escaped).to.not.include("<img");
// The literal string "onerror" still appears in the escaped output,
// but it is no longer an HTML attribute — it is plain text
expect(escaped).to.equal(
'&lt;img src=x onerror=&quot;alert(1)&quot;&gt;'
);
});
it("prevents XSS via script tags in file names", function () {
const maliciousName = '<script>alert("xss")</script>';
const escaped = escapeHtml(maliciousName);
expect(escaped).to.not.include("<script");
});
});
+398
View File
@@ -0,0 +1,398 @@
const { expect } = require("chai");
/**
* Tests for input validation functions used in repository and pull request
* creation/update routes.
*
* Replicates the pure validation logic from:
* - src/server/routes/repository-private.ts (validateNewRepo)
* - src/server/routes/pullRequest-private.ts (validateNewPullRequest)
*/
// ---------------------------------------------------------------------------
// Replicated validation from repository-private.ts
// ---------------------------------------------------------------------------
function validateNewRepo(repoUpdate) {
const validCharacters = /^[0-9a-zA-Z\-_]+$/;
if (
!repoUpdate.repoId.match(validCharacters) ||
repoUpdate.repoId.length < 3
) {
throw new Error("invalid_repoId");
}
if (!repoUpdate.source.branch) {
throw new Error("branch_not_specified");
}
if (!repoUpdate.source.commit) {
throw new Error("commit_not_specified");
}
if (!repoUpdate.options) {
throw new Error("options_not_provided");
}
if (!Array.isArray(repoUpdate.terms)) {
throw new Error("invalid_terms_format");
}
if (!/^[a-fA-F0-9]+$/.test(repoUpdate.source.commit)) {
throw new Error("invalid_commit_format");
}
}
// ---------------------------------------------------------------------------
// Replicated validation from pullRequest-private.ts
// ---------------------------------------------------------------------------
function validateNewPullRequest(pullRequestUpdate) {
const validCharacters = /^[0-9a-zA-Z\-_]+$/;
if (
!pullRequestUpdate.pullRequestId.match(validCharacters) ||
pullRequestUpdate.pullRequestId.length < 3
) {
throw new Error("invalid_pullRequestId");
}
if (!pullRequestUpdate.source.repositoryFullName) {
throw new Error("repository_not_specified");
}
if (!pullRequestUpdate.source.pullRequestId) {
throw new Error("pullRequestId_not_specified");
}
if (
parseInt(pullRequestUpdate.source.pullRequestId) !=
pullRequestUpdate.source.pullRequestId
) {
throw new Error("pullRequestId_is_not_a_number");
}
if (!pullRequestUpdate.options) {
throw new Error("options_not_provided");
}
if (!Array.isArray(pullRequestUpdate.terms)) {
throw new Error("invalid_terms_format");
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function validRepo(overrides = {}) {
return {
repoId: "my-test-repo",
source: { branch: "main", commit: "abc123def" },
options: { terms: ["secret"] },
terms: ["secret"],
...overrides,
};
}
function validPR(overrides = {}) {
return {
pullRequestId: "my-pr-id",
source: { repositoryFullName: "owner/repo", pullRequestId: 42 },
options: { title: true },
terms: ["author"],
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests: validateNewRepo
// ---------------------------------------------------------------------------
describe("validateNewRepo", function () {
describe("repoId validation", function () {
it("accepts valid alphanumeric repoId", function () {
expect(() => 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();
});
});
});