mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-04-21 21:06:01 +02:00
Fix 9 bugs and add 103 tests for core anonymization, config, and routing (#669)
This commit is contained in:
@@ -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("");
|
||||
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("");
|
||||
expect(result).to.include("![alt]");
|
||||
});
|
||||
|
||||
it("keeps markdown images when image option is undefined (default)", function () {
|
||||
const anon = new ContentAnonimizer({});
|
||||
const result = anon.anonymize("");
|
||||
expect(result).to.include("![alt]");
|
||||
});
|
||||
|
||||
it("removes multiple images in the same content", function () {
|
||||
const anon = new ContentAnonimizer({ image: false });
|
||||
const result = anon.anonymize(
|
||||
" text "
|
||||
);
|
||||
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:  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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, """)
|
||||
.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("<script>")).to.equal("<script>");
|
||||
});
|
||||
|
||||
it("escapes greater-than signs", function () {
|
||||
expect(escapeHtml("a > b")).to.equal("a > b");
|
||||
});
|
||||
|
||||
it("escapes double quotes", function () {
|
||||
expect(escapeHtml('say "hello"')).to.equal("say "hello"");
|
||||
});
|
||||
|
||||
it("escapes single quotes", function () {
|
||||
expect(escapeHtml("it's")).to.equal("it's");
|
||||
});
|
||||
|
||||
it("handles a string with all special characters", function () {
|
||||
expect(escapeHtml(`<a href="x" onclick='y'>&`)).to.equal(
|
||||
"<a href="x" onclick='y'>&"
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<img src=x onerror="alert(1)">'
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user