const { setTimeout, setImmediate } = require("timers"); const { expect } = require("chai"); require("ts-node/register/transpile-only"); const { Readable } = require("stream"); const { once } = require("events"); const config = require("../src/config").default; const db = require("../src/server/database"); const gh = require("../src/core/GitHubUtils"); const RepoModel = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default; const Repository = require("../src/core/Repository").default; const GitHubStream = require("../src/core/source/GitHubStream").default; const { ContentAnonimizer, AnonymizeTransformer, anonymizePath } = require("../src/core/anonymize-utils"); async function collect(stream) { const chunks = []; for await (const chunk of stream) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks).toString(); } describe("production regressions", function () { const restores = []; function stub(object, key, value) { const old = object[key]; restores.push(() => { object[key] = old; }); object[key] = value; } afterEach(() => { while (restores.length) restores.pop()(); }); it("masks literal names across the retained-suffix boundary", async function () { const input = "xx Alice " + "z".repeat(4091); const options = { filePath: "a.txt", terms: ["Alice"], image: true, link: true }; for (const split of [1, 5, 4096, input.length]) { const transformer = new AnonymizeTransformer(options); const output = collect(transformer); for (let i = 0; i < input.length; i += split) transformer.write(Buffer.from(input.slice(i, i + split))); transformer.end(); expect(await output).to.equal(new ContentAnonimizer(options).anonymize(input)); } }); it("executes adjacent repeated patterns without backtracking", function () { const start = Date.now(); const text = "a".repeat(250); expect(new ContentAnonimizer({ terms: ["a+a+a+a+a+a+b"] }).anonymize(text)).to.equal(text); expect(anonymizePath(text, ["a+a+a+a+a+a+b"])).to.equal(text); expect(Date.now() - start).to.be.lessThan(1000); }); it("enforces the text buffering limit without emitting source bytes", async function () { stub(config, "MAX_FILE_SIZE", 8); const transformer = new AnonymizeTransformer({ filePath: "a.txt", terms: ["Alice"] }); let emitted = false; transformer.on("data", () => { emitted = true; }); const error = once(transformer, "error"); transformer.end(Buffer.from("Alice Alice")); expect((await error)[0].message).to.include("exceeded"); expect(emitted).to.equal(false); }); for (const status of ["removing", "removed", "expiring", "expired"]) { it(`ignores delayed downloads for ${status} repositories`, async function () { stub(db, "connect", async () => {}); stub(db, "getRepository", async () => ({ status })); stub(gh, "getToken", async () => { throw new Error("must not download"); }); await require("../src/queue/processes/downloadRepository").default({ data: { repoId: "repo" } }); }); it(`does not let an active worker overwrite concurrent ${status}`, async function () { stub(db, "isConnected", true); const model = new RepoModel({ repoId: "repo", status: "download", anonymizeDate: new Date(1000) }); const repo = new Repository(model); repo.protectLifecycle = true; stub(RepoModel, "updateOne", filter => ({ exec: async () => ({ matchedCount: require("sift").default(filter)({ _id: model._id, anonymizeDate: model.anonymizeDate, status }) ? 1 : 0 }) })); try { await repo.updateStatus("ready"); throw new Error("expected rejection"); } catch (error) { expect(error.message).to.equal("repository_job_cancelled"); } expect(repo.status).to.equal("download"); }); } it("denies another GitHub identity access through a reused coauthor username", async function () { const User = require("../src/core/User").default; const UserModel = require("../src/core/model/users/users.model").default; const user = new User(new UserModel({ username: "old-name", externalIDs: { github: "new-id" } })); stub(RepoModel, "find", filter => { expect(require("sift").default(filter)({ owner: "someone", coauthors: [{ username: "old-name", githubId: "old-id" }] })).to.equal(false); expect(require("sift").default(filter)({ owner: "someone", coauthors: [{ username: "renamed", githubId: "new-id" }] })).to.equal(true); return { exec: async () => [] }; }); expect(await user.getRepositories()).to.deep.equal([]); }); it("does not link a recycled OAuth username to an existing GitHub identity", async function () { require("../src/server/routes/connection"); const passport = require("passport"); const UserModel = require("../src/core/model/users/users.model").default; let calls = 0; stub(UserModel, "findOne", async () => ++calls === 1 ? null : { externalIDs: { github: "old-id" }, isAdmin: true }); stub(UserModel, "updateOne", () => { throw new Error("must not overwrite identity"); }); const error = await new Promise(resolve => passport._strategy("github")._verify("token", "", { id: "new-id", username: "recycled" }, resolve)); expect(error.message).to.equal("not_connected"); }); it("checks GitHub authorization before returning shared cached metadata", async function () { const CachedRepoModel = require("../src/core/model/repositories/repositories.model").default; stub(db, "isConnected", true); stub(CachedRepoModel, "findOne", async () => ({ name: "owner/private", branches: [{ readme: "secret" }] })); stub(gh, "octokit", () => ({ repos: { get: async () => { throw Object.assign(new Error("Forbidden"), { status: 403 }); } } })); try { await require("../src/core/source/GitHubRepository").getRepositoryFromGitHub({ owner: "owner", repo: "private", accessToken: "unauthorized" }); throw new Error("expected rejection"); } catch (error) { expect(error.message).to.equal("token_expired"); } }); it("omits an upstream length when later text is rewritten", async function () { const File = require("../src/core/AnonymizedFile").default; stub(config, "STREAMER_ENTRYPOINT", ""); const repo = new Repository(new RepoModel({ repoId: "repo", options: { terms: ["Alice"], image: true, link: true }, source: {} })); const file = new File({ repository: repo, anonymizedPath: "file.txt" }); const input = "z".repeat(9000) + " Alice"; file._file = { name: "file.txt", path: "", size: input.length }; file.content = async () => Readable.from([Buffer.from(input.slice(0, 5000)), Buffer.from(input.slice(5000))]); const res = new (require("stream").PassThrough)(); const headers = {}; res.header = (key, value) => { headers[key] = value; return res; }; res.contentType = () => res; const output = collect(res); await file.send(res); expect(await output).to.equal("z".repeat(9000) + " XXXX-1"); expect(headers).not.to.have.property("Content-Length"); }); it("fails closed when a JavaScript-only pattern exceeds its execution deadline", function () { this.timeout(3000); const anonymizer = new ContentAnonimizer({ terms: ["a+a+a+a+a+a+b(?=x)"] }); expect(() => anonymizer.anonymize("a".repeat(250))).to.throw(/timed out/); }); });