mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
1449 lines
63 KiB
TypeScript
1449 lines
63 KiB
TypeScript
// @vitest-environment node
|
|
|
|
/**
|
|
* Tests for GitHub App installation tracking:
|
|
* 1. upsertInstallation — COALESCE owner, lowercase storage
|
|
* 2. deleteInstallation — removes rows + child repo rows
|
|
* 3. setInstallationSuspended — suspend / unsuspend
|
|
* 4. replaceInstallationRepositories — full replace
|
|
* 5. addInstallationRepositories / removeInstallationRepositories — partial edits
|
|
* 6. handleGithubInstallationEvent — webhook dispatch: created/deleted/suspend/unsuspend
|
|
* 7. handleGithubInstallationRepositoriesEvent — added/removed/selection flip
|
|
* 8. repoAppStatus / repoAppStatusBatch — coverage computation
|
|
* 9. getInstallationsForOwner — owner-scoped read
|
|
* 10. POST /api/webhooks/github-app — installation + installation_repositories route dispatch
|
|
* 11. GET /api/github-app/config — authenticated, owner-scoped installed/accounts fields
|
|
* 12. repo read model — app_status on POST/GET /api/repositories
|
|
* 13. recordInstallationFromSetup — fetch stubbed, upsert + replace exercised
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { Miniflare } from "miniflare";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { createTestWebSession, seedUser, setupMiniflare } from "./helpers/db";
|
|
|
|
const WEBHOOK_SECRET = "test-webhook-secret-xyz";
|
|
const OIDC_ISSUER = "https://github-installations.realmroot.test";
|
|
const AK_PUBLIC_ORIGIN = "http://localhost";
|
|
|
|
let db: D1Database;
|
|
let mf: Miniflare;
|
|
|
|
// Pre-computed throwaway RSA-2048 PKCS#8 private key (PEM). Embedded as a
|
|
// static constant so crypto.subtle.generateKey is never called at runtime,
|
|
// which previously caused out-of-heap-memory crashes when run repeatedly.
|
|
const sharedPrivateKey =
|
|
"-----BEGIN PRIVATE KEY-----\n" +
|
|
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDeWh/EeP4jqpGO\n" +
|
|
"kRUr2VgNWqS1Q4XRir9JfCHxgWuKoodrTa6dSJtBbFEoLYjHA2CRpr+xTs6OsTAO\n" +
|
|
"9IKJSoZBkzyyMItkY7MOzGCU1diPmtNqvV+W1Q9EmrJQ/w7VekIv9CeGWcBe3YOY\n" +
|
|
"8bJ8ZP7atroxo/gFAP5NYXaYygMSVPYgAUeC/VHGwYbB1hnYJBUV4qhy9fgTGnmD\n" +
|
|
"AXPg0/EnAJL5BXdSxa2B+PtytFOBl2709f+e/BhxsVMY5NPikQczhghraA/6/4o4\n" +
|
|
"lv+ZEFr7qVoFqthFxPUMVA4iOS/QEPpBIDjh6Db1Yvkbe5h/Bol2yHhJrGmjE+AP\n" +
|
|
"BcMwUmoFAgMBAAECggEANoF2uJmlSNZy7HUVsMRMYKen6RRGjT17Ezc19ebxFxCs\n" +
|
|
"7AmkpIMsJd84zMXOueRSy5mJ85u7KED4pC3dguytGQ2QCyk5vk/vUJEamtmKBvff\n" +
|
|
"3BJUiJutbLaUQCUp/HxGFc2+06EUNl0MOZWEGJjEXZZ98ZW9gnKCJDNgWGdq1dbf\n" +
|
|
"beny1KbMd7Lmtkp+fVCC/r/EOhkRrc/ltgYRrcGyG6KdvWBidI/ugAF3qgN9Kc1p\n" +
|
|
"9TkvbmISN14xgLtxeYZbW9jG9uMcxEi4Yo1xD6eZkLJzf6Ca0cYi98DpTx4JBKy6\n" +
|
|
"EiXtTXHHiLXLxO02Y9VrcgYxiPUeXqnT2eyxNpsxYQKBgQDwuJbXB/VVkuHy9jIn\n" +
|
|
"JohuCvjWJxrShpsxDhBHCcbG8rsFL0KfvfZZ+k2hO0SZyNN51g4cSXGcvi+9PXTp\n" +
|
|
"7AotmPc4h5eQx0lYNKteiDD5Agl3GTa7h8H0kyHAL1bZt2xN6Ui0xArhDHWIQwJG\n" +
|
|
"YRU5T4uw2KkZJDQUFu/u68rTpQKBgQDsdw/aVttNLjDMrSpX7Iwx0Qd8IT/Cit/l\n" +
|
|
"i6IRCEC2epTVIh9tXwwDiH+csJR2/kLEp/sC9ceJYN5fgALInZF39GfAGYOjzWnA\n" +
|
|
"ng37tfDbHsv0oE5zYgH0SfN70jxa46zjRetCplKiMeeUvl9lvX2zOuk0+fGuXUGS\n" +
|
|
"4saFuy/u4QKBgQDG8ZdgQaiFv63TUZtjddodMB41Rv5I7YxG/3t+alsIw0TDZSqn\n" +
|
|
"wKRf+pi73rK0ciAsujbRM/WceCYWPTtptHU4+Amhg5ZExh8csfLLXr0ynndaIdF1\n" +
|
|
"LR6j1hF3tugNaSUuQtWe58Kh+d0M72xq5ANZaR9m2bjvGVedHtPO3rqzLQKBgHcl\n" +
|
|
"ruk3Jp0HDzOydUmEOUfIqVrUbgoaa6J/7xNh8yl/LosN/IPhhm4pUxOircwfZYkt\n" +
|
|
"kv701KvWEXZRTBXFv0yP688RjBD3KbgSa71O+aOPKvmB5MWitpVexb64Og0Z9z01\n" +
|
|
"N8uHfs+XEbcTDYJ4LmQm5Ob6odpXxvi6J4muvgJBAoGADJHDSJsj1jXzV9LqZQIA\n" +
|
|
"b3mePaxUJa6jQlGQTKr1baOJtKFXZXvSP4zvKb/LiVtjyj1vqktn0D2FrCyYsXON\n" +
|
|
"HamHhIsBF3eNRT4VUSqyGh3UyCKuLinmpQtX5W9CPWAkqYaMvhUCZqLA9FMc+nv4\n" +
|
|
"8or33ehPHwLc5KQfnNaXXXY=\n" +
|
|
"-----END PRIVATE KEY-----\n";
|
|
|
|
function makeEnv(overrides: Record<string, unknown> = {}): any {
|
|
return {
|
|
DB: db,
|
|
EMAIL: { send: async () => ({ messageId: "test" }) } as any,
|
|
ASSETS: null as any,
|
|
AUTH_SECRET: "test-secret-32-chars-minimum-ok!!",
|
|
ALLOWED_HOSTS: "localhost:8788",
|
|
GITHUB_CLIENT_ID: "x",
|
|
GITHUB_CLIENT_SECRET: "x",
|
|
GITHUB_APP_WEBHOOK_SECRET: WEBHOOK_SECRET,
|
|
OIDC_ISSUER,
|
|
AK_PUBLIC_ORIGIN,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function signWebhookBody(body: string, secret: string = WEBHOOK_SECRET): Promise<string> {
|
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
|
|
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
return `sha256=${hex}`;
|
|
}
|
|
|
|
async function apiRequest(
|
|
method: string,
|
|
path: string,
|
|
body?: string,
|
|
headers: Record<string, string> = {},
|
|
envOverrides: Record<string, unknown> = {},
|
|
token?: string,
|
|
) {
|
|
const { api } = await import("../server/http/app");
|
|
const allHeaders: Record<string, string> = { "Content-Type": "application/json", Host: "localhost:8788", "x-forwarded-proto": "http", ...headers };
|
|
if (token?.includes(":csrf:")) {
|
|
const [sessionToken, csrfToken] = token.split(":csrf:");
|
|
allHeaders.cookie = `ak_session=${sessionToken}`;
|
|
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") allHeaders["x-csrf-token"] = csrfToken;
|
|
} else if (token) {
|
|
allHeaders.Authorization = `Bearer ${token}`;
|
|
}
|
|
const init: RequestInit = {
|
|
method,
|
|
headers: allHeaders,
|
|
...(body !== undefined ? { body } : {}),
|
|
};
|
|
return api.request(path, init, makeEnv(envOverrides));
|
|
}
|
|
|
|
async function createVerifiedUserToken(): Promise<{ token: string; userId: string }> {
|
|
const userId = `github-config-${randomUUID()}`;
|
|
const email = `config-test-${randomUUID()}@test.local`;
|
|
await seedUser(db, userId, email);
|
|
const session = await createTestWebSession(db, userId, { email });
|
|
return { token: `${session.token}:csrf:${session.csrfToken}`, userId };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
({ mf, db } = await setupMiniflare());
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mf.dispose();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ─── 1. upsertInstallation ────────────────────────────────────────────────────
|
|
|
|
describe("upsertInstallation", () => {
|
|
it("inserts a new installation row", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: "owner-insert-test",
|
|
accountLogin: "MyOrg",
|
|
accountId: 99,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const row = await db.prepare("SELECT * FROM github_installations WHERE installation_id = ?").bind(id).first<any>();
|
|
expect(row).not.toBeNull();
|
|
expect(row.owner_id).toBe("owner-insert-test");
|
|
expect(row.account_login).toBe("myorg");
|
|
});
|
|
|
|
it("stores account_login lowercased", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 200_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "UpperCaseOrg",
|
|
accountId: 100,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const row = await db
|
|
.prepare("SELECT account_login FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ account_login: string }>();
|
|
expect(row!.account_login).toBe("uppercaseorg");
|
|
});
|
|
|
|
it("COALESCE: does NOT overwrite an existing owner_id when upserted with null owner", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 300_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: "original-owner",
|
|
accountLogin: "acme",
|
|
accountId: 201,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
// Second upsert with no ownerId — must not clobber
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: null,
|
|
accountLogin: "acme",
|
|
accountId: 201,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const row = await db.prepare("SELECT owner_id FROM github_installations WHERE installation_id = ?").bind(id).first<{ owner_id: string | null }>();
|
|
expect(row!.owner_id).toBe("original-owner");
|
|
});
|
|
|
|
it("COALESCE: sets owner_id when first inserted with null then upserted with a value", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 400_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: null,
|
|
accountLogin: "nullfirst",
|
|
accountId: 202,
|
|
accountType: "User",
|
|
repositorySelection: "all",
|
|
});
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: "late-owner",
|
|
accountLogin: "nullfirst",
|
|
accountId: 202,
|
|
accountType: "User",
|
|
repositorySelection: "all",
|
|
});
|
|
const row = await db.prepare("SELECT owner_id FROM github_installations WHERE installation_id = ?").bind(id).first<{ owner_id: string | null }>();
|
|
expect(row!.owner_id).toBe("late-owner");
|
|
});
|
|
|
|
it("updates repository_selection on conflict", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 500_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "seltest",
|
|
accountId: 203,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "seltest",
|
|
accountId: 203,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
const row = await db
|
|
.prepare("SELECT repository_selection FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ repository_selection: string }>();
|
|
expect(row!.repository_selection).toBe("selected");
|
|
});
|
|
});
|
|
|
|
// ─── 2. deleteInstallation ────────────────────────────────────────────────────
|
|
|
|
describe("deleteInstallation", () => {
|
|
it("removes the installation row", async () => {
|
|
const { upsertInstallation, deleteInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 600_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "deltest",
|
|
accountId: 300,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
await deleteInstallation(db, id);
|
|
const row = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(id).first();
|
|
expect(row).toBeNull();
|
|
});
|
|
|
|
it("removes child repository rows when installation is deleted", async () => {
|
|
const { upsertInstallation, deleteInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 700_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "delwithrepos",
|
|
accountId: 301,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "delwithrepos/repo1" }]);
|
|
await deleteInstallation(db, id);
|
|
const repoRow = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).first();
|
|
expect(repoRow).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── 3. setInstallationSuspended ─────────────────────────────────────────────
|
|
|
|
describe("setInstallationSuspended", () => {
|
|
it("sets suspended_at to a timestamp when suspended", async () => {
|
|
const { upsertInstallation, setInstallationSuspended } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 800_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "susptest",
|
|
accountId: 400,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const ts = new Date().toISOString();
|
|
await setInstallationSuspended(db, id, ts);
|
|
const row = await db
|
|
.prepare("SELECT suspended_at FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ suspended_at: string | null }>();
|
|
expect(row!.suspended_at).toBe(ts);
|
|
});
|
|
|
|
it("clears suspended_at (unsuspend) by setting null", async () => {
|
|
const { upsertInstallation, setInstallationSuspended } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 810_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "unsusptest",
|
|
accountId: 401,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
suspendedAt: new Date().toISOString(),
|
|
});
|
|
await setInstallationSuspended(db, id, null);
|
|
const row = await db
|
|
.prepare("SELECT suspended_at FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ suspended_at: string | null }>();
|
|
expect(row!.suspended_at).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── 4. replaceInstallationRepositories ──────────────────────────────────────
|
|
|
|
describe("replaceInstallationRepositories", () => {
|
|
it("inserts repos from the list", async () => {
|
|
const { upsertInstallation, replaceInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 900_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "replacetest",
|
|
accountId: 500,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await replaceInstallationRepositories(db, id, [{ fullName: "replacetest/alpha" }, { fullName: "replacetest/beta" }]);
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
const names = rows.results.map((r) => r.full_name).sort();
|
|
expect(names).toEqual(["replacetest/alpha", "replacetest/beta"]);
|
|
});
|
|
|
|
it("replaces previous repos with the new list", async () => {
|
|
const { upsertInstallation, replaceInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 910_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "replaceover",
|
|
accountId: 501,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await replaceInstallationRepositories(db, id, [{ fullName: "replaceover/old" }]);
|
|
await replaceInstallationRepositories(db, id, [{ fullName: "replaceover/new" }]);
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name)).toEqual(["replaceover/new"]);
|
|
});
|
|
|
|
it("stores full_name lowercased", async () => {
|
|
const { upsertInstallation, replaceInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 920_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "lowerrepo",
|
|
accountId: 502,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await replaceInstallationRepositories(db, id, [{ fullName: "LowerRepo/UpperCase" }]);
|
|
const row = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ full_name: string }>();
|
|
expect(row!.full_name).toBe("lowerrepo/uppercase");
|
|
});
|
|
|
|
it("empties the repo list when passed an empty array", async () => {
|
|
const { upsertInstallation, replaceInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 930_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "emptyreplace",
|
|
accountId: 503,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
await replaceInstallationRepositories(db, id, [{ fullName: "emptyreplace/x" }]);
|
|
await replaceInstallationRepositories(db, id, []);
|
|
const rows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─── 5. addInstallationRepositories / removeInstallationRepositories ──────────
|
|
|
|
describe("addInstallationRepositories", () => {
|
|
it("inserts the given repos", async () => {
|
|
const { upsertInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_000_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "addtest",
|
|
accountId: 600,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [
|
|
{ fullName: "addtest/r1", repoId: 1 },
|
|
{ fullName: "addtest/r2", repoId: 2 },
|
|
]);
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name).sort()).toEqual(["addtest/r1", "addtest/r2"]);
|
|
});
|
|
|
|
it("is a no-op when repos array is empty", async () => {
|
|
const { upsertInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_010_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "addnoop",
|
|
accountId: 601,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, []);
|
|
const rows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(0);
|
|
});
|
|
|
|
it("is idempotent (INSERT OR IGNORE)", async () => {
|
|
const { upsertInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_020_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "addidempotent",
|
|
accountId: 602,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "addidempotent/r1" }]);
|
|
await addInstallationRepositories(db, id, [{ fullName: "addidempotent/r1" }]);
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("removeInstallationRepositories", () => {
|
|
it("removes the specified repos", async () => {
|
|
const { upsertInstallation, addInstallationRepositories, removeInstallationRepositories } = await import(
|
|
"../server/adapters/github/githubInstallations"
|
|
);
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "rmtest",
|
|
accountId: 700,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "rmtest/keep" }, { fullName: "rmtest/remove" }]);
|
|
await removeInstallationRepositories(db, id, ["rmtest/remove"]);
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name)).toEqual(["rmtest/keep"]);
|
|
});
|
|
|
|
it("is a no-op when fullNames array is empty", async () => {
|
|
const { upsertInstallation, addInstallationRepositories, removeInstallationRepositories } = await import(
|
|
"../server/adapters/github/githubInstallations"
|
|
);
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_110_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "rmnoop",
|
|
accountId: 701,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "rmnoop/r1" }]);
|
|
await removeInstallationRepositories(db, id, []);
|
|
const rows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(1);
|
|
});
|
|
|
|
it("matches case-insensitively (lowercases input before delete)", async () => {
|
|
const { upsertInstallation, addInstallationRepositories, removeInstallationRepositories } = await import(
|
|
"../server/adapters/github/githubInstallations"
|
|
);
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_120_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "rmcase",
|
|
accountId: 702,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "rmcase/MyRepo" }]);
|
|
await removeInstallationRepositories(db, id, ["RmCase/MyRepo"]);
|
|
const rows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─── 6. handleGithubInstallationEvent ────────────────────────────────────────
|
|
|
|
describe("handleGithubInstallationEvent", () => {
|
|
it("returns handled:false when installation id is missing", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const result = await handleGithubInstallationEvent(db, { action: "created", installation: undefined });
|
|
expect(result.handled).toBe(false);
|
|
});
|
|
|
|
it("inserts a new installation row on 'created' with selection 'all'", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_300_000;
|
|
const result = await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "AllOrg", id: 50, type: "Organization" }, repository_selection: "all" },
|
|
});
|
|
expect(result.handled).toBe(true);
|
|
expect(result.action).toBe("created");
|
|
const row = await db
|
|
.prepare("SELECT account_login, repository_selection FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<any>();
|
|
expect(row!.account_login).toBe("allorg");
|
|
expect(row!.repository_selection).toBe("all");
|
|
});
|
|
|
|
it("owner remains NULL when no github account row matches", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_310_000;
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "unknownorg", id: 12345, type: "Organization" }, repository_selection: "all" },
|
|
});
|
|
const row = await db.prepare("SELECT owner_id FROM github_installations WHERE installation_id = ?").bind(id).first<{ owner_id: string | null }>();
|
|
expect(row!.owner_id).toBeNull();
|
|
});
|
|
|
|
it("seeds selected-repo rows when selection is 'selected' with repositories in payload", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_320_000;
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "SelOrg", id: 51, type: "Organization" }, repository_selection: "selected" },
|
|
repositories: [
|
|
{ full_name: "SelOrg/repo1", id: 1001 },
|
|
{ full_name: "SelOrg/repo2", id: 1002 },
|
|
],
|
|
});
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name).sort()).toEqual(["selorg/repo1", "selorg/repo2"]);
|
|
});
|
|
|
|
it("deletes the installation row on 'deleted'", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_340_000;
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "todelete", id: 52, type: "Organization" }, repository_selection: "all" },
|
|
});
|
|
const result = await handleGithubInstallationEvent(db, {
|
|
action: "deleted",
|
|
installation: { id },
|
|
});
|
|
expect(result.handled).toBe(true);
|
|
const row = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(id).first();
|
|
expect(row).toBeNull();
|
|
});
|
|
|
|
it("deletes selected-repo rows when installation is deleted", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_350_000;
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "deletewithrepos", id: 53, type: "Organization" }, repository_selection: "selected" },
|
|
repositories: [{ full_name: "deletewithrepos/r1" }],
|
|
});
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "deleted",
|
|
installation: { id },
|
|
});
|
|
const repoRows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(repoRows.results).toHaveLength(0);
|
|
});
|
|
|
|
it("sets suspended_at on 'suspend'", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_360_000;
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "susporg", id: 54, type: "Organization" }, repository_selection: "all" },
|
|
});
|
|
const ts = new Date().toISOString();
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "suspend",
|
|
installation: { id, suspended_at: ts },
|
|
});
|
|
const row = await db
|
|
.prepare("SELECT suspended_at FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ suspended_at: string | null }>();
|
|
expect(row!.suspended_at).toBe(ts);
|
|
});
|
|
|
|
it("clears suspended_at on 'unsuspend'", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_370_000;
|
|
const ts = new Date().toISOString();
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id, account: { login: "unsusporg", id: 55, type: "Organization" }, repository_selection: "all", suspended_at: ts },
|
|
});
|
|
await handleGithubInstallationEvent(db, {
|
|
action: "unsuspend",
|
|
installation: { id },
|
|
});
|
|
const row = await db
|
|
.prepare("SELECT suspended_at FROM github_installations WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ suspended_at: string | null }>();
|
|
expect(row!.suspended_at).toBeNull();
|
|
});
|
|
|
|
it("is idempotent: sending 'created' twice results in one row", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_380_000;
|
|
const payload = {
|
|
action: "created" as const,
|
|
installation: { id, account: { login: "idempotentorg", id: 56, type: "Organization" }, repository_selection: "all" },
|
|
};
|
|
await handleGithubInstallationEvent(db, payload);
|
|
await handleGithubInstallationEvent(db, payload);
|
|
const rows = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(1);
|
|
});
|
|
|
|
it("returns handled:false and skips upsert when account info is missing", async () => {
|
|
const { handleGithubInstallationEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_390_000;
|
|
const result = await handleGithubInstallationEvent(db, {
|
|
action: "created",
|
|
installation: { id },
|
|
});
|
|
expect(result.handled).toBe(false);
|
|
const row = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(id).first();
|
|
expect(row).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── 8. handleGithubInstallationRepositoriesEvent ────────────────────────────
|
|
|
|
describe("handleGithubInstallationRepositoriesEvent", () => {
|
|
it("returns handled:false when installation id is missing", async () => {
|
|
const { handleGithubInstallationRepositoriesEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const result = await handleGithubInstallationRepositoriesEvent(db, { action: "added", installation: undefined });
|
|
expect(result.handled).toBe(false);
|
|
});
|
|
|
|
it("adds repository rows on 'added' action", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const { handleGithubInstallationRepositoriesEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_400_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "addrepoorg",
|
|
accountId: 800,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await handleGithubInstallationRepositoriesEvent(db, {
|
|
action: "added",
|
|
installation: { id, account: { login: "addrepoorg", id: 800, type: "Organization" }, repository_selection: "selected" },
|
|
repository_selection: "selected",
|
|
repositories_added: [{ full_name: "addrepoorg/new-repo", id: 9001 }],
|
|
repositories_removed: [],
|
|
});
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name)).toContain("addrepoorg/new-repo");
|
|
});
|
|
|
|
it("removes repository rows on 'removed' action", async () => {
|
|
const { upsertInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const { handleGithubInstallationRepositoriesEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_410_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "rmrepoorg",
|
|
accountId: 801,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "rmrepoorg/old-repo" }, { fullName: "rmrepoorg/keep-repo" }]);
|
|
await handleGithubInstallationRepositoriesEvent(db, {
|
|
action: "removed",
|
|
installation: { id, account: { login: "rmrepoorg", id: 801, type: "Organization" }, repository_selection: "selected" },
|
|
repository_selection: "selected",
|
|
repositories_added: [],
|
|
repositories_removed: [{ full_name: "rmrepoorg/old-repo", id: 9002 }],
|
|
});
|
|
const rows = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.all<{ full_name: string }>();
|
|
expect(rows.results.map((r) => r.full_name)).toEqual(["rmrepoorg/keep-repo"]);
|
|
});
|
|
|
|
it("clears selected-repo rows when selection flips to 'all'", async () => {
|
|
const { upsertInstallation, addInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const { handleGithubInstallationRepositoriesEvent } = await import("../server/adapters/github/githubWebhook");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 1_420_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "fliptoall",
|
|
accountId: 802,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
await addInstallationRepositories(db, id, [{ fullName: "fliptoall/r1" }]);
|
|
await handleGithubInstallationRepositoriesEvent(db, {
|
|
action: "added",
|
|
installation: { id, account: { login: "fliptoall", id: 802, type: "Organization" }, repository_selection: "all" },
|
|
repository_selection: "all",
|
|
repositories_added: [],
|
|
repositories_removed: [],
|
|
});
|
|
const rows = await db.prepare("SELECT 1 FROM github_installation_repositories WHERE installation_id = ?").bind(id).all();
|
|
expect(rows.results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─── 9. repoAppStatus / repoAppStatusBatch ───────────────────────────────────
|
|
|
|
describe("repoAppStatus / repoAppStatusBatch", () => {
|
|
const OWNER_A = `status-owner-a-${randomUUID()}`;
|
|
const OWNER_B = `status-owner-b-${randomUUID()}`;
|
|
|
|
beforeAll(async () => {
|
|
await seedUser(db, OWNER_A, `${OWNER_A}@test.local`);
|
|
await seedUser(db, OWNER_B, `${OWNER_B}@test.local`);
|
|
});
|
|
|
|
let installForCounter = 9_000_000;
|
|
async function installFor(
|
|
ownerId: string,
|
|
accountLogin: string,
|
|
accountId: number,
|
|
selection: "all" | "selected",
|
|
repos: string[] = [],
|
|
suspendedAt: string | null = null,
|
|
) {
|
|
const { upsertInstallation, replaceInstallationRepositories } = await import("../server/adapters/github/githubInstallations");
|
|
const id = installForCounter++;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId,
|
|
accountLogin,
|
|
accountId,
|
|
accountType: "Organization",
|
|
repositorySelection: selection,
|
|
suspendedAt,
|
|
});
|
|
if (selection === "selected" && repos.length > 0) {
|
|
await replaceInstallationRepositories(
|
|
db,
|
|
id,
|
|
repos.map((r) => ({ fullName: r })),
|
|
);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
it("returns 'covered' for any repo under account when selection is 'all'", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_A, "allcovered-acme", 1001, "all");
|
|
expect(await repoAppStatus(db, OWNER_A, "allcovered-acme/anything")).toBe("covered");
|
|
expect(await repoAppStatus(db, OWNER_A, "allcovered-acme/other")).toBe("covered");
|
|
});
|
|
|
|
it("returns 'covered' only for listed repos when selection is 'selected'", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_A, "selcovered-corp", 1002, "selected", ["selcovered-corp/listed-repo"]);
|
|
expect(await repoAppStatus(db, OWNER_A, "selcovered-corp/listed-repo")).toBe("covered");
|
|
expect(await repoAppStatus(db, OWNER_A, "selcovered-corp/unlisted-repo")).toBe("not_covered");
|
|
});
|
|
|
|
it("returns 'suspended' when installation suspended_at is set", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_A, "suspendedacct", 1003, "all", [], new Date().toISOString());
|
|
expect(await repoAppStatus(db, OWNER_A, "suspendedacct/any-repo")).toBe("suspended");
|
|
});
|
|
|
|
it("returns 'app_not_installed' when no installation exists for the account", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
expect(await repoAppStatus(db, OWNER_A, "nonexistent-account/repo")).toBe("app_not_installed");
|
|
});
|
|
|
|
it("is case-insensitive (Acme/Repo vs stored acme/repo)", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_A, "CaseOrg", 1004, "selected", ["CaseOrg/MyRepo"]);
|
|
expect(await repoAppStatus(db, OWNER_A, "CaseOrg/MyRepo")).toBe("covered");
|
|
expect(await repoAppStatus(db, OWNER_A, "caseorg/myrepo")).toBe("covered");
|
|
});
|
|
|
|
it("cross-tenant isolation: owner B's install on 'sharedacct' does not cover owner A's query", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_B, "sharedacct", 1005, "all");
|
|
// Owner A has no installation on sharedacct
|
|
expect(await repoAppStatus(db, OWNER_A, "sharedacct/shared-repo")).toBe("app_not_installed");
|
|
});
|
|
|
|
it("repoAppStatusBatch returns a map keyed by the original input strings", async () => {
|
|
const { repoAppStatusBatch } = await import("../server/adapters/github/githubInstallations");
|
|
await installFor(OWNER_A, "batchacct", 1006, "selected", ["batchacct/repo-a"]);
|
|
const result = await repoAppStatusBatch(db, OWNER_A, ["batchacct/repo-a", "batchacct/repo-b"]);
|
|
expect(result.get("batchacct/repo-a")).toBe("covered");
|
|
expect(result.get("batchacct/repo-b")).toBe("not_covered");
|
|
});
|
|
|
|
it("repoAppStatusBatch returns empty map for empty input", async () => {
|
|
const { repoAppStatusBatch } = await import("../server/adapters/github/githubInstallations");
|
|
const result = await repoAppStatusBatch(db, OWNER_A, []);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── 10. getInstallationsForOwner ────────────────────────────────────────────
|
|
|
|
describe("getInstallationsForOwner", () => {
|
|
it("returns only installations belonging to the given owner", async () => {
|
|
const { upsertInstallation, getInstallationsForOwner } = await import("../server/adapters/github/githubInstallations");
|
|
const owner1 = `owner-list-1-${randomUUID()}`;
|
|
const owner2 = `owner-list-2-${randomUUID()}`;
|
|
const id1 = Math.floor(Math.random() * 1_000_000) + 3_000_000;
|
|
const id2 = Math.floor(Math.random() * 1_000_000) + 3_100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id1,
|
|
ownerId: owner1,
|
|
accountLogin: "owner1acct",
|
|
accountId: 2001,
|
|
accountType: "User",
|
|
repositorySelection: "all",
|
|
});
|
|
await upsertInstallation(db, {
|
|
installationId: id2,
|
|
ownerId: owner2,
|
|
accountLogin: "owner2acct",
|
|
accountId: 2002,
|
|
accountType: "User",
|
|
repositorySelection: "all",
|
|
});
|
|
const result = await getInstallationsForOwner(db, owner1);
|
|
const ids = result.map((i) => i.installationId);
|
|
expect(ids).toContain(id1);
|
|
expect(ids).not.toContain(id2);
|
|
});
|
|
|
|
it("returns empty array when owner has no installations", async () => {
|
|
const { getInstallationsForOwner } = await import("../server/adapters/github/githubInstallations");
|
|
const result = await getInstallationsForOwner(db, "owner-with-no-installs");
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("parses fields correctly from the row", async () => {
|
|
const { upsertInstallation, getInstallationsForOwner } = await import("../server/adapters/github/githubInstallations");
|
|
const owner = `owner-parse-${randomUUID()}`;
|
|
const id = Math.floor(Math.random() * 1_000_000) + 3_200_000;
|
|
const ts = new Date().toISOString();
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: owner,
|
|
accountLogin: "ParseOrg",
|
|
accountId: 2003,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
suspendedAt: ts,
|
|
});
|
|
const [install] = await getInstallationsForOwner(db, owner);
|
|
expect(install.installationId).toBe(id);
|
|
expect(install.accountLogin).toBe("parseorg");
|
|
expect(install.accountType).toBe("Organization");
|
|
expect(install.repositorySelection).toBe("selected");
|
|
expect(install.suspendedAt).toBe(ts);
|
|
});
|
|
});
|
|
|
|
// ─── 11. Webhook route — installation + installation_repositories events ──────
|
|
|
|
describe("POST /api/webhooks/github-app — installation events", () => {
|
|
it("handles installation 'created' event via route", async () => {
|
|
const id = Math.floor(Math.random() * 1_000_000) + 4_000_000;
|
|
const payload = JSON.stringify({
|
|
action: "created",
|
|
installation: { id, account: { login: "webhookroute-org", id: 3001, type: "Organization" }, repository_selection: "all" },
|
|
});
|
|
const sig = await signWebhookBody(payload);
|
|
const res = await apiRequest("POST", "/api/webhooks/github-app", payload, {
|
|
"x-hub-signature-256": sig,
|
|
"x-github-event": "installation",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.ok).toBe(true);
|
|
expect(json.handled).toBe(true);
|
|
expect(json.action).toBe("created");
|
|
// Verify persistence
|
|
const row = await db.prepare("SELECT account_login FROM github_installations WHERE installation_id = ?").bind(id).first<any>();
|
|
expect(row!.account_login).toBe("webhookroute-org");
|
|
});
|
|
|
|
it("handles installation 'deleted' event via route", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 4_100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "routedelete-org",
|
|
accountId: 3002,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const payload = JSON.stringify({ action: "deleted", installation: { id } });
|
|
const sig = await signWebhookBody(payload);
|
|
const res = await apiRequest("POST", "/api/webhooks/github-app", payload, {
|
|
"x-hub-signature-256": sig,
|
|
"x-github-event": "installation",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const row = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(id).first();
|
|
expect(row).toBeNull();
|
|
});
|
|
|
|
it("handles installation_repositories 'added' event via route", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 4_200_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
accountLogin: "routeaddrepo",
|
|
accountId: 3003,
|
|
accountType: "Organization",
|
|
repositorySelection: "selected",
|
|
});
|
|
const payload = JSON.stringify({
|
|
action: "added",
|
|
installation: { id, account: { login: "routeaddrepo", id: 3003, type: "Organization" }, repository_selection: "selected" },
|
|
repository_selection: "selected",
|
|
repositories_added: [{ full_name: "routeaddrepo/via-webhook", id: 8001 }],
|
|
repositories_removed: [],
|
|
});
|
|
const sig = await signWebhookBody(payload);
|
|
const res = await apiRequest("POST", "/api/webhooks/github-app", payload, {
|
|
"x-hub-signature-256": sig,
|
|
"x-github-event": "installation_repositories",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const row = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(id)
|
|
.first<{ full_name: string }>();
|
|
expect(row!.full_name).toBe("routeaddrepo/via-webhook");
|
|
});
|
|
|
|
it("returns 200 handled:false for unknown event type", async () => {
|
|
const payload = JSON.stringify({ action: "foo" });
|
|
const sig = await signWebhookBody(payload);
|
|
const res = await apiRequest("POST", "/api/webhooks/github-app", payload, {
|
|
"x-hub-signature-256": sig,
|
|
"x-github-event": "release",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.handled).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── 12. GET /api/github-app/config (authenticated) ─────────────────────────
|
|
|
|
describe("GET /api/github-app/config", () => {
|
|
it("returns 401 when no Authorization header is provided", async () => {
|
|
const res = await apiRequest("GET", "/api/github-app/config");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns configured:false + installed:false + accounts:[] when GITHUB_APP_ID is not set and owner has no installations", async () => {
|
|
const { token } = await createVerifiedUserToken();
|
|
const res = await apiRequest(
|
|
"GET",
|
|
"/api/github-app/config",
|
|
undefined,
|
|
{},
|
|
{
|
|
GITHUB_APP_ID: undefined,
|
|
GITHUB_APP_PRIVATE_KEY: undefined,
|
|
GITHUB_APP_SLUG: undefined,
|
|
},
|
|
token,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.configured).toBe(false);
|
|
expect(json.slug).toBeNull();
|
|
expect(json.install_url).toBeNull();
|
|
expect(json.installed).toBe(false);
|
|
expect(json.accounts).toEqual([]);
|
|
});
|
|
|
|
it("returns configured:true + slug + install_url when env is set and owner has no installations", async () => {
|
|
const { token } = await createVerifiedUserToken();
|
|
const res = await apiRequest(
|
|
"GET",
|
|
"/api/github-app/config",
|
|
undefined,
|
|
{},
|
|
{
|
|
GITHUB_APP_ID: "123",
|
|
GITHUB_APP_PRIVATE_KEY: "fake-key",
|
|
GITHUB_APP_SLUG: "agent-kanban",
|
|
},
|
|
token,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.configured).toBe(true);
|
|
expect(json.slug).toBe("agent-kanban");
|
|
expect(json.install_url).toBe("https://github.com/apps/agent-kanban/installations/new");
|
|
expect(json.installed).toBe(false);
|
|
expect(json.accounts).toEqual([]);
|
|
});
|
|
|
|
it("returns installed:true + accounts list when owner has a non-suspended installation", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const { token, userId } = await createVerifiedUserToken();
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 8_000_000;
|
|
await upsertInstallation(db, {
|
|
installationId: installId,
|
|
ownerId: userId,
|
|
accountLogin: "config-test-org",
|
|
accountId: 99001,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const res = await apiRequest(
|
|
"GET",
|
|
"/api/github-app/config",
|
|
undefined,
|
|
{},
|
|
{
|
|
GITHUB_APP_ID: "123",
|
|
GITHUB_APP_PRIVATE_KEY: "fake-key",
|
|
GITHUB_APP_SLUG: "agent-kanban",
|
|
},
|
|
token,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.installed).toBe(true);
|
|
expect(json.accounts).toContain("config-test-org");
|
|
});
|
|
|
|
it("returns installed:false when owner's installation is suspended", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const { token, userId } = await createVerifiedUserToken();
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 8_100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: installId,
|
|
ownerId: userId,
|
|
accountLogin: "suspended-config-org",
|
|
accountId: 99002,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
suspendedAt: new Date().toISOString(),
|
|
});
|
|
const res = await apiRequest("GET", "/api/github-app/config", undefined, {}, {}, token);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.installed).toBe(false);
|
|
expect(json.accounts).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── 13. app_status on repo read model ───────────────────────────────────────
|
|
|
|
describe("app_status on /api/repositories read model", () => {
|
|
// These tests exercise repoAppStatus + repoAppStatusBatch called from route handlers.
|
|
// We test the underlying functions directly (same contract as the routes compute)
|
|
// to avoid needing auth headers, while verifying the read-model contract.
|
|
const OWNER = `repo-model-owner-${randomUUID()}`;
|
|
|
|
beforeAll(async () => {
|
|
await seedUser(db, OWNER, `${OWNER}@test.local`);
|
|
});
|
|
|
|
it("repoAppStatus returns 'covered' after installing app on the account", async () => {
|
|
const { upsertInstallation, repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 5_000_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: OWNER,
|
|
accountLogin: "repomodel-org",
|
|
accountId: 4001,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const status = await repoAppStatus(db, OWNER, "repomodel-org/my-repo");
|
|
expect(status).toBe("covered");
|
|
});
|
|
|
|
it("repoAppStatus returns 'app_not_installed' for a repo with no installation", async () => {
|
|
const { repoAppStatus } = await import("../server/adapters/github/githubInstallations");
|
|
const status = await repoAppStatus(db, OWNER, "no-install-org/random-repo");
|
|
expect(status).toBe("app_not_installed");
|
|
});
|
|
|
|
it("repoAppStatusBatch includes all requested repos in result map", async () => {
|
|
const { upsertInstallation, repoAppStatusBatch } = await import("../server/adapters/github/githubInstallations");
|
|
const id = Math.floor(Math.random() * 1_000_000) + 5_100_000;
|
|
await upsertInstallation(db, {
|
|
installationId: id,
|
|
ownerId: OWNER,
|
|
accountLogin: "batchmodel-org",
|
|
accountId: 4002,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
const result = await repoAppStatusBatch(db, OWNER, ["batchmodel-org/r1", "other-org/r2"]);
|
|
expect(result.get("batchmodel-org/r1")).toBe("covered");
|
|
expect(result.get("other-org/r2")).toBe("app_not_installed");
|
|
});
|
|
});
|
|
|
|
// ─── 14. recordInstallationFromSetup (fetch stubbed) ─────────────────────────
|
|
|
|
describe("recordInstallationFromSetup", () => {
|
|
const OWNER = `setup-owner-${randomUUID()}`;
|
|
|
|
beforeAll(async () => {
|
|
await seedUser(db, OWNER, `${OWNER}@test.local`);
|
|
});
|
|
|
|
// Stub RSA crypto before each test to prevent OOM. githubAppJwt calls
|
|
// crypto.subtle.importKey + crypto.subtle.sign for every JWT. The mocked
|
|
// fetch in each test never validates JWT contents, so a fake signature is
|
|
// fine. vi.spyOn intercepts calls at the object-property level; the outer
|
|
// afterEach's vi.restoreAllMocks() clears the spy, so we re-apply in each
|
|
// beforeEach.
|
|
beforeEach(() => {
|
|
// Capture originals BEFORE spying to avoid infinite recursion in the passthrough.
|
|
const realImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
|
const realSign = crypto.subtle.sign.bind(crypto.subtle);
|
|
vi.spyOn(crypto.subtle, "importKey").mockImplementation(async (format: any, keyData: any, algorithm: any, extractable: any, keyUsages: any) => {
|
|
const alg = typeof algorithm === "string" ? algorithm : (algorithm as { name: string }).name;
|
|
if (alg === "RSASSA-PKCS1-v1_5") {
|
|
return { type: "private", extractable: false, algorithm: { name: "RSASSA-PKCS1-v1_5" }, usages: ["sign"] } as unknown as CryptoKey;
|
|
}
|
|
return realImportKey(format, keyData, algorithm, extractable, keyUsages);
|
|
});
|
|
vi.spyOn(crypto.subtle, "sign").mockImplementation(async (algorithm: any, key: any, data: any) => {
|
|
const alg = typeof algorithm === "string" ? algorithm : (algorithm as { name: string }).name;
|
|
if (alg === "RSASSA-PKCS1-v1_5") {
|
|
return new Uint8Array(256).buffer;
|
|
}
|
|
return realSign(algorithm, key, data);
|
|
});
|
|
});
|
|
|
|
it("upserts the installation under the owner when fetch returns 'all' selection", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_000_000;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes(`/app/installations/${installId}`)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: installId,
|
|
account: { login: "setup-all-org", id: 5001, type: "Organization" },
|
|
repository_selection: "all",
|
|
suspended_at: null,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
const details = await recordInstallationFromSetup(db, env, OWNER, installId);
|
|
expect(details.id).toBe(installId);
|
|
expect(details.account.login).toBe("setup-all-org");
|
|
|
|
const row = await db
|
|
.prepare("SELECT owner_id, account_login, repository_selection FROM github_installations WHERE installation_id = ?")
|
|
.bind(installId)
|
|
.first<any>();
|
|
expect(row!.owner_id).toBe(OWNER);
|
|
expect(row!.account_login).toBe("setup-all-org");
|
|
expect(row!.repository_selection).toBe("all");
|
|
});
|
|
|
|
it("accepts a GitHub App private key PEM with escaped newlines", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_050_000;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes(`/app/installations/${installId}`)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: installId,
|
|
account: { login: "setup-escaped-key-org", id: 5003, type: "Organization" },
|
|
repository_selection: "all",
|
|
suspended_at: null,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey.replaceAll("\n", "\\n"), GITHUB_APP_SLUG: "agent-kanban" });
|
|
const details = await recordInstallationFromSetup(db, env, OWNER, installId);
|
|
expect(details.account.login).toBe("setup-escaped-key-org");
|
|
});
|
|
|
|
it("accepts GitHub's RSA PRIVATE KEY PEM shape", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_060_000;
|
|
const rsaPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" + "AQIDBAUGBwgJCgsMDQ4PEA==\n" + "-----END RSA PRIVATE KEY-----";
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes(`/app/installations/${installId}`)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: installId,
|
|
account: { login: "setup-rsa-key-org", id: 5004, type: "Organization" },
|
|
repository_selection: "all",
|
|
suspended_at: null,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: rsaPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
const details = await recordInstallationFromSetup(db, env, OWNER, installId);
|
|
expect(details.account.login).toBe("setup-rsa-key-org");
|
|
});
|
|
|
|
it("upserts the installation and stores selected repos when selection is 'selected'", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_100_000;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes(`/app/installations/${installId}`) && !url.includes("access_tokens")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: installId,
|
|
account: { login: "setup-sel-org", id: 5002, type: "Organization" },
|
|
repository_selection: "selected",
|
|
suspended_at: null,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url.includes("access_tokens")) {
|
|
return new Response(JSON.stringify({ token: "ghs_test_token" }), { status: 201, headers: { "content-type": "application/json" } });
|
|
}
|
|
if (url.includes("/installation/repositories")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
total_count: 1,
|
|
repositories: [
|
|
{
|
|
id: 7001,
|
|
name: "selected-repo",
|
|
full_name: "setup-sel-org/selected-repo",
|
|
clone_url: "https://github.com/setup-sel-org/selected-repo.git",
|
|
html_url: "https://github.com/setup-sel-org/selected-repo",
|
|
private: false,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
await recordInstallationFromSetup(db, env, OWNER, installId);
|
|
|
|
const repoRow = await db
|
|
.prepare("SELECT full_name FROM github_installation_repositories WHERE installation_id = ?")
|
|
.bind(installId)
|
|
.first<{ full_name: string }>();
|
|
expect(repoRow!.full_name).toBe("setup-sel-org/selected-repo");
|
|
});
|
|
|
|
it("throws when GitHub API returns non-ok for getInstallation", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_200_000;
|
|
const credentialSentinel = "github-upstream-credential-sentinel";
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
return new Response(credentialSentinel, { status: 404 });
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
const error = await recordInstallationFromSetup(db, env, OWNER, installId).catch((caught: unknown) => caught);
|
|
expect(error).toEqual(new Error(`GitHub get installation ${installId} failed (HTTP 404)`));
|
|
expect(String(error)).not.toContain(credentialSentinel);
|
|
});
|
|
|
|
it("throws when mintInstallationWideToken fails (access_tokens returns non-ok)", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_300_000;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes(`/app/installations/${installId}`) && !url.includes("access_tokens")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: installId,
|
|
account: { login: "wide-token-fail-org", id: 9000, type: "Organization" },
|
|
repository_selection: "selected",
|
|
suspended_at: null,
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url.includes("access_tokens")) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
await expect(recordInstallationFromSetup(db, env, OWNER, installId)).rejects.toThrow(/403/);
|
|
});
|
|
|
|
it("throws when listInstallationRepositories list call returns non-ok", async () => {
|
|
const { listInstallationRepositories } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_400_000;
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes("access_tokens")) {
|
|
return new Response(JSON.stringify({ token: "ghs_list_fail_token" }), { status: 201, headers: { "content-type": "application/json" } });
|
|
}
|
|
if (url.includes("/installation/repositories")) {
|
|
return new Response("Internal Server Error", { status: 500 });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
}),
|
|
);
|
|
|
|
const env = makeEnv({ GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: sharedPrivateKey, GITHUB_APP_SLUG: "agent-kanban" });
|
|
await expect(listInstallationRepositories(env, installId)).rejects.toThrow(/500/);
|
|
});
|
|
|
|
it("throws when GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY is not set", async () => {
|
|
const { recordInstallationFromSetup } = await import("../server/adapters/github/githubApp");
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 6_500_000;
|
|
const env = makeEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined });
|
|
await expect(recordInstallationFromSetup(db, env, OWNER, installId)).rejects.toThrow(/not configured/i);
|
|
});
|
|
});
|
|
|
|
// ─── 15. DELETE /api/repositories — does not touch github_installations ──────
|
|
|
|
describe("DELETE /api/repositories does not remove installation rows", () => {
|
|
const OWNER = `del-repo-owner-${randomUUID()}`;
|
|
|
|
beforeAll(async () => {
|
|
await seedUser(db, OWNER, `${OWNER}@test.local`);
|
|
});
|
|
|
|
it("github_installations row survives after deleting a repository", async () => {
|
|
const { upsertInstallation } = await import("../server/adapters/github/githubInstallations");
|
|
const { createRepository, deleteRepository } = await import("../server/adapters/d1/repositoryRepo");
|
|
|
|
// Install app
|
|
const installId = Math.floor(Math.random() * 1_000_000) + 7_000_000;
|
|
await upsertInstallation(db, {
|
|
installationId: installId,
|
|
ownerId: OWNER,
|
|
accountLogin: "del-repo-acme",
|
|
accountId: 6001,
|
|
accountType: "Organization",
|
|
repositorySelection: "all",
|
|
});
|
|
|
|
// Create and delete repo — fetch must never be called
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(() => {
|
|
throw new Error("fetch must not be called on repo delete");
|
|
}),
|
|
);
|
|
|
|
const repo = await createRepository(db, OWNER, { name: "del-test-repo", url: "https://github.com/del-repo-acme/del-test-repo" });
|
|
await deleteRepository(db, repo.id, OWNER);
|
|
|
|
// Installation row must still be there
|
|
const row = await db.prepare("SELECT 1 FROM github_installations WHERE installation_id = ?").bind(installId).first();
|
|
expect(row).not.toBeNull();
|
|
});
|
|
});
|