mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
180 lines
9.0 KiB
JavaScript
180 lines
9.0 KiB
JavaScript
const { expect } = require("chai");
|
|
require("ts-node/register/transpile-only");
|
|
const { createOwnerCredentialRecovery } = require("../src/core/recover-owner-credential");
|
|
const { migrateCredentials } = require("../src/core/migrate-credentials");
|
|
const { createTokenCipher } = require("../src/core/credential-crypto");
|
|
|
|
describe("owner credential recovery", function () {
|
|
this.timeout(5000);
|
|
it("rejects a shared admin token and deduplicates the owner's token", async () => {
|
|
const calls = [];
|
|
const recover = createOwnerCredentialRecovery(async token => {
|
|
calls.push(token);
|
|
return { githubId: token === "admin" ? "9" : "42" };
|
|
});
|
|
expect(await recover(42, ["admin", "owner", "owner"])).to.deep.equal({ token: "owner" });
|
|
expect(await recover("9", ["admin", "owner"])).to.deep.equal({ token: "admin" });
|
|
expect(calls).to.deep.equal(["admin", "owner"]);
|
|
});
|
|
it("does not choose between distinct valid owner tokens", async () => {
|
|
const recover = createOwnerCredentialRecovery(async () => ({ githubId: "42" }));
|
|
expect(await recover("42", ["old", "new"])).to.deep.equal({ issue: "multiple_valid_owner_tokens" });
|
|
});
|
|
it("ignores revoked tokens but does not recover a mismatched identity", async () => {
|
|
const recover = createOwnerCredentialRecovery(async token => token === "revoked"
|
|
? { issue: "invalid_or_revoked_token" } : { githubId: "9" });
|
|
expect(await recover("42", ["revoked", "admin"])).to.deep.equal({ issue: "no_valid_owner_token" });
|
|
});
|
|
it("does not call GitHub without a usable recorded ID", async () => {
|
|
const recover = createOwnerCredentialRecovery(async () => { throw new Error("must not call"); });
|
|
for (const id of [undefined, null, "", "login", 0, -1]) {
|
|
expect(await recover(id, ["token"])).to.deep.equal({ issue: "missing_or_invalid_owner_github_id" });
|
|
}
|
|
});
|
|
it("halts on a failed request even after a matching token", async () => {
|
|
const recover = createOwnerCredentialRecovery(async token => token === "owner"
|
|
? { githubId: "42" } : { issue: "github_http_403", halt: true });
|
|
expect(await recover("42", ["owner", "unknown"])).to.deep.equal({ issue: "github_http_403", halt: true });
|
|
});
|
|
it("redacts thrown request errors", async () => {
|
|
const recover = createOwnerCredentialRecovery(async () => { throw new Error("secret"); });
|
|
expect(await recover("42", ["secret"])).to.deep.equal({ issue: "github_request_failed", halt: true });
|
|
});
|
|
});
|
|
|
|
// Exercise the migration's write/cleanup decisions independently of a Mongo server.
|
|
function fixture() {
|
|
const user = { _id: "owner", status: "active", externalIDs: { github: "42" } };
|
|
const rows = [{ _id: "repo", owner: "owner", source: { accessToken: "owner-token" }, accessToken: "admin-token" }];
|
|
const writes = [];
|
|
let credential;
|
|
const cursor = values => ({ batchSize() { return this; }, async *[Symbol.asyncIterator]() { yield* values; } });
|
|
const db = { collection(name) {
|
|
return {
|
|
find: () => cursor(name === "users" ? [user] : name === "anonymizedrepositories" ? rows : []),
|
|
findOne: async () => name === "users" ? user : credential,
|
|
createIndex: async () => {},
|
|
updateOne: async (filter, update) => {
|
|
writes.push({ name, update });
|
|
if (name === "credentials") credential = update.$setOnInsert;
|
|
return { modifiedCount: 1 };
|
|
},
|
|
updateMany: async (filter, update) => { writes.push({ name, update }); return { modifiedCount: 1 }; },
|
|
};
|
|
} };
|
|
return { db, user, rows, writes, credential: () => credential };
|
|
}
|
|
|
|
describe("migration owner recovery decisions", function () {
|
|
const cipher = createTokenCipher(JSON.stringify({ test: Buffer.alloc(32, 7).toString("base64") }), "test");
|
|
const identify = async token => ({ githubId: token === "owner-token" ? "42" : "9" });
|
|
it("previews without writes, then encrypts the matching token without changing ownership", async () => {
|
|
const f = fixture();
|
|
const options = { recoverOwnerTokens: true, preferOwnerToken: true, identify };
|
|
expect((await migrateCredentials(f.db, cipher, options)).created).to.equal(1);
|
|
expect(f.writes).to.deep.equal([]);
|
|
const result = await migrateCredentials(f.db, cipher, { ...options, apply: true, removeLegacy: true });
|
|
expect(result.issues).to.equal(0);
|
|
expect(cipher.decrypt(f.credential().encryptedToken, "owner", "github")).to.equal("owner-token");
|
|
expect(f.writes.filter(w => w.name !== "credentials").every(w => !!w.update.$unset)).to.equal(true);
|
|
expect(f.rows[0].owner).to.equal("owner");
|
|
});
|
|
it("leaves all tokens untouched on ambiguity", async () => {
|
|
const f = fixture();
|
|
const events = [];
|
|
const result = await migrateCredentials(f.db, cipher, {
|
|
recoverOwnerTokens: true, apply: true, removeLegacy: true,
|
|
identify: async () => ({ githubId: "42" }), report: e => events.push(e),
|
|
});
|
|
expect(result.issues).to.equal(1);
|
|
expect(events[0].issue).to.equal("multiple_valid_owner_tokens");
|
|
expect(f.writes).to.deep.equal([]);
|
|
expect(JSON.stringify(events)).not.to.include("owner-token");
|
|
});
|
|
it("halts without cleaning the current owner on transient failures", async () => {
|
|
const f = fixture();
|
|
const result = await migrateCredentials(f.db, cipher, {
|
|
recoverOwnerTokens: true, apply: true, removeLegacy: true,
|
|
identify: async () => ({ issue: "github_http_429", halt: true }),
|
|
});
|
|
expect(result.halted).to.equal(true);
|
|
expect(f.writes).to.deep.equal([]);
|
|
});
|
|
it("preserves the authoritative user token without making GitHub requests", async () => {
|
|
const f = fixture();
|
|
f.user.accessTokens = { github: "authoritative" };
|
|
await migrateCredentials(f.db, cipher, {
|
|
recoverOwnerTokens: true, preferOwnerToken: true, apply: true,
|
|
identify: async () => { throw new Error("must not call"); },
|
|
});
|
|
expect(cipher.decrypt(f.credential().encryptedToken, "owner", "github")).to.equal("authoritative");
|
|
});
|
|
});
|
|
|
|
describe("parallel credential migration", function () {
|
|
const { setTimeout } = require("timers/promises");
|
|
const cipher = createTokenCipher(JSON.stringify({ test: Buffer.alloc(32, 7).toString("base64") }), "test");
|
|
it("bounds owner jobs and avoids per-resource owner lookups", async () => {
|
|
const owners = Array.from({ length: 12 }, (_, i) => ({ _id: String(i) }));
|
|
let active = 0, peak = 0, completed = 0;
|
|
const cursor = values => ({ batchSize() { return this; }, async *[Symbol.asyncIterator]() { yield* values; } });
|
|
const events = [];
|
|
const db = { collection(name) { return {
|
|
find: query => cursor(name === "users" ? owners : name === "anonymizedrepositories" && !query.owner
|
|
? [{ _id: "known", owner: "1" }, { _id: "orphan", owner: "missing" }] : []),
|
|
findOne: async () => {
|
|
expect(name).to.equal("credentials");
|
|
active++; peak = Math.max(peak, active);
|
|
await setTimeout(5);
|
|
active--; completed++;
|
|
return null;
|
|
},
|
|
}; } };
|
|
const result = await migrateCredentials(db, cipher, { concurrency: 3, report: e => events.push(e) });
|
|
expect(peak).to.equal(3);
|
|
expect(active).to.equal(0);
|
|
expect(completed).to.equal(12);
|
|
expect(result.owners).to.equal(12);
|
|
expect(events).to.deep.equal([{ collection: "anonymizedrepositories", id: "orphan", issue: "missing_owner" }]);
|
|
});
|
|
it("drains outstanding jobs before propagating a database failure", async () => {
|
|
let active = 0, calls = 0;
|
|
const cursor = values => ({ batchSize() { return this; }, async *[Symbol.asyncIterator]() { yield* values; } });
|
|
const db = { collection(name) { return {
|
|
find: () => cursor(name === "users" ? [{ _id: "1" }, { _id: "2" }, { _id: "3" }] : []),
|
|
findOne: async () => {
|
|
const call = ++calls;
|
|
active++;
|
|
await setTimeout(call === 1 ? 5 : 20);
|
|
active--;
|
|
if (call === 1) throw new Error("database failed");
|
|
return null;
|
|
},
|
|
}; } };
|
|
let error;
|
|
try { await migrateCredentials(db, cipher, { concurrency: 2 }); } catch (e) { error = e; }
|
|
expect(error.message).to.equal("database failed");
|
|
expect(active).to.equal(0);
|
|
expect(calls).to.equal(2);
|
|
});
|
|
it("shares in-flight identities and spaces requests across concurrent owners", async () => {
|
|
const starts = [];
|
|
const recover = createOwnerCredentialRecovery(async () => {
|
|
starts.push(Date.now());
|
|
await setTimeout(30);
|
|
return { githubId: "42" };
|
|
});
|
|
const results = await Promise.all([recover("42", ["a"]), recover("42", ["a"]), recover("42", ["b"])]);
|
|
expect(results).to.deep.equal([{ token: "a" }, { token: "a" }, { token: "b" }]);
|
|
expect(starts).to.have.length(2);
|
|
expect(starts[1] - starts[0]).to.be.at.least(240);
|
|
});
|
|
it("suppresses queued GitHub requests after a rate limit", async () => {
|
|
let calls = 0;
|
|
const recover = createOwnerCredentialRecovery(async () => { calls++; return { issue: "github_http_429", halt: true }; });
|
|
const results = await Promise.all([recover("42", ["a"]), recover("42", ["b"])]);
|
|
expect(calls).to.equal(1);
|
|
expect(results.every(r => r.halt)).to.equal(true);
|
|
});
|
|
});
|