mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
feat: add read-only GitHub App access alongside OAuth
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
const { expect } = require("chai");
|
||||
const { createHmac, generateKeyPairSync, createVerify } = require("crypto");
|
||||
const process = require("process");
|
||||
const { setTimeout } = require("timers");
|
||||
const express = require("express");
|
||||
const mongoose = require("mongoose");
|
||||
require("ts-node/register/transpile-only");
|
||||
const config = require("../src/config").default;
|
||||
const app = require("../src/core/github-app");
|
||||
const { createTokenCipher } = require("../src/core/credential-crypto");
|
||||
const { githubAppRouter, githubAppWebhook, validWebhookSignature, safeReturnTo, consumeFlow } = require("../src/server/routes/github-app");
|
||||
const Credentials = require("../src/core/model/credentials/credentials.model").default;
|
||||
const Users = require("../src/core/model/users/users.model").default;
|
||||
const Installations = require("../src/core/model/github-installation").default;
|
||||
const { getCredentialToken, setCredential } = require("../src/core/credentials");
|
||||
const { verifyCredentials } = require("../src/core/migrate-credentials");
|
||||
const { registerGitHubToken, githubQuotaKey } = require("../src/core/github-token-context");
|
||||
const keys = JSON.stringify({ test: Buffer.alloc(32, 9).toString("base64") });
|
||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const pem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
|
||||
async function rejects(promise, message) {
|
||||
try { await promise; } catch (error) { expect(error.message).to.equal(message); return; }
|
||||
throw new Error(`Expected rejection: ${message}`);
|
||||
}
|
||||
|
||||
describe("GitHub App protocol boundaries", () => {
|
||||
it("uses a different authenticated purpose for refresh ciphertext", () => {
|
||||
const cipher = createTokenCipher(keys, "test");
|
||||
const access = cipher.encrypt("access", "owner", app.APP_PROVIDER);
|
||||
const refresh = cipher.encrypt("refresh", "owner", app.APP_PROVIDER, "encryptedRefreshToken");
|
||||
expect(cipher.decrypt(refresh, "owner", app.APP_PROVIDER, "encryptedRefreshToken")).to.equal("refresh");
|
||||
expect(() => cipher.decrypt(access, "owner", app.APP_PROVIDER, "encryptedRefreshToken")).to.throw();
|
||||
expect(() => cipher.decrypt(refresh, "owner", app.APP_PROVIDER)).to.throw();
|
||||
});
|
||||
it("validates raw webhook bytes and rejects malformed signatures", () => {
|
||||
const raw = Buffer.from('{"action":"deleted"}');
|
||||
const signature = "sha256=" + createHmac("sha256", "secret").update(raw).digest("hex");
|
||||
expect(validWebhookSignature(raw, signature, "secret")).to.equal(true);
|
||||
expect(validWebhookSignature(Buffer.from('{}'), signature, "secret")).to.equal(false);
|
||||
for (const value of [undefined, "sha256=aa", [], signature.replace("sha256", "sha1")]) {
|
||||
expect(validWebhookSignature(raw, value, "secret")).to.equal(false);
|
||||
}
|
||||
});
|
||||
it("restricts callback destinations and expires state", () => {
|
||||
for (const path of ["https://evil.test", "//evil.test", "/\\evil.test", "/anonymize\r\nLocation: x"]) {
|
||||
expect(safeReturnTo(path)).to.equal("/connections");
|
||||
}
|
||||
expect(safeReturnTo("/anonymize/saved")).to.equal("/anonymize/saved");
|
||||
expect(() => consumeFlow({ state: "secret", expires: Date.now() - 1 }, "secret")).to.throw("invalid_auth_state");
|
||||
expect(() => consumeFlow({ state: "secret", expires: Date.now() + 1000 }, "wrong")).to.throw("invalid_auth_state");
|
||||
});
|
||||
it("preselects known repositories without defaulting to all repositories", () => {
|
||||
const url = new globalThis.URL(app.installationURL(123, [456, 789]));
|
||||
expect(url.pathname).to.match(/\/installations\/new\/permissions$/);
|
||||
expect(url.searchParams.getAll("repository_ids[]")).to.deep.equal(["456", "789"]);
|
||||
expect(app.installationURL(123, [])).not.to.include("/permissions");
|
||||
});
|
||||
it("renews a rejected installation token once without using OAuth", async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const sent = [];
|
||||
let current = "ghs_before";
|
||||
registerGitHubToken("ghs_original", { quotaKey: "installation:renew", renew: async force => {
|
||||
if (force) current = "ghs_after";
|
||||
return current;
|
||||
} });
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
sent.push(new globalThis.Headers(options.headers).get("authorization"));
|
||||
return new globalThis.Response(JSON.stringify(sent.length === 1 ? { message: "Bad credentials" } : { id: 1 }), {
|
||||
status: sent.length === 1 ? 401 : 200, headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const { octokit } = require("../src/core/GitHubUtils");
|
||||
const response = await octokit("ghs_original").request("GET /repos/owner/private");
|
||||
expect(response.data.id).to.equal(1);
|
||||
expect(sent).to.deep.equal(["token ghs_before", "token ghs_after"]);
|
||||
} finally { globalThis.fetch = previousFetch; }
|
||||
});
|
||||
|
||||
it("signs a short-lived App JWT with clock skew", () => {
|
||||
const previous = { enabled: config.GITHUB_APP_ENABLED, key: config.GITHUB_APP_PRIVATE_KEY, client: config.GITHUB_APP_CLIENT_ID };
|
||||
Object.assign(config, { GITHUB_APP_ENABLED: true, GITHUB_APP_PRIVATE_KEY: pem, GITHUB_APP_CLIENT_ID: "Iv.test" });
|
||||
try {
|
||||
const jwt = app.appJWT(1000000);
|
||||
const [header, payload, signature] = jwt.split(".");
|
||||
const body = JSON.parse(Buffer.from(payload, "base64url"));
|
||||
expect(body).to.deep.equal({ iat: 940, exp: 1540, iss: "Iv.test" });
|
||||
expect(createVerify("RSA-SHA256").update(header + "." + payload).verify(publicKey, signature, "base64url")).to.equal(true);
|
||||
} finally {
|
||||
Object.assign(config, { GITHUB_APP_ENABLED: previous.enabled, GITHUB_APP_PRIVATE_KEY: previous.key, GITHUB_APP_CLIENT_ID: previous.client });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
|
||||
describeMongo("GitHub App credential and repository integration", function () {
|
||||
this.timeout(15000);
|
||||
let owner, previousConfig, previousFetch, calls, server, base, session;
|
||||
const data = (suffix = "1", expires = 3600) => ({ access_token: "ghu_access" + suffix, refresh_token: "ghr_refresh" + suffix,
|
||||
expires_in: expires, refresh_token_expires_in: 100000 });
|
||||
before(async () => {
|
||||
previousConfig = { ...config };
|
||||
Object.assign(config, { GITHUB_APP_ENABLED: true, GITHUB_APP_ID: "123", GITHUB_APP_CLIENT_ID: "Iv.test",
|
||||
GITHUB_APP_PRIVATE_KEY: pem, CREDENTIAL_KEYS: keys, CREDENTIAL_ACTIVE_KEY_ID: "test" });
|
||||
await mongoose.connect(process.env.TEST_MONGODB_URI, { dbName: "github_app_test_" + Date.now() });
|
||||
await Credentials.createIndexes();
|
||||
await Installations.createIndexes();
|
||||
const api = express();
|
||||
api.use("/github/app/webhook", githubAppWebhook);
|
||||
api.use(express.json());
|
||||
api.use((req, _res, next) => {
|
||||
req.session = session;
|
||||
req.user = { user: owner };
|
||||
req.isAuthenticated = () => true;
|
||||
req.login = (identity, done) => { req.user = identity; done(); };
|
||||
req.logout = done => done();
|
||||
next();
|
||||
});
|
||||
api.use("/github", githubAppRouter);
|
||||
server = await new Promise(resolve => { const listening = api.listen(0, "127.0.0.1", () => resolve(listening)); });
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
globalThis.fetch = previousFetch;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.disconnect();
|
||||
Object.assign(config, previousConfig);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
previousFetch = globalThis.fetch;
|
||||
session = { save: done => done(), githubConnectionCSRF: "csrf-test" };
|
||||
calls = [];
|
||||
await Credentials.deleteMany({}); await Users.deleteMany({}); await Installations.deleteMany({});
|
||||
app.clearAppTokenCache();
|
||||
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
|
||||
});
|
||||
afterEach(() => { globalThis.fetch = previousFetch; });
|
||||
function mock(handler) {
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (String(url).startsWith(base)) return previousFetch(url, options);
|
||||
const body = options?.body ? JSON.parse(options.body) : undefined;
|
||||
calls.push({ url: String(url), body, options });
|
||||
const result = await handler(String(url), options, body);
|
||||
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
}
|
||||
it("keeps OAuth and App grants separate, encrypted and migration-verifiable", async () => {
|
||||
await setCredential(owner.id, "legacy-secret");
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
|
||||
expect(await app.appUserToken(owner.id)).to.equal("ghu_access1");
|
||||
const raw = await mongoose.connection.db.collection("credentials").find({}).toArray();
|
||||
expect(JSON.stringify(raw)).not.to.include("ghr_refresh1");
|
||||
expect(JSON.stringify(raw)).not.to.include("ghu_access1");
|
||||
const projected = await Credentials.findOne({ ownerId: owner.id, provider: app.APP_PROVIDER }).lean();
|
||||
expect(projected.encryptedRefreshToken).to.equal(undefined);
|
||||
expect(projected.encryptedToken).to.equal(undefined);
|
||||
expect(await verifyCredentials(mongoose.connection.db, createTokenCipher(keys, "test"))).to.deep.equal({ checked: 2, legacy: 0 });
|
||||
});
|
||||
it("serializes refresh and atomically rotates the token pair", async () => {
|
||||
await app.saveAppGrant(owner.id, data("old", -1));
|
||||
mock(async (_url, _options, body) => {
|
||||
expect(body.refresh_token).to.equal("ghr_refreshold");
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
return data("new");
|
||||
});
|
||||
const tokens = await Promise.all([app.appUserToken(owner.id), app.appUserToken(owner.id)]);
|
||||
expect(tokens).to.deep.equal(["ghu_accessnew", "ghu_accessnew"]);
|
||||
expect(calls).to.have.length(1);
|
||||
const row = await Credentials.findOne({ ownerId: owner.id, provider: app.APP_PROVIDER }).select("+encryptedRefreshToken").lean();
|
||||
expect(createTokenCipher(keys, "test").decrypt(row.encryptedRefreshToken, owner.id, app.APP_PROVIDER, "encryptedRefreshToken")).to.equal("ghr_refreshnew");
|
||||
});
|
||||
it("does not overwrite a login that races a refresh", async () => {
|
||||
await app.saveAppGrant(owner.id, data("old", -1));
|
||||
mock(async () => { await app.saveAppGrant(owner.id, data("login")); return data("stale"); });
|
||||
expect(await app.appUserToken(owner.id)).to.equal("ghu_accesslogin");
|
||||
});
|
||||
it("does not revive revoked grants or disabled users", async () => {
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
await Credentials.updateOne({ ownerId: owner.id }, { $set: { revoked: true } });
|
||||
await rejects(app.appUserToken(owner.id), "github_app_reconnect_required");
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
await Users.updateOne({ _id: owner._id }, { $set: { status: "banned" } });
|
||||
await rejects(app.appUserToken(owner.id), "github_app_reconnect_required");
|
||||
});
|
||||
it("never falls back to OAuth when an App cannot access the repository", async () => {
|
||||
await setCredential(owner.id, "legacy-secret"); await app.saveAppGrant(owner.id, data());
|
||||
mock(() => ({ installations: [] }));
|
||||
await rejects(app.selectRepositoryAccess(owner.id, "owner/private"), "github_app_access_required");
|
||||
const selected = await app.selectRepositoryAccess(owner.id, "owner/private", "oauth");
|
||||
expect(selected.token).to.equal("legacy-secret");
|
||||
expect(selected.binding.kind).to.equal("oauth");
|
||||
});
|
||||
it("checks the user's access before minting a repository-restricted token", async () => {
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };
|
||||
mock((url) => {
|
||||
if (url.endsWith("/repositories/7")) return { id: 7 };
|
||||
if (url.endsWith("/app/installations/4")) return { app_id: 123, permissions: { metadata: "read", contents: "read", pull_requests: "read" }, suspended_at: null };
|
||||
if (url.endsWith("/access_tokens")) return { token: "ghs_installation", expires_at: new Date(Date.now() + 3600000).toISOString() };
|
||||
throw new Error("Unexpected request");
|
||||
});
|
||||
expect(await app.boundAppToken(owner.id, binding)).to.equal("ghs_installation");
|
||||
const mint = calls.find(c => c.url.endsWith("/access_tokens"));
|
||||
expect(mint.body.repository_ids).to.deep.equal([7]);
|
||||
expect(Object.values(mint.body.permissions)).to.deep.equal(["read", "read", "read"]);
|
||||
expect(githubQuotaKey("ghs_installation")).to.equal("installation:4");
|
||||
expect(calls[0].options.headers.Authorization).to.equal("Bearer ghu_access1");
|
||||
await app.boundAppToken(owner.id, binding);
|
||||
expect(calls.filter(c => c.url.endsWith("/access_tokens"))).to.have.length(1);
|
||||
expect(calls.filter(c => c.url.endsWith("/repositories/7"))).to.have.length(2);
|
||||
});
|
||||
it("denies forged installations and grants with write permissions", async () => {
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };
|
||||
mock(url => url.endsWith("/repositories/7") ? { id: 7 } : { app_id: 999, permissions: { contents: "read" } });
|
||||
await rejects(app.boundAppToken(owner.id, binding), "github_app_access_required");
|
||||
mock(url => url.endsWith("/repositories/7") ? { id: 7 } : { app_id: 123, permissions: { contents: "read", issues: "write" } });
|
||||
await rejects(app.boundAppToken(owner.id, binding), "github_app_permissions_invalid");
|
||||
expect(calls.some(c => c.url.endsWith("/access_tokens"))).to.equal(false);
|
||||
});
|
||||
it("blocks removed user access even if an installation token was cached", async () => {
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
mock(() => ({ status: 404, body: { message: "private secret details" } }));
|
||||
await rejects(app.boundAppToken(owner.id, { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" }), "github_app_access_required");
|
||||
expect(calls).to.have.length(1);
|
||||
});
|
||||
async function request(path, body, csrf = "csrf-test") {
|
||||
const response = await previousFetch(base + path, { method: body ? "POST" : "GET", redirect: "manual",
|
||||
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("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();
|
||||
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/anonymize" };
|
||||
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 10, login: "owner" });
|
||||
const result = await request("/github/app/callback?state=state&code=code");
|
||||
expect(result.status).to.equal(302);
|
||||
expect(result.location).to.equal("/anonymize");
|
||||
expect(await Users.countDocuments()).to.equal(1);
|
||||
expect((await Users.findById(owner.id)).isAdmin).to.equal(true);
|
||||
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
|
||||
expect(await app.appUserToken(owner.id)).to.equal("ghu_access1");
|
||||
expect(session.githubAppFlow).to.equal(undefined);
|
||||
const replay = await request("/github/app/callback?state=state&code=code");
|
||||
expect(replay.status).to.equal(400);
|
||||
expect(calls).to.have.length(2);
|
||||
});
|
||||
it("rejects linking a different GitHub identity", async () => {
|
||||
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/connections" };
|
||||
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 99, login: "other" });
|
||||
const result = await request("/github/app/callback?state=state&code=code");
|
||||
expect(result.status).to.equal(409);
|
||||
expect(await Credentials.countDocuments()).to.equal(0);
|
||||
expect(await Users.countDocuments()).to.equal(1);
|
||||
});
|
||||
it("migrates only the owner's resource after checking its configured commit", async () => {
|
||||
const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
const resource = await Repos.create({ repoId: "migration-resource", owner: owner.id, status: "ready",
|
||||
source: { type: "GitHubStream", repositoryName: "owner/private", commit: "abc123", branch: "main" },
|
||||
options: { terms: ["owner"], update: true } });
|
||||
mock(url => {
|
||||
if (url.includes("/user/installations?")) return { installations: [{ id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" } }] };
|
||||
if (url.includes("/user/installations/4/repositories")) return { repositories: [{ id: 7, full_name: "owner/private" }] };
|
||||
if (url.endsWith("/repositories/7")) return { id: 7 };
|
||||
if (url.endsWith("/app/installations/4")) return { app_id: 123, permissions: { contents: "read", metadata: "read" } };
|
||||
if (url.endsWith("/access_tokens")) return { token: "ghs_migration", expires_at: new Date(Date.now() + 3600000).toISOString() };
|
||||
if (url.endsWith("/commits/abc123")) return { sha: "abc123" };
|
||||
throw new Error("Unexpected request");
|
||||
});
|
||||
const body = { type: "repository", id: resource.repoId, connection: "github-app" };
|
||||
expect((await request("/github/connections/migrate", body, "bad")).status).to.equal(403);
|
||||
expect((await request("/github/connections/migrate", { ...body, preview: true })).status).to.equal(200);
|
||||
expect((await Repos.findById(resource.id)).githubAccess).to.equal(undefined);
|
||||
expect((await request("/github/connections/migrate", body)).status).to.equal(200);
|
||||
const migrated = await Repos.findById(resource.id);
|
||||
expect(migrated.githubAccess.kind).to.equal("github-app");
|
||||
expect(migrated.source.toObject()).to.deep.equal(resource.source.toObject());
|
||||
expect(migrated.options.terms).to.deep.equal(["owner"]);
|
||||
expect(migrated.repoId).to.equal("migration-resource");
|
||||
expect(calls.some(c => c.url.endsWith("/commits/abc123"))).to.equal(true);
|
||||
const stranger = await Users.create({ username: "stranger", externalIDs: { github: "11" } });
|
||||
owner = stranger;
|
||||
expect((await request("/github/connections/migrate", body)).status).to.equal(404);
|
||||
});
|
||||
it("checks repository access with signed lifecycle events and rejects forged deliveries", async () => {
|
||||
config.GITHUB_APP_WEBHOOK_SECRET = "webhook-secret";
|
||||
const body = JSON.stringify({ action: "deleted", installation: { id: 4, app_id: 123 } });
|
||||
const send = signature => previousFetch(base + "/github/app/webhook", { method: "POST", body,
|
||||
headers: { "Content-Type": "application/json", "X-GitHub-Event": "installation", "X-Hub-Signature-256": signature } });
|
||||
expect((await send("sha256=aa")).status).to.equal(401);
|
||||
const signature = "sha256=" + createHmac("sha256", "webhook-secret").update(body).digest("hex");
|
||||
expect((await send(signature)).status).to.equal(204);
|
||||
expect((await send(signature)).status).to.equal(204);
|
||||
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(true);
|
||||
expect(await Installations.countDocuments()).to.equal(1);
|
||||
});
|
||||
|
||||
async function webhook(event, payload) {
|
||||
config.GITHUB_APP_WEBHOOK_SECRET = "webhook-secret";
|
||||
const body = JSON.stringify(payload);
|
||||
return previousFetch(base + "/github/app/webhook", { method: "POST", body,
|
||||
headers: { "Content-Type": "application/json", "X-GitHub-Event": event,
|
||||
"X-Hub-Signature-256": "sha256=" + createHmac("sha256", "webhook-secret").update(body).digest("hex") } });
|
||||
}
|
||||
|
||||
it("recovers a failed installation webhook on subsequent repository access", async () => {
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
let unavailable = true;
|
||||
mock(url => {
|
||||
if (url.endsWith("/repositories/7")) return { id: 7 };
|
||||
if (url.endsWith("/access_tokens")) return { token: "ghs_recovered", expires_at: new Date(Date.now() + 3600000).toISOString() };
|
||||
if (unavailable) return { status: 503, body: {} };
|
||||
return { id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" }, permissions: { contents: "read" } };
|
||||
});
|
||||
expect((await webhook("installation_repositories", { action: "added", installation: { id: 4, app_id: 123 } })).status).to.equal(503);
|
||||
expect((await Installations.findOne({ installationId: 4 })).reconciliationPending).to.equal(true);
|
||||
unavailable = false;
|
||||
expect(await app.boundAppToken(owner.id, { kind: "github-app", repositoryId: 7, installationId: 4, revision: "r" })).to.equal("ghs_recovered");
|
||||
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(false);
|
||||
});
|
||||
|
||||
it("does not let an in-flight installation check undo a deletion", async () => {
|
||||
await Installations.create({ appId: "123", installationId: 4, revision: "old", blocked: true, reconciliationPending: true });
|
||||
mock(async () => {
|
||||
await webhook("installation", { action: "deleted", installation: { id: 4, app_id: 123 } });
|
||||
return { id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" } };
|
||||
});
|
||||
await app.reconcileInstallation(4, "old");
|
||||
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(true);
|
||||
});
|
||||
|
||||
it("reconciles replayed revocations without revoking a new working grant", async () => {
|
||||
const payload = { action: "revoked", sender: { id: 10 } };
|
||||
await app.saveAppGrant(owner.id, data());
|
||||
mock(() => ({ status: 401, body: {} }));
|
||||
expect((await webhook("github_app_authorization", payload)).status).to.equal(204);
|
||||
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(true);
|
||||
await app.saveAppGrant(owner.id, data("new"));
|
||||
mock(() => ({ id: 10 }));
|
||||
expect((await webhook("github_app_authorization", payload)).status).to.equal(204);
|
||||
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
|
||||
mock(async () => {
|
||||
await app.saveAppGrant(owner.id, data("racing"));
|
||||
return { status: 401, body: {} };
|
||||
});
|
||||
await webhook("github_app_authorization", payload);
|
||||
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
|
||||
});
|
||||
|
||||
it("leaves the current grant intact when revocation reconciliation is unavailable", async () => {
|
||||
await app.saveAppGrant(owner.id, data("expired", -1));
|
||||
mock(() => ({ status: 503, body: {} }));
|
||||
expect((await webhook("github_app_authorization", { action: "revoked", sender: { id: 10 } })).status).to.equal(503);
|
||||
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
|
||||
});
|
||||
|
||||
it("rejects a stale source edit before clearing files or changing status", async () => {
|
||||
const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const Repository = require("../src/core/Repository").default;
|
||||
const model = await Repos.create({ repoId: "edit-race", owner: owner.id, status: "ready",
|
||||
githubAccess: { kind: "oauth", revision: "old" } });
|
||||
const repo = new Repository(model);
|
||||
let cleared = false;
|
||||
repo.resetSate = async () => { cleared = true; };
|
||||
await Repos.updateOne({ _id: model._id }, { $set: { githubAccess: { kind: "github-app", revision: "new", repositoryId: 7, installationId: 4 } } });
|
||||
await rejects(repo.remove({ accessRevision: "old" }), "connection_changed");
|
||||
expect(cleared).to.equal(false);
|
||||
expect((await Repos.findById(model._id)).status).to.equal("ready");
|
||||
});
|
||||
|
||||
});
|
||||
+82
-1
@@ -8,7 +8,7 @@ const { setTimeout: delay } = require("node:timers/promises");
|
||||
const publicDir = path.join(__dirname, "../public");
|
||||
const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(publicDir, "script", name), "utf8"));
|
||||
|
||||
async function browser(route = "/", overrides = {}) {
|
||||
async function browser(route = "/", overrides = {}, storage = {}) {
|
||||
const errors = [], requests = [], assets = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on("jsdomError", error => {
|
||||
@@ -50,6 +50,7 @@ async function browser(route = "/", overrides = {}) {
|
||||
if (data?.__status) data = data.body;
|
||||
return { ok: status < 400, status, headers: { get: () => typeof data === "string" ? "text/plain" : "application/json" }, text: async () => typeof data === "string" ? data : JSON.stringify(data) };
|
||||
};
|
||||
for (const [key, value] of Object.entries(storage)) window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||
bundles.forEach(bundle => window.eval(bundle));
|
||||
const app = window.anonymousApp;
|
||||
await app.router.isReady();
|
||||
@@ -88,6 +89,86 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("offers App and OAuth sign-in and direct repository access links", async function () {
|
||||
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true } });
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).not.to.equal(null);
|
||||
expect(ui.window.document.querySelector('a[href="/github/login"]')).not.to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("previews a connection migration before switching and includes CSRF protection", async function () {
|
||||
const resource = { type: "repository", id: "saved", name: "owner/private", connection: "oauth", status: "ready" };
|
||||
ui = await browser("/connections", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/github/connections": { csrf: "csrf-value", appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: true,
|
||||
installations: [{ id: 4, account: "owner" }], gistCount: 0, resources: [resource] },
|
||||
"/github/connections/migrate": request => request.payload.preview ? { eligible: true } : { connection: "github-app" },
|
||||
});
|
||||
const button = label => [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(label));
|
||||
expect(button("Switch to read-only access")).to.equal(undefined);
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/install?installationId=4"]')).not.to.equal(null);
|
||||
button("Check read-only access").click();
|
||||
await delay(30);
|
||||
button("Switch to read-only access").click();
|
||||
await delay(30);
|
||||
const changes = ui.requests.filter(r => r.url.pathname === "/github/connections/migrate");
|
||||
expect(changes).to.have.length(2);
|
||||
expect(changes[0].payload.preview).to.equal(true);
|
||||
expect(changes[1].payload.preview).to.equal(false);
|
||||
expect(changes[1].payload.connection).to.equal("github-app");
|
||||
expect(changes[1].headers["X-CSRF-Token"]).to.equal("csrf-value");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
|
||||
it("preserves redactions, identifiers and pinned commits when switching connections", async () => {
|
||||
ui = await browser("/anonymize", {
|
||||
"/github/connections": { appEnabled: true, oauthConnected: true },
|
||||
"/api/repo/owner/repo/": { defaultBranch: "main", repo: "repo" },
|
||||
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
||||
"/api/repo/owner/repo/readme": "",
|
||||
});
|
||||
const url = await ui.input("#sourceUrl", "https://github.com/owner/repo");
|
||||
url.dispatchEvent(new ui.window.Event("blur"));
|
||||
await delay(50);
|
||||
await ui.input("#terms", "Private Author");
|
||||
await delay(300);
|
||||
const id = await ui.input("#repoId", "chosen-id");
|
||||
id.dispatchEvent(new ui.window.Event("blur"));
|
||||
await ui.input("#commit", "123456abcdef");
|
||||
[...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Read-only GitHub App")).click();
|
||||
await delay(60);
|
||||
expect(ui.window.document.querySelector("#terms").value).to.equal("Private Author");
|
||||
expect(ui.window.document.querySelector("#repoId").value).to.equal("chosen-id");
|
||||
expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const type of ["repo", "pr", "gist"]) {
|
||||
it(`restores unsaved ${type} edits after granting GitHub access`, async () => {
|
||||
const route = { repo: "anonymize", pr: "pull-request-anonymize", gist: "gist-anonymize" }[type];
|
||||
const source = { repo: { fullName: "owner/repo", branch: "main", commit: "123456abcdef" },
|
||||
pr: { repositoryFullName: "owner/repo", pullRequestId: 1 }, gist: { gistId: "311fc9" } }[type];
|
||||
const sourceUrl = { repo: "https://github.com/owner/repo", pr: "https://github.com/owner/repo/pull/1", gist: "https://gist.github.com/311fc9" }[type];
|
||||
const routePath = `/${route}/test`;
|
||||
ui = await browser(routePath, {
|
||||
[`/api/${type}/test`]: { status: "ready", source, options: { terms: ["persisted"], update: false } },
|
||||
"/api/repo/owner/repo/": { defaultBranch: "main" },
|
||||
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
||||
"/api/repo/owner/repo/readme": "",
|
||||
"/api/pr/owner/repo/1": { pullRequest: { title: "Test", body: "", comments: [] } },
|
||||
"/api/gist/source/311fc9": { files: [], comments: [] },
|
||||
}, { "github-access-draft": { path: routePath, savedAt: Date.now(), draft: {
|
||||
sourceUrl, source, terms: "unsaved redaction", options: { update: false, expirationDate: "2030-01-01" },
|
||||
} } });
|
||||
await delay(60);
|
||||
expect(ui.window.document.querySelector("#terms").value).to.equal("unsaved redaction");
|
||||
if (type === "repo") expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
|
||||
expect(ui.window.sessionStorage.getItem("github-access-draft")).to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const type of ["gist", "pr", "repo"]) {
|
||||
for (const status of ["removed", "expired", "error", "ready"]) {
|
||||
it(`submits ${status} ${type} edits and exposes invalid expiration dates`, async function () {
|
||||
|
||||
Reference in New Issue
Block a user