feat: notify owners when GitHub access needs attention

This commit is contained in:
Thomas Durieux committed 2026-10-09 05:22:41 +00:00
1 parent f92cfbe795
commit 95f8aef30a
21 files changed
+548 -38

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const config = require("../src/config").default;
const User = require("../src/core/model/users/users.model").default;
const notifications = require("../src/core/owner-notifications");
const { normalizeEmail, notificationEmail, notifyOwnerAccessProblem } = notifications;
describe("owner access notifications", () => {
const restores = [];
const stub = (object, key, value) => { const old = object[key]; restores.push(() => object[key] = old); object[key] = value; };
let sent, claims, retryWrites, owner, after;
beforeEach(() => {
sent = []; claims = []; retryWrites = []; after = null;
owner = { status: "active", emails: [{ email: "owner@example.com", default: true }] };
stub(config, "RESEND_API_KEY", "test-resend-key");
stub(config, "EMAIL_FROM", "alerts@example.com");
stub(config, "APP_HOSTNAME", "anonymous.example.com");
stub(User, "findOneAndUpdate", (filter, update) => ({ lean: async () => {
claims.push(filter);
if (!owner || ["removed", "banned"].includes(owner.status) || !owner.emails.length || after > new Date()) return null;
after = update.$set.accessAlertAfter;
return owner;
} }));
stub(User, "updateOne", async (filter, update) => { retryWrites.push({ filter, update }); after = update.$set.accessAlertAfter; });
stub(global, "fetch", async (url, options) => { sent.push({ url, options, body: JSON.parse(options.body) }); return { ok: true }; });
});
afterEach(() => { while (restores.length) restores.pop()(); });
it("accepts one mailbox and rejects recipient lists and header injection", () => {
expect(normalizeEmail(" Owner+alerts@example.com ")).to.equal("Owner+alerts@example.com");
for (const value of [undefined, {}, "a@b.com,b@b.com", "Person <a@b.com>", "a@b.com\r\nBcc: x@y.com", ".a@b.com", "a..b@b.com", "a".repeat(65) + "@example.com"]) {
expect(normalizeEmail(value)).to.equal(null);
}
expect(notificationEmail([{ email: "first@example.com", default: false }, { email: "primary@example.com", default: true }])).to.equal("primary@example.com");
});
it("sends one private Resend message across concurrent failures", async () => {
await Promise.all(Array.from({ length: 10 }, () => notifyOwnerAccessProblem("owner-id", "token_expired")));
expect(sent).to.have.length(1);
expect(sent[0].url).to.equal("https://api.resend.com/emails");
expect(sent[0].options.headers.Authorization).to.equal("Bearer test-resend-key");
expect(sent[0].body.to).to.deep.equal(["owner@example.com"]);
expect(sent[0].body.text).to.include("https://anonymous.example.com/connections");
expect(JSON.stringify(sent[0].body)).not.to.include("test-resend-key");
expect(claims[0].status).to.deep.equal({ $nin: ["removed", "banned"] });
expect(claims[0].$or).to.have.length(2);
});
it("ignores disabled delivery, missing recipients and transient failures", async () => {
for (const code of ["github_rate_limit_exceeded", "github_unavailable", "ETIMEDOUT", "quota_exceeded"]) {
await notifyOwnerAccessProblem("owner-id", new Error(code));
}
await notifyOwnerAccessProblem("owner-id", Object.assign(new Error("repo_not_found"), { cause: { status: 503 } }));
await notifyOwnerAccessProblem("owner-id", Object.assign(new Error("token_expired"), { cause: new Error("network timeout") }));
expect(claims).to.have.length(0);
owner.emails = [];
await notifyOwnerAccessProblem("owner-id", "repo_not_found");
owner.emails = [{ email: "owner@example.com", default: true }];
owner.status = "banned";
await notifyOwnerAccessProblem("owner-id", "repo_not_found");
config.RESEND_API_KEY = "";
await notifyOwnerAccessProblem("owner-id", "token_expired");
expect(sent).to.have.length(0);
});
it("backs off after delivery failure without throwing", async () => {
stub(global, "fetch", async () => ({ ok: false }));
await notifyOwnerAccessProblem("owner-id", "repo_not_accessible");
expect(retryWrites).to.have.length(1);
expect(after.getTime() - Date.now()).to.be.within(590000, 600000);
await notifyOwnerAccessProblem("owner-id", "repo_not_accessible");
expect(retryWrites).to.have.length(1);
});
it("does not let database failures escape", async () => {
stub(User, "findOneAndUpdate", () => { throw new Error("database down"); });
await notifyOwnerAccessProblem("owner-id", "token_expired");
expect(sent).to.have.length(0);
});
});
describe("owner email settings", () => {
const utils = require("../src/server/routes/route-utils");
const router = require("../src/server/routes/github-app").githubAppRouter;
const endpoint = router.stack.find(layer => layer.route?.path === "/connections/email").route.stack[0].handle;
const csrf = router.stack.find(layer => !layer.route && layer.handle.toString().includes("x-csrf-token")).handle;
let oldUser, oldUpdate, writes;
beforeEach(() => {
writes = [];
oldUser = utils.getUser; oldUpdate = User.updateOne;
utils.getUser = async () => ({ id: "signed-in-owner" });
User.updateOne = async (...args) => { writes.push(args); };
});
afterEach(() => { utils.getUser = oldUser; User.updateOne = oldUpdate; });
const response = () => ({ statusCode: 200, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; } });
it("updates only the signed-in owner's address", async () => {
const res = response();
await endpoint({ body: { email: " owner@example.com ", ownerId: "someone-else" } }, res);
expect(writes).to.deep.equal([[{ _id: "signed-in-owner" }, { $set: { emails: [{ email: "owner@example.com", default: true }] } }]]);
expect(res.body.notificationEmail).to.equal("owner@example.com");
});
it("rejects malformed input without a database write", async () => {
const res = response();
await endpoint({ body: { email: ["one@example.com", "two@example.com"] } }, res);
expect(res.statusCode).to.equal(400);
expect(writes).to.have.length(0);
});
for (const action of ["later", "never"]) it(`persists ${action} at the right scope`, async () => {
const req = { body: { action }, session: { save: callback => callback() } };
const res = response();
await endpoint(req, res);
expect(req.session.emailPromptDismissed).to.equal(true);
expect(res.body.ok).to.equal(true);
expect(writes).to.deep.equal(action === "never" ? [[{ _id: "signed-in-owner" }, { $set: { emailPromptNever: true } }]] : []);
});
it("rejects unknown preference actions", async () => {
const res = response();
await endpoint({ body: { action: "invalid" } }, res);
expect(res.statusCode).to.equal(400);
expect(writes).to.have.length(0);
});
it("requires the session's CSRF token before changing email", () => {
const res = response(); let next = false;
csrf({ method: "POST", session: { githubConnectionCSRF: "secret" }, headers: {} }, res, () => { next = true; });
expect(res.statusCode).to.equal(403); expect(next).to.equal(false);
csrf({ method: "POST", session: { githubConnectionCSRF: "secret" }, headers: { "x-csrf-token": "secret" } }, response(), () => { next = true; });
expect(next).to.equal(true);
});
});
describe("email popup eligibility", () => {
const utils = require("../src/server/routes/route-utils");
const router = require("../src/server/routes/user").default;
const endpoint = router.stack.find(layer => layer.route?.path === "/" && layer.route.methods.get).route.stack[0].handle;
let oldUser, oldKey, oldFrom;
beforeEach(() => {
oldUser = utils.getUser; oldKey = config.RESEND_API_KEY; oldFrom = config.EMAIL_FROM;
config.RESEND_API_KEY = "test"; config.EMAIL_FROM = "alerts@example.com";
});
afterEach(() => { utils.getUser = oldUser; config.RESEND_API_KEY = oldKey; config.EMAIL_FROM = oldFrom; });
for (const reason of ["eligible", "email", "never", "later", "no-key", "no-sender"]) {
it(`checks ${reason} before showing the form`, async () => {
utils.getUser = async () => ({ username: "owner", model: {
emails: reason === "email" ? [{ email: "owner@example.com", default: true }] : [],
emailPromptNever: reason === "never",
} });
if (reason === "no-key") config.RESEND_API_KEY = "";
if (reason === "no-sender") config.EMAIL_FROM = "";
const req = { session: { emailPromptDismissed: reason === "later", save: cb => cb() } };
const res = { set() {}, json(body) { this.body = body; } };
await endpoint(req, res);
expect(res.body.showEmailPrompt).to.equal(reason === "eligible");
expect(!!res.body.emailPromptCSRF).to.equal(reason === "eligible");
});
}
});
describe("repository notification hooks", () => {
const Repository = require("../src/core/Repository").default;
const RepositoryModel = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const Files = require("../src/core/model/files/files.model").default;
const github = require("../src/core/source/GitHubRepository");
const tokens = require("../src/core/GitHubUtils");
const app = require("../src/core/github-app");
const restores = [];
const stub = (object, key, value) => { const old = object[key]; restores.push(() => object[key] = old); object[key] = value; };
let repo, sent;
beforeEach(() => {
sent = [];
repo = new Repository(new RepositoryModel({ repoId: "anonymous-id", owner: "507f1f77bcf86cd799439011", status: "ready",
source: { repositoryName: "private/source", branch: "main" }, options: { expirationMode: "never", update: true } }));
stub(notifications, "notifyOwnerAccessProblem", async (...args) => { sent.push(args); });
});
afterEach(() => { while (restores.length) restores.pop()(); });
async function failsWith(work, error) {
try { await work(); } catch (caught) { expect(caught).to.equal(error); return; }
throw new Error("Expected original failure");
}
it("notifies the saved owner when an App repository token cannot be resolved", async () => {
repo.model.githubAccess = { kind: "github-app", revision: "test" };
const error = new Error("github_app_access_required");
stub(app, "boundAppToken", async () => { throw error; });
await failsWith(() => tokens.getToken(repo), error);
expect(sent).to.deep.equal([[repo.owner.id, error]]);
});
it("notifies the saved owner when reading the repository tree fails", async () => {
const error = new Error("repo_not_found");
stub(Files, "exists", () => ({ exec: async () => null }));
Object.defineProperty(repo, "source", { value: { getFiles: async () => { throw error; } } });
await failsWith(() => repo.files(), error);
expect(sent).to.deep.equal([[repo.owner.id, error]]);
});
it("notifies the saved owner when a repository refresh cannot read GitHub", async () => {
const error = new Error("repo_not_found");
repo.getToken = async () => "token";
stub(github, "getRepositoryFromGitHub", async () => { throw error; });
await failsWith(() => repo.updateIfNeeded({ force: true }), error);
expect(sent).to.deep.equal([[repo.owner.id, error]]);
});
});