diff --git a/migrations/0077_orb_github_installation_account_id.sql b/migrations/0077_orb_github_installation_account_id.sql new file mode 100644 index 000000000..2ad4d4850 --- /dev/null +++ b/migrations/0077_orb_github_installation_account_id.sql @@ -0,0 +1,3 @@ +-- Store the immutable GitHub account id for the Orb App installation owner. Logins can be renamed and +-- eventually reused, so OAuth self-enrollment must bind the admin check to this stable id as well. +ALTER TABLE orb_github_installations ADD COLUMN account_id INTEGER; diff --git a/src/orb/app-auth.ts b/src/orb/app-auth.ts index e0289fb98..1f6d3dcbb 100644 --- a/src/orb/app-auth.ts +++ b/src/orb/app-auth.ts @@ -29,6 +29,7 @@ export interface OrbAppInstallation { id: number; accountLogin: string | null; accountType: string | null; + accountId: number | null; repositorySelection: string | null; } @@ -43,9 +44,9 @@ export async function listOrbAppInstallations(env: Env): Promise; + const rows = (await response.json()) as Array<{ id?: number; account?: { login?: string; type?: string; id?: number } | null; repository_selection?: string }>; for (const row of rows) { - if (row.id) installs.push({ id: row.id, accountLogin: row.account?.login ?? null, accountType: row.account?.type ?? null, repositorySelection: row.repository_selection ?? null }); + if (row.id) installs.push({ id: row.id, accountLogin: row.account?.login ?? null, accountType: row.account?.type ?? null, accountId: row.account?.id ?? null, repositorySelection: row.repository_selection ?? null }); } if (rows.length < 100) break; // short page → last page /* v8 ignore next 2 -- runaway-loop backstop: a single App would need 1000+ installs (>10 pages) to reach this */ diff --git a/src/orb/installations.ts b/src/orb/installations.ts index 2d0390c07..8aeaadfe0 100644 --- a/src/orb/installations.ts +++ b/src/orb/installations.ts @@ -17,14 +17,14 @@ export async function upsertOrbInstallation(env: Env, eventName: string, payload case "created": case "new_permissions_accepted": await env.DB.prepare( - `INSERT INTO orb_github_installations (installation_id, account_login, account_type, repository_selection, last_event_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `INSERT INTO orb_github_installations (installation_id, account_login, account_type, account_id, repository_selection, last_event_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(installation_id) DO UPDATE SET - account_login = excluded.account_login, account_type = excluded.account_type, + account_login = excluded.account_login, account_type = excluded.account_type, account_id = excluded.account_id, repository_selection = excluded.repository_selection, suspended_at = NULL, removed_at = NULL, last_event_at = CURRENT_TIMESTAMP`, ) - .bind(inst.id, inst.account?.login ?? null, inst.account?.type ?? null, inst.repository_selection ?? null) + .bind(inst.id, inst.account?.login ?? null, inst.account?.type ?? null, inst.account?.id ?? null, inst.repository_selection ?? null) .run(); return; case "deleted": @@ -51,14 +51,14 @@ export async function backfillOrbInstallations(env: Env): Promise<{ backfilled: const installs = await listOrbAppInstallations(env); for (const inst of installs) { await env.DB.prepare( - `INSERT INTO orb_github_installations (installation_id, account_login, account_type, repository_selection, last_event_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `INSERT INTO orb_github_installations (installation_id, account_login, account_type, account_id, repository_selection, last_event_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(installation_id) DO UPDATE SET - account_login = excluded.account_login, account_type = excluded.account_type, + account_login = excluded.account_login, account_type = excluded.account_type, account_id = excluded.account_id, repository_selection = excluded.repository_selection, suspended_at = NULL, removed_at = NULL, last_event_at = CURRENT_TIMESTAMP`, ) - .bind(inst.id, inst.accountLogin, inst.accountType, inst.repositorySelection) + .bind(inst.id, inst.accountLogin, inst.accountType, inst.accountId, inst.repositorySelection) .run(); } return { backfilled: installs.length }; diff --git a/src/orb/oauth.ts b/src/orb/oauth.ts index 4e0becfb1..6846e13bf 100644 --- a/src/orb/oauth.ts +++ b/src/orb/oauth.ts @@ -16,6 +16,7 @@ import type { Context } from "hono"; import { isOrbBrokerEnabled, issueOrbEnrollment } from "./broker"; type GitHubUser = { login: string; id?: number }; +type GitHubOrgMembership = { role?: string; state?: string; organization?: { id?: number } }; /** Exchange the OAuth code for the maintainer's access token using the ORB App's OAuth credentials. Null when the * credentials aren't configured or GitHub returns no token. */ @@ -46,20 +47,22 @@ export async function fetchOrbOAuthUser(token: string, fetchImpl: typeof fetch = export async function verifyInstallationAdmin( token: string, userLogin: string, + userId: number | null | undefined, accountLogin: string | null, accountType: string | null, + accountId: number | null, fetchImpl: typeof fetch = fetch, ): Promise { - if (!accountLogin) return false; + if (!accountLogin || accountId === null) return false; if (accountType !== "Organization") { - return userLogin.toLowerCase() === accountLogin.toLowerCase(); + return userId === accountId && userLogin.toLowerCase() === accountLogin.toLowerCase(); } const res = await fetchImpl(`https://api.github.com/user/memberships/orgs/${encodeURIComponent(accountLogin)}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "gittensory/0.1" }, }); if (!res.ok) return false; - const body = (await res.json().catch(() => ({}))) as { role?: string; state?: string }; - return body.state === "active" && body.role === "admin"; + const body = (await res.json().catch(() => ({}))) as GitHubOrgMembership; + return body.state === "active" && body.role === "admin" && body.organization?.id === accountId; } async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string, installationId: number): Promise { @@ -67,13 +70,14 @@ async function handleOrbEnrollment(c: Context<{ Bindings: Env }>, code: string, if (!token) return c.html(landingPage("Couldn't verify your GitHub identity", "The authorization didn't complete — re-run the install from GitHub and try again."), 400); const user = await fetchOrbOAuthUser(token); if (!user) return c.html(landingPage("Couldn't verify your GitHub identity", "We couldn't read your GitHub account — try the install again."), 400); - const install = await c.env.DB.prepare("SELECT account_login, account_type, registered, self_enrollment_disabled, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") + const install = await c.env.DB.prepare("SELECT account_login, account_type, account_id, registered, self_enrollment_disabled, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") .bind(installationId) - .first<{ account_login: string | null; account_type: string | null; registered: number; self_enrollment_disabled: number; suspended_at: string | null; removed_at: string | null }>(); + .first<{ account_login: string | null; account_type: string | null; account_id: number | null; registered: number; self_enrollment_disabled: number; suspended_at: string | null; removed_at: string | null }>(); if (!install) return c.html(landingPage("Installation not recognized", "We haven't recorded this installation yet — give it a moment after installing, then retry."), 404); // The admin-of-installation check is the authorization gate — it runs BEFORE we reveal or change any state, so a - // non-admin learns nothing about the install and can never enroll someone else's. - const isAdmin = await verifyInstallationAdmin(token, user.login, install.account_login, install.account_type); + // non-admin learns nothing about the install and can never enroll someone else's. It binds to the immutable + // GitHub account id (logins can be renamed/reused), so a stale account_login can never grant access. + const isAdmin = await verifyInstallationAdmin(token, user.login, user.id, install.account_login, install.account_type, install.account_id); if (!isAdmin) return c.html(landingPage("Admin access required", "You must be an admin of this installation's account to enroll it for self-host."), 403); if (install.removed_at !== null || install.suspended_at !== null) return c.html(landingPage("Installation not active", "This installation is suspended or uninstalled — re-install the Orb App, then retry."), 403); if (install.self_enrollment_disabled === 1) return c.html(landingPage("Installation disabled", "This installation was disabled by the operator — contact the operator to re-enable self-host enrollment."), 403); diff --git a/test/integration/orb-installations.test.ts b/test/integration/orb-installations.test.ts index 3a4536afc..459a77ae7 100644 --- a/test/integration/orb-installations.test.ts +++ b/test/integration/orb-installations.test.ts @@ -2,18 +2,18 @@ import { describe, expect, it } from "vitest"; import { upsertOrbInstallation } from "../../src/orb/installations"; import { createTestEnv, type TestD1Database } from "../helpers/d1"; -const created = (id: number) => ({ action: "created", installation: { id, account: { login: "acme", type: "Organization" }, repository_selection: "selected" } }); +const created = (id: number) => ({ action: "created", installation: { id, account: { login: "acme", type: "Organization", id: 9001 }, repository_selection: "selected" } }); const get = (e: Env, id: number) => (e.DB as unknown as TestD1Database) - .prepare("SELECT account_login, account_type, repository_selection, registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id=?") + .prepare("SELECT account_login, account_type, account_id, repository_selection, registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id=?") .bind(id) - .first<{ account_login: string; account_type: string; repository_selection: string; registered: number; suspended_at: string | null; removed_at: string | null }>(); + .first<{ account_login: string; account_type: string; account_id: number | null; repository_selection: string; registered: number; suspended_at: string | null; removed_at: string | null }>(); describe("upsertOrbInstallation", () => { it("'created' registers the install (registered=0 — the manual-onboarding gate)", async () => { const e = createTestEnv(); await upsertOrbInstallation(e, "installation", created(100)); - expect(await get(e, 100)).toMatchObject({ account_login: "acme", account_type: "Organization", repository_selection: "selected", registered: 0, suspended_at: null, removed_at: null }); + expect(await get(e, 100)).toMatchObject({ account_login: "acme", account_type: "Organization", account_id: 9001, repository_selection: "selected", registered: 0, suspended_at: null, removed_at: null }); }); it("'created' with a minimal installation stores null account/type/selection", async () => { @@ -36,7 +36,7 @@ describe("upsertOrbInstallation", () => { await upsertOrbInstallation(e, "installation", created(102)); await upsertOrbInstallation(e, "installation", { action: "deleted", installation: { id: 102 } }); expect((await get(e, 102))?.removed_at).not.toBeNull(); - await upsertOrbInstallation(e, "installation", { action: "new_permissions_accepted", installation: { id: 102, account: { login: "acme", type: "Organization" }, repository_selection: "all" } }); + await upsertOrbInstallation(e, "installation", { action: "new_permissions_accepted", installation: { id: 102, account: { login: "acme", type: "Organization", id: 9001 }, repository_selection: "all" } }); const row = await get(e, 102); expect(row?.removed_at).toBeNull(); expect(row?.repository_selection).toBe("all"); diff --git a/test/integration/orb-oauth.test.ts b/test/integration/orb-oauth.test.ts index c28ecb739..a3ba1b359 100644 --- a/test/integration/orb-oauth.test.ts +++ b/test/integration/orb-oauth.test.ts @@ -39,18 +39,21 @@ describe("GET /v1/orb/oauth/callback (post-install landing)", () => { describe("verifyInstallationAdmin (the privilege-escalation gate)", () => { it("a USER-account install: only the account owner is an admin (case-insensitive)", async () => { const f = asFetch(async () => Response.json({})); - expect(await verifyInstallationAdmin("t", "Alice", "alice", "User", f)).toBe(true); - expect(await verifyInstallationAdmin("t", "mallory", "alice", "User", f)).toBe(false); + expect(await verifyInstallationAdmin("t", "Alice", 10, "alice", "User", 10, f)).toBe(true); + expect(await verifyInstallationAdmin("t", "mallory", 11, "alice", "User", 10, f)).toBe(false); + expect(await verifyInstallationAdmin("t", "alice", undefined, "alice", "User", 10, f)).toBe(false); }); it("an ORG install: an ACTIVE org admin passes; a member, a pending admin, and an API error all fail", async () => { - expect(await verifyInstallationAdmin("t", "alice", "acme", "Organization", asFetch(async () => Response.json({ role: "admin", state: "active" })))).toBe(true); - expect(await verifyInstallationAdmin("t", "bob", "acme", "Organization", asFetch(async () => Response.json({ role: "member", state: "active" })))).toBe(false); - expect(await verifyInstallationAdmin("t", "carol", "acme", "Organization", asFetch(async () => Response.json({ role: "admin", state: "pending" })))).toBe(false); - expect(await verifyInstallationAdmin("t", "mallory", "acme", "Organization", asFetch(async () => new Response("no", { status: 403 })))).toBe(false); - expect(await verifyInstallationAdmin("t", "alice", "acme", "Organization", asFetch(async () => new Response("not-json", { status: 200 })))).toBe(false); // json() rejects → {} → not admin + expect(await verifyInstallationAdmin("t", "alice", 7, "acme", "Organization", 20, asFetch(async () => Response.json({ role: "admin", state: "active", organization: { id: 20 } })))).toBe(true); + expect(await verifyInstallationAdmin("t", "bob", 8, "acme", "Organization", 20, asFetch(async () => Response.json({ role: "member", state: "active", organization: { id: 20 } })))).toBe(false); + expect(await verifyInstallationAdmin("t", "carol", 9, "acme", "Organization", 20, asFetch(async () => Response.json({ role: "admin", state: "pending", organization: { id: 20 } })))).toBe(false); + expect(await verifyInstallationAdmin("t", "mallory", 10, "acme", "Organization", 20, asFetch(async () => Response.json({ role: "admin", state: "active", organization: { id: 21 } })))).toBe(false); + expect(await verifyInstallationAdmin("t", "mallory", 10, "acme", "Organization", 20, asFetch(async () => new Response("no", { status: 403 })))).toBe(false); + expect(await verifyInstallationAdmin("t", "alice", 7, "acme", "Organization", 20, asFetch(async () => new Response("not-json", { status: 200 })))).toBe(false); // json() rejects → {} → not admin }); - it("a missing account login is never admin", async () => { - expect(await verifyInstallationAdmin("t", "alice", null, "Organization", asFetch(async () => Response.json({})))).toBe(false); + it("a missing account login or account id is never admin", async () => { + expect(await verifyInstallationAdmin("t", "alice", 7, null, "Organization", 20, asFetch(async () => Response.json({})))).toBe(false); + expect(await verifyInstallationAdmin("t", "alice", 7, "acme", "Organization", null, asFetch(async () => Response.json({})))).toBe(false); }); }); @@ -79,7 +82,7 @@ describe("maintainer self-enrollment via the OAuth callback", () => { const stubGitHub = (over: { token?: string; user?: unknown; membership?: unknown } = {}) => vi.stubGlobal("fetch", asFetch(async (url) => { if (url.includes("/login/oauth/access_token")) return Response.json({ access_token: over.token ?? "ghu_x" }); - if (url.includes("api.github.com/user/memberships/orgs/")) return Response.json(over.membership ?? { role: "admin", state: "active" }); + if (url.includes("api.github.com/user/memberships/orgs/")) return Response.json(over.membership ?? { role: "admin", state: "active", organization: { id: 20 } }); if (url.endsWith("api.github.com/user")) return Response.json(over.user ?? { login: "alice", id: 7 }); return new Response("nf", { status: 404 }); })); @@ -87,7 +90,7 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("an org ADMIN self-enrolls a registered install → a one-time secret + recorded maintainer identity", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 500, account_login: "acme", account_type: "Organization", registered: 1 }); + await seedInstall(e, { installation_id: 500, account_login: "acme", account_type: "Organization", account_id: 20, registered: 1 }); stubGitHub(); const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=500", {}, e); expect(res.status).toBe(200); @@ -100,8 +103,8 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("a NON-admin is refused (403), NO enrollment created, and the install is NOT auto-registered — the escalation gate", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 501, account_login: "acme", account_type: "Organization", registered: 0 }); - stubGitHub({ membership: { role: "member", state: "active" } }); + await seedInstall(e, { installation_id: 501, account_login: "acme", account_type: "Organization", account_id: 20, registered: 0 }); + stubGitHub({ membership: { role: "member", state: "active", organization: { id: 20 } } }); const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=501", {}, e); expect(res.status).toBe(403); expect(await res.text()).toContain("Admin access required"); @@ -110,9 +113,20 @@ describe("maintainer self-enrollment via the OAuth callback", () => { expect(row?.registered).toBe(0); // a non-admin never auto-registers }); + it("refuses stale-login reuse when the OAuth account id does not match the installation account id", async () => { + const e = brokeredEnv(); + await seedInstall(e, { installation_id: 509, account_login: "old-acme", account_type: "Organization", account_id: 20, registered: 1 }); + // mallory is an active admin of an org whose id (21) differs from the install's account_id (20) — the + // immutable-id bind rejects this even though the login + role would otherwise pass. + stubGitHub({ user: { login: "mallory", id: 666 }, membership: { role: "admin", state: "active", organization: { id: 21 } } }); + const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=509", {}, e); + expect(res.status).toBe(403); + expect(await db(e).prepare("SELECT 1 AS x FROM orb_enrollments WHERE installation_id=509").first()).toBeUndefined(); + }); + it("a verified admin AUTO-REGISTERS an unregistered install (zero-touch) and gets a secret", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 502, account_login: "acme", account_type: "Organization", registered: 0 }); + await seedInstall(e, { installation_id: 502, account_login: "acme", account_type: "Organization", account_id: 20, registered: 0 }); stubGitHub(); const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=502", {}, e); expect(res.status).toBe(200); @@ -123,7 +137,7 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("an operator-disabled install cannot be self-reenabled through OAuth", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 503, account_login: "acme", account_type: "Organization", registered: 0, self_enrollment_disabled: 1 }); + await seedInstall(e, { installation_id: 503, account_login: "acme", account_type: "Organization", account_id: 20, registered: 0, self_enrollment_disabled: 1 }); stubGitHub(); const res = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=503", {}, e); expect(res.status).toBe(403); @@ -135,8 +149,8 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("a SUSPENDED or UNINSTALLED install is refused (403 not active), even for an admin", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 507, account_login: "acme", account_type: "Organization", registered: 1, suspended_at: "2026-01-01T00:00:00Z" }); - await seedInstall(e, { installation_id: 508, account_login: "acme", account_type: "Organization", registered: 0, removed_at: "2026-01-01T00:00:00Z" }); + await seedInstall(e, { installation_id: 507, account_login: "acme", account_type: "Organization", account_id: 20, registered: 1, suspended_at: "2026-01-01T00:00:00Z" }); + await seedInstall(e, { installation_id: 508, account_login: "acme", account_type: "Organization", account_id: 20, registered: 0, removed_at: "2026-01-01T00:00:00Z" }); stubGitHub(); expect((await app.request("/v1/orb/oauth/callback?code=abc&installation_id=507", {}, e)).status).toBe(403); // suspended (removed_at null → right arm) const removed = await app.request("/v1/orb/oauth/callback?code=abc&installation_id=508", {}, e); @@ -152,16 +166,16 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("a USER-account owner self-enrolls (a login-only identity stores a null github id)", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 504, account_login: "alice", account_type: "User", registered: 1 }); - stubGitHub({ user: { login: "alice" } }); // no id → user.id ?? null + await seedInstall(e, { installation_id: 504, account_login: "alice", account_type: "User", account_id: 7, registered: 1 }); + stubGitHub({ user: { login: "alice", id: 7 } }); expect(await (await app.request("/v1/orb/oauth/callback?code=abc&installation_id=504", {}, e)).text()).toContain("Your enrollment secret"); const row = await db(e).prepare("SELECT maintainer_login, maintainer_github_id FROM orb_enrollments WHERE installation_id=504").first<{ maintainer_login: string; maintainer_github_id: number | null }>(); - expect(row).toMatchObject({ maintainer_login: "alice", maintainer_github_id: null }); + expect(row).toMatchObject({ maintainer_login: "alice", maintainer_github_id: 7 }); }); it("a failed code exchange → 400; the broker being OFF falls through to the landing page", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 505, account_login: "acme", account_type: "Organization", registered: 1 }); + await seedInstall(e, { installation_id: 505, account_login: "acme", account_type: "Organization", account_id: 20, registered: 1 }); vi.stubGlobal("fetch", asFetch(async () => Response.json({}))); // no access_token expect((await app.request("/v1/orb/oauth/callback?code=abc&installation_id=505", {}, e)).status).toBe(400); const off = createTestEnv({ ORB_GITHUB_CLIENT_ID: "id", ORB_GITHUB_CLIENT_SECRET: "sec" }); // broker OFF @@ -170,7 +184,7 @@ describe("maintainer self-enrollment via the OAuth callback", () => { it("a failed /user read → 400", async () => { const e = brokeredEnv(); - await seedInstall(e, { installation_id: 506, account_login: "acme", account_type: "Organization", registered: 1 }); + await seedInstall(e, { installation_id: 506, account_login: "acme", account_type: "Organization", account_id: 20, registered: 1 }); vi.stubGlobal("fetch", asFetch(async (url) => (url.endsWith("api.github.com/user") ? new Response("no", { status: 401 }) : Response.json({ access_token: "x" })))); expect((await app.request("/v1/orb/oauth/callback?code=abc&installation_id=506", {}, e)).status).toBe(400); }); diff --git a/test/unit/orb-app-auth.test.ts b/test/unit/orb-app-auth.test.ts index b2023eda4..60ff6e35b 100644 --- a/test/unit/orb-app-auth.test.ts +++ b/test/unit/orb-app-auth.test.ts @@ -27,12 +27,12 @@ describe("createOrbAppJwt", () => { describe("listOrbAppInstallations", () => { it("walks pages and maps installs (missing account / id tolerated)", async () => { const env = orbEnv({ ORB_GITHUB_APP_PRIVATE_KEY: await pkcs8Pem() }); - const page1 = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, account: { login: "acme", type: "Organization" }, repository_selection: "all" })); - const page2 = [{ id: 101, account: { login: "bob", type: "User" }, repository_selection: "selected" }, { account: { login: "no-id" } }, { id: 102 }]; + const page1 = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, account: { login: "acme", type: "Organization", id: 20 }, repository_selection: "all" })); + const page2 = [{ id: 101, account: { login: "bob", type: "User", id: 21 }, repository_selection: "selected" }, { account: { login: "no-id" } }, { id: 102 }]; vi.stubGlobal("fetch", async (url: RequestInfo | URL) => Response.json(String(url).includes("&page=1") ? page1 : page2)); const installs = await listOrbAppInstallations(env); expect(installs).toHaveLength(102); // 100 (full page → continue) + 101 + 102; the no-id row is skipped - expect(installs.at(-1)).toEqual({ id: 102, accountLogin: null, accountType: null, repositorySelection: null }); + expect(installs.at(-1)).toEqual({ id: 102, accountLogin: null, accountType: null, accountId: null, repositorySelection: null }); }); it("throws on a non-ok response", async () => { @@ -65,17 +65,17 @@ describe("backfillOrbInstallations", () => { await (env.DB as unknown as TestD1Database).prepare("INSERT INTO orb_github_installations (installation_id, registered) VALUES (5, 1)").run(); // already opted in vi.stubGlobal("fetch", async () => Response.json([ - { id: 5, account: { login: "acme", type: "Organization" }, repository_selection: "all" }, - { id: 6, account: { login: "bob", type: "User" }, repository_selection: "selected" }, + { id: 5, account: { login: "acme", type: "Organization", id: 20 }, repository_selection: "all" }, + { id: 6, account: { login: "bob", type: "User", id: 21 }, repository_selection: "selected" }, ]), ); expect(await backfillOrbInstallations(env)).toEqual({ backfilled: 2 }); const rows = await (env.DB as unknown as TestD1Database) - .prepare("SELECT installation_id, account_login, registered FROM orb_github_installations ORDER BY installation_id") - .all<{ installation_id: number; account_login: string; registered: number }>(); + .prepare("SELECT installation_id, account_login, account_id, registered FROM orb_github_installations ORDER BY installation_id") + .all<{ installation_id: number; account_login: string; account_id: number; registered: number }>(); expect(rows.results).toEqual([ - { installation_id: 5, account_login: "acme", registered: 1 }, // stayed registered (backfill never re-trusts/untrusts) - { installation_id: 6, account_login: "bob", registered: 0 }, // new → default opt-out + { installation_id: 5, account_login: "acme", account_id: 20, registered: 1 }, // stayed registered (backfill never re-trusts/untrusts) + { installation_id: 6, account_login: "bob", account_id: 21, registered: 0 }, // new → default opt-out ]); }); });