mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 22:27:20 +02:00
fix: support gists through GitHub App login (#875)
* fix: prompt OAuth reconnection when gist credentials are rejected * fix: support gist reads through GitHub App user grants * test: adapt truncated gist regression to provider selection * fix: address gist reconnect visibility and token renewal review * fix: verify App authorization before revoking OAuth
This commit is contained in:
1 parent
4fd31a59c8
commit
db966356ec
14 files changed
+283
-54
No files matched your search
@@ -0,0 +1,59 @@
|
||||
const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const github = require("../src/core/GitHubUtils");
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
|
||||
const { handleError } = require("../src/server/routes/route-utils");
|
||||
|
||||
describe("gist authentication errors", function () {
|
||||
let original, gist, client;
|
||||
beforeEach(function () {
|
||||
original = github.octokit;
|
||||
gist = new Gist(new Model({ source: { gistId: "example" }, gist: { files: [{ filename: "saved.txt", content: "saved" }] } }));
|
||||
gist.getAccess = async () => ({ token: "rejected-token", connection: "oauth" });
|
||||
client = { rest: { gists: { get: async () => ({ data: { files: {} } }) } }, paginate: async () => [], request: async () => ({ data: "raw" }) };
|
||||
github.octokit = token => { expect(token).to.equal("rejected-token"); return client; };
|
||||
});
|
||||
afterEach(function () { github.octokit = original; });
|
||||
for (const operation of ["metadata", "comments", "raw file"]) {
|
||||
it("requests OAuth reconnection when GitHub rejects " + operation, async function () {
|
||||
const failure = Object.assign(new Error("Bad credentials"), { status: 401 });
|
||||
const reject = async () => { throw failure; };
|
||||
if (operation === "metadata") client.rest.gists.get = reject;
|
||||
if (operation === "comments") client.paginate = reject;
|
||||
if (operation === "raw file") {
|
||||
client.rest.gists.get = async () => ({ data: { files: { file: { filename: "large.txt", truncated: true, raw_url: "https://gist.githubusercontent.com/example/raw" } } } });
|
||||
client.request = reject;
|
||||
}
|
||||
let error;
|
||||
try { await gist.download(); } catch (caught) { error = caught; }
|
||||
expect(error?.message).to.equal("github_oauth_required");
|
||||
expect(error.cause).to.equal(failure);
|
||||
let status, body;
|
||||
handleError(error, { headersSent: false, status: value => { status = value; return { json: value => { body = value; } }; } });
|
||||
expect(status).to.equal(403);
|
||||
expect(body).to.deep.equal({ error: "github_oauth_required" });
|
||||
expect(gist.model.gist.files[0].content).to.equal("saved");
|
||||
});
|
||||
}
|
||||
for (const status of [403, 404, 429, 500]) {
|
||||
it("preserves non-authentication GitHub errors: " + status, async function () {
|
||||
const failure = Object.assign(new Error("Other GitHub failure"), { status });
|
||||
client.rest.gists.get = async () => { throw failure; };
|
||||
let error;
|
||||
try { await gist.download(); } catch (caught) { error = caught; }
|
||||
expect(error).to.equal(failure);
|
||||
});
|
||||
}
|
||||
it("requests App reconnection when an App user token is rejected", async function () {
|
||||
gist.getAccess = async () => ({ token: "rejected-token", connection: "github-app" });
|
||||
client.rest.gists.get = async () => { throw Object.assign(new Error("Bad credentials"), { status: 401 }); };
|
||||
let error;
|
||||
try { await gist.download(); } catch (caught) { error = caught; }
|
||||
expect(error?.message).to.equal("github_app_reconnect_required");
|
||||
});
|
||||
it("still downloads a gist when the credential is accepted", async function () {
|
||||
await gist.download();
|
||||
expect(gist.model.gist.files).to.have.length(0);
|
||||
});
|
||||
});
|
||||
+105
-1
@@ -175,7 +175,7 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
const result = String(url).endsWith("/token/scoped")
|
||||
? scopeHandler ? await scopeHandler(body, options) : { status: 403 }
|
||||
: await handler(String(url), options, body);
|
||||
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json" } });
|
||||
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json", ...result.headers } });
|
||||
};
|
||||
}
|
||||
it("keeps OAuth and App grants separate, encrypted and migration-verifiable", async () => {
|
||||
@@ -191,6 +191,68 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
expect(projected.encryptedToken).to.equal(undefined);
|
||||
expect(await verifyCredentials(mongoose.connection.db, createTokenCipher(keys, "test"))).to.deep.equal({ checked: 2, legacy: 0 });
|
||||
});
|
||||
for (const legacy of [false, true]) {
|
||||
it(`reads gist files and comments with an App user grant (legacy: ${legacy})`, async () => {
|
||||
if (legacy) await setCredential(owner.id, "legacy-secret");
|
||||
await app.saveAppGrant(owner.id, data("old", -1));
|
||||
mock((url, options, body) => {
|
||||
if (url.includes("/login/oauth/access_token")) {
|
||||
expect(body.refresh_token).to.equal("ghr_refreshold");
|
||||
return data("new");
|
||||
}
|
||||
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("token ghu_accessnew");
|
||||
if (url.includes("/comments")) return [{ body: "comment", user: { login: "owner" } }];
|
||||
expect(url).to.include("/gists/example");
|
||||
return { description: "gist", files: { file: { filename: "file.txt", content: "hello" } } };
|
||||
});
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
|
||||
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
|
||||
await gist.download();
|
||||
expect(gist.model.gist.files[0].content).to.equal("hello");
|
||||
expect(gist.model.gist.comments[0].body).to.equal("comment");
|
||||
expect(calls).to.have.length(3);
|
||||
});
|
||||
}
|
||||
for (const rejected of [false, true]) {
|
||||
it(`renews App gist access during comment pagination (401: ${rejected})`, async () => {
|
||||
await app.saveAppGrant(owner.id, data("old"));
|
||||
let pages = 0, refreshed = false;
|
||||
mock(async (url, options) => {
|
||||
if (url.includes("/login/oauth/access_token")) { refreshed = true; return data("new"); }
|
||||
const authorization = new globalThis.Headers(options.headers).get("authorization");
|
||||
if (url.includes("/comments")) {
|
||||
pages++;
|
||||
if (pages === 1) {
|
||||
expect(authorization).to.equal("token ghu_accessold");
|
||||
await Credentials.updateOne({ ownerId: owner.id, provider: app.APP_PROVIDER }, { $set: { expiresAt: new Date(0) } });
|
||||
if (rejected) return { status: 401, body: { message: "Bad credentials" } };
|
||||
return { body: [{ body: "first", user: { login: "owner" } }], headers: { link: '<https://api.github.com/gists/example/comments?page=2>; rel="next"' } };
|
||||
}
|
||||
expect(authorization).to.equal("token ghu_accessnew");
|
||||
return [{ body: "last", user: { login: "owner" } }];
|
||||
}
|
||||
return { files: {} };
|
||||
});
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
|
||||
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
|
||||
await gist.download();
|
||||
expect(refreshed).to.equal(true);
|
||||
expect(pages).to.equal(2);
|
||||
expect(gist.model.gist.comments.map(c => c.body)).to.deep.equal(rejected ? ["last"] : ["first", "last"]);
|
||||
});
|
||||
}
|
||||
it("keeps legacy gist access but does not bypass revoked App grants", async () => {
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
|
||||
const gist = new Gist(new Model({ owner: owner.id, source: { gistId: "example" } }));
|
||||
expect(await gist.getToken()).to.equal("legacy-secret");
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
await Credentials.updateOne({ ownerId: owner.id, provider: app.APP_PROVIDER }, { $set: { revoked: true } });
|
||||
await rejects(gist.download(), "github_app_reconnect_required");
|
||||
});
|
||||
it("serializes refresh and atomically rotates the token pair", async () => {
|
||||
await app.saveAppGrant(owner.id, data("old", -1));
|
||||
mock(async (_url, _options, body) => {
|
||||
@@ -415,6 +477,48 @@ describeMongo("GitHub App credential and repository integration", function () {
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: body ? JSON.stringify(body) : undefined });
|
||||
return { status: response.status, location: response.headers.get("location"), data: await response.json().catch(() => null) };
|
||||
}
|
||||
it("allows OAuth revocation with an existing gist and keeps gist access working", async () => {
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
const Model = require("../src/core/model/anonymizedGists/anonymizedGists.model").default;
|
||||
const model = await Model.create({ owner: owner.id, gistId: "oauth-revoke-gist", source: { gistId: "example" } });
|
||||
mock((url, options, body) => {
|
||||
if (url.includes("/user/installations")) {
|
||||
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("Bearer ghu_access1");
|
||||
return { installations: [] };
|
||||
}
|
||||
if (url.includes("/grant")) {
|
||||
expect(options.method).to.equal("DELETE");
|
||||
expect(body.access_token).to.equal("legacy-secret");
|
||||
return {};
|
||||
}
|
||||
expect(new globalThis.Headers(options.headers).get("authorization")).to.equal("token ghu_access1");
|
||||
return url.includes("/comments") ? [] : { files: { file: { filename: "file.txt", content: "still accessible" } } };
|
||||
});
|
||||
const result = await request("/github/connections/disconnect-oauth", {});
|
||||
expect(result.status).to.equal(200);
|
||||
expect(await getCredentialToken(owner.id)).not.to.be.ok;
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const gist = new Gist(model);
|
||||
await gist.download();
|
||||
expect(gist.model.gist.files[0].content).to.equal("still accessible");
|
||||
});
|
||||
for (const status of [401, 503]) {
|
||||
it(`preserves OAuth when GitHub cannot verify the App grant (${status})`, async () => {
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
mock((url, options) => {
|
||||
expect(url).to.include("/user/installations");
|
||||
expect(options.method).not.to.equal("DELETE");
|
||||
return { status, body: { message: "unavailable" } };
|
||||
});
|
||||
const result = await request("/github/connections/disconnect-oauth", {});
|
||||
expect(result.status).to.equal(status === 401 ? 403 : 502);
|
||||
expect(result.data.error).to.equal(status === 401 ? "github_app_reconnect_required" : "github_unavailable");
|
||||
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
|
||||
expect(calls).to.have.length(1);
|
||||
});
|
||||
}
|
||||
it("links App authorization to the existing OAuth account without replacing its grant", async () => {
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
owner.isAdmin = true; await owner.save();
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("production regressions", function () {
|
||||
it("fetches complete truncated gist content", async function () {
|
||||
const Gist = require("../src/core/Gist").default;
|
||||
const model = new (require("../src/core/model/anonymizedGists/anonymizedGists.model").default)({ source: { gistId: "123" } });
|
||||
const gist = new Gist(model); gist.getToken = async () => "private-token";
|
||||
const gist = new Gist(model); gist.getAccess = async () => ({ token: "private-token", connection: "github-app" });
|
||||
stub(gh, "octokit", token => {
|
||||
expect(token).to.equal("private-token");
|
||||
return { rest: { gists: { get: async () => ({ data: { files: { file: { filename: "file", content: "prefix", truncated: true, raw_url: "https://gist.githubusercontent.com/raw", size: 20 } } } }) } },
|
||||
|
||||
+29
-4
@@ -469,17 +469,23 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).to.equal(null);
|
||||
});
|
||||
|
||||
it("offers OAuth for an App-only gist and saves the draft before connecting", async function () {
|
||||
for (const provider of ["OAuth", "App"]) {
|
||||
it(`offers ${provider} recovery when gist credentials are rejected`, async function () {
|
||||
ui = await browser("/gist-anonymize", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/github/connections": { appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: false },
|
||||
"/github/connections": { appEnabled: true, appConnected: provider === "App", oauthEnabled: true, oauthConnected: provider === "OAuth" },
|
||||
"/api/gist/source/311fc9": { __status: 403, body: { error: provider === "App" ? "github_app_reconnect_required" : "github_oauth_required" } },
|
||||
});
|
||||
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
|
||||
input.dispatchEvent(new ui.window.Event("blur"));
|
||||
await delay(60);
|
||||
const button = [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes("Connect GitHub OAuth to access gists"));
|
||||
const button = [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(`Connect GitHub ${provider} to access gists`));
|
||||
expect(button).not.to.equal(undefined);
|
||||
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(false);
|
||||
expect(button.closest(".anonymize-workspace")).not.to.equal(null);
|
||||
for (let node = button; node; node = node.parentElement) {
|
||||
expect(ui.window.getComputedStyle(node).display, node.className).not.to.equal("none");
|
||||
}
|
||||
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
|
||||
button.click();
|
||||
const saved = JSON.parse(ui.window.sessionStorage.getItem("github-access-draft"));
|
||||
expect(saved.path).to.equal("/gist-anonymize");
|
||||
@@ -487,6 +493,25 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
it("previews a gist with only an App login", async function () {
|
||||
ui = await browser("/gist-anonymize", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/github/connections": { appEnabled: true, appConnected: true, gistConnection: "github-app", oauthEnabled: true, oauthConnected: false },
|
||||
"/api/user/default": { terms: [], options: { content: true } },
|
||||
"/api/anonymize-preview": request => ({ contents: request.payload.contents }),
|
||||
"/api/gist/source/311fc9": { gist: { description: "App gist", files: [{ filename: "example.txt", content: "example" }], comments: [] } },
|
||||
});
|
||||
const input = await ui.input("#sourceUrl", "https://gist.github.com/311fc9");
|
||||
input.dispatchEvent(new ui.window.Event("blur"));
|
||||
await delay(300);
|
||||
expect(ui.requests.some(r => r.url.pathname === "/api/gist/source/311fc9")).to.equal(true);
|
||||
expect(ui.window.document.body.textContent).to.include("example.txt");
|
||||
expect(ui.window.document.body.textContent).not.to.include("Connect GitHub OAuth to access gists");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const status of ["archived", "preparing", "queue", "download", "removing", "expiring"]) {
|
||||
it(`hides connection changes on ${status} resources`, async function () {
|
||||
ui = await browser("/connections", {
|
||||
|
||||
Reference in new issue
Block a user