Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/hub/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { authMethods, isEmailAllowed } from "./auth.js";
import { signInSettings, type HubEnv } from "./env.js";
import { clientIpHeaders, signInSettings, type HubEnv } from "./env.js";

const ALLOWED = ["@corbits.dev", "friend@example.com"];

Expand All @@ -22,3 +22,8 @@ test("an allowlist requires Google or email, and admits email/password only when
expect(authMethods(signInSettings({ ...google, AUTH_ALLOWED_EMAILS: "@corbits.dev", SMTP_URL: "smtp://mail.test" } as HubEnv), true)).toEqual({ google: true, emailPassword: true, passwordReset: true, verificationEmail: true });
expect(authMethods(signInSettings(google), false)).toEqual({ google: true, emailPassword: true, passwordReset: false, verificationEmail: false });
});

test("the caller's address comes from the header a detected host sets, and none elsewhere", () => {
expect(clientIpHeaders({ RAILWAY_ENVIRONMENT_ID: "env" })).toEqual(["x-real-ip"]);
expect(clientIpHeaders({})).toBeUndefined();
});
31 changes: 31 additions & 0 deletions apps/hub/src/github/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,18 @@ function bridge({ refreshed = [], ...overrides }: Partial<BridgeDeps> & { sent?:
}

describe("loadBridgeHook", () => {
test("loads an active tenant-owned hook credential", async () => {
const hook = await loadBridgeHook(stubDb([hookRow()]), stubCipher, HOOK_ID, undefined);
expect(hook).toMatchObject({ credentialId: HOOK_ID, tenantId: TENANT_ID, secret: SECRET, workflow: "pr-triage" });
});
test("rejects revoked, personal and non-webhook credentials", async () => {
expect(await loadBridgeHook(stubDb([hookRow({ status: "revoked" })]), stubCipher, HOOK_ID, undefined)).toBeUndefined();
expect(await loadBridgeHook(stubDb([hookRow({ principalId: "prn_1" })]), stubCipher, HOOK_ID, undefined)).toBeUndefined();
expect(await loadBridgeHook(stubDb([hookRow({ metadata: {} })]), stubCipher, HOOK_ID, undefined)).toBeUndefined();
});
test("name lookup without a tenant hint misses", async () => {
expect(await loadBridgeHook(stubDb([hookRow()]), stubCipher, "github-hook", undefined)).toBeUndefined();
});
});

describe("bridge handler", () => {
Expand All @@ -208,6 +215,13 @@ describe("bridge handler", () => {
})]);
});

test("unknown hook is 404", async () => {
const sent: Sent[] = [];
const res = await bridge({ db: stubDb([]), sent })(githubRequest(prPayload), TARGET);
expect(res.status).toBe(404);
expect(sent).toHaveLength(0);
});

test("oversize bodies are 413 without delivery", async () => {
const sent: Sent[] = [];
const res = await bridge({ sent })(githubRequest("x".repeat(MAX_BODY_BYTES + 1), { delivery: "big" }), TARGET);
Expand Down Expand Up @@ -309,6 +323,23 @@ describe("bridge handler", () => {
expect(sent).toHaveLength(1);
});

test("a Bitbucket repository of the same name is not the GitHub one", async () => {
const sent: Sent[] = [];
const rechecks: Recheck[] = [];
const refreshed: string[] = [];
const db = stubDb([hookRow()], { corbitsTriage: { repos: [{ name: "octocat/hello", source: "bitbucket-datacenter", connected: true, enabled: true }] } });
const handle = bridge({ db, sent, rechecks, refreshed });
const pr = JSON.parse(prPayload);
const merged = JSON.stringify({ ...pr, action: "closed", repository: { full_name: "octocat/hello", default_branch: "main" }, pull_request: { ...pr.pull_request, state: "closed", merged: true, base: { ref: "main" } } });
const pushed = JSON.stringify({ ref: "refs/heads/main", forced: true, repository: { full_name: "octocat/hello", default_branch: "main" }, commits: [] });
const deliveries: Array<[string, string]> = [["pull_request", prPayload], ["pull_request", merged], ["push", pushed]];
for (const [i, [event, body]] of deliveries.entries()) {
const res = await handle(githubRequest(body, { event, delivery: `b${i}` }), TARGET);
expect(await res.json()).toEqual({ status: "ignored" });
}
expect([sent, rechecks, refreshed]).toEqual([[], [], []]);
});

test("a disabled repo is 202 ignored", async () => {
const sent: Sent[] = [];
const db = stubDb([hookRow()], {
Expand Down
57 changes: 57 additions & 0 deletions apps/hub/src/github/github-app-credential-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { createGithubAppCredentialFetch, toRequest } from "./github-app-credenti

const API = "https://api.github.com";

function tokenExpiry(): string {
return new Date(Date.now() + 60 * 60_000).toISOString();
}

function noContent(): Response {
return new Response(null, { status: 204 });
}
Expand All @@ -18,6 +22,38 @@ function appSecret(): string {
}

describe("GitHub App credential adapter", () => {
test("discovers and caches installation tokens separately for each target repository", async () => {
const secret = appSecret();
const calls: string[] = [];
async function github(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const request = toRequest(input, init);
const path = new URL(request.url).pathname;
calls.push(`${request.method} ${path}`);
if (path === "/repos/acme/one/installation") return Response.json({ id: 101 });
if (path === "/repos/acme/two/installation") return Response.json({ id: 202 });
if (path === "/app/installations/101/access_tokens") return Response.json({ token: "token-one", expires_at: tokenExpiry() });
if (path === "/app/installations/202/access_tokens") return Response.json({ token: "token-two", expires_at: tokenExpiry() });
if (path === "/repos/acme/one/pulls") {
expect(request.headers.get("authorization")).toBe("Bearer token-one");
return Response.json([]);
}
if (path === "/repos/acme/two/pulls") {
expect(request.headers.get("authorization")).toBe("Bearer token-two");
return Response.json([]);
}
throw new Error(`unexpected request ${request.method} ${path}`);
}
const adapted = createGithubAppCredentialFetch({ apiOrigin: API, fetch: github });
const headers = { authorization: `Bearer ${secret}` };

await adapted(`${API}/repos/acme/one/pulls`, { headers });
await adapted(`${API}/repos/acme/two/pulls`, { headers });
await adapted(`${API}/repos/acme/one/pulls`, { headers });

expect(calls.filter((call) => call.includes("/app/installations/101/access_tokens"))).toHaveLength(1);
expect(calls.filter((call) => call.includes("/app/installations/202/access_tokens"))).toHaveLength(1);
});

test("never sends GitHub App JSON or its private key to a non-GitHub origin", async () => {
const secret = appSecret();
const observed: string[] = [];
Expand All @@ -35,4 +71,25 @@ describe("GitHub App credential adapter", () => {
expect(observed.join("\n")).not.toContain(secret);
expect(observed.join("\n")).not.toContain("PRIVATE KEY");
});

test("uses App JWT for App endpoints and strips the internal installation selector", async () => {
const secret = appSecret();
const seen: Array<{ path: string; authorization: string; selector: string | null }> = [];
async function github(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const request = toRequest(input, init);
const path = new URL(request.url).pathname;
seen.push({ path, authorization: request.headers.get("authorization") ?? "", selector: request.headers.get("x-corbits-github-installation-id") });
if (path === "/app") return Response.json({ slug: "corbits" });
if (path === "/app/installations/42/access_tokens") return Response.json({ token: "installation-token", expires_at: tokenExpiry() });
if (path === "/installation/repositories") return Response.json({ repositories: [] });
throw new Error(`unexpected ${path}`);
}
const adapted = createGithubAppCredentialFetch({ apiOrigin: API, fetch: github });
const authorization = `Bearer ${secret}`;
await adapted(`${API}/app`, { headers: { authorization } });
await adapted(`${API}/installation/repositories`, { headers: { authorization, "x-corbits-github-installation-id": "42" } });
expect(seen[0]?.authorization).toMatch(/^Bearer eyJ/);
expect(seen.at(-1)).toMatchObject({ path: "/installation/repositories", authorization: "Bearer installation-token", selector: null });
expect(seen.some((call) => call.authorization.includes("privateKey"))).toBe(false);
});
});
23 changes: 23 additions & 0 deletions apps/hub/src/github/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
async limit() {
return rows;
},
then(resolve: (value: unknown[]) => void) {

Check warning on line 66 in apps/hub/src/github/manifest.test.ts

View workflow job for this annotation

GitHub Actions / lint

unicorn(no-thenable)

apps/hub/src/github/manifest.test.ts:66:5: Do not add `then` to an object.
resolve(rows);
},
};
Expand Down Expand Up @@ -130,6 +130,29 @@
});
}

test("callback returns 403 without exchanging the code when replacement authority is absent", async () => {
let exchanged = false;
async function exchangeSpy() {
exchanged = true;
return Response.json(conversion);
}
const integration = createGithubManifestIntegration({
db: { query: { principal: { findFirst: activeMember } } } as never,
cipher: {} as never,
getSession: signedInSession,
authorizeCredential: deny,
stateSecret: STATE_SECRET,
trustedPortalOrigins: [],
githubApiOrigin: GITHUB_API_ORIGIN,
publicOrigin: "https://hub.example.com",
fetchImpl: Object.assign(exchangeSpy, { preconnect: noPreconnect }),
now: fixedNow,
});
const response = await integration.callback(callbackRequest(signManifestState(STATE_KEY, setup)));
expect(response.status).toBe(403);
expect(exchanged).toBeFalse();
});

test("requires JSON, an exact trusted Origin, and rejects cross-site mutations", () => {
const trusted = new Set(["https://portal.example", "https://hub.example"]);
expect(validManifestMutationRequest(mutationRequest({
Expand Down
38 changes: 37 additions & 1 deletion apps/hub/src/github/policy-file.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
const PUSHED = "acme/pushed";
const BITBUCKET_PUSHED = "PROJ/pushed";
const PATCHED = "acme/patched";
const BOTH = "acme/both";
const UNHEARD = "acme/unheard";
const BITBUCKET_UNHEARD = "PROJ/unheard";
const MEMBER = { tenantId: TENANT, principalId: "prn_user" };
/** Uses the host credential, but may neither create grants nor manage the workspace. */
const USER = { tenantId: TENANT, principalId: "prn_plain" };
Expand Down Expand Up @@ -106,7 +109,7 @@
}
if (req.method === "PATCH" && path.startsWith("git/refs/heads/")) {
const branch = path.slice("git/refs/heads/".length);
for (const key of [...files.keys()]) if (key.startsWith(`${repo}@${branch}/`)) files.delete(key);

Check warning on line 112 in apps/hub/src/github/policy-file.postgres.test.ts

View workflow job for this annotation

GitHub Actions / lint

unicorn(no-useless-spread)

apps/hub/src/github/policy-file.postgres.test.ts:112:24: Using a spread operator here creates a new array unnecessarily.
branchCommits.splice(0, branchCommits.length, ...branchCommits.filter((row) => row.at !== `${repo}:${branch}`));
for (const [key, file] of files) if (key.startsWith(`${repo}@main/`)) files.set(key.replace("@main/", `@${branch}/`), file);
return Response.json({ ref: `refs/heads/${branch}` });
Expand Down Expand Up @@ -181,7 +184,7 @@
handle = createDB(config);
const db = handle.db;
const cipher = createEnvKeyCredentialCipher(new Uint8Array(32).fill(7));
const repos = [...[FROM_FILE, STARTER, PATCHED, PUSHED].map((name) => ({ name, connected: true, enabled: true, installationId: 1 })), { name: SHARED, connected: true, enabled: false, installationId: 1 }, { name: EXTENDS, connected: true, enabled: true, installationId: 1 }, { name: BITBUCKET_PUSHED, source: "bitbucket-datacenter", connected: true, enabled: true, defaultBranch: "main" }];
const repos = [...[FROM_FILE, STARTER, PATCHED, PUSHED].map((name) => ({ name, connected: true, enabled: true, installationId: 1 })), { name: SHARED, connected: true, enabled: false, installationId: 1 }, { name: EXTENDS, connected: true, enabled: true, installationId: 1 }, { name: BITBUCKET_PUSHED, source: "bitbucket-datacenter", connected: true, enabled: true, defaultBranch: "main" }, { name: BOTH, connected: true, enabled: true, installationId: 1 }, { name: BOTH, source: "bitbucket-datacenter", connected: true, enabled: true }, { name: UNHEARD, connected: true, enabled: true, installationId: 1 }, { name: BITBUCKET_UNHEARD, source: "bitbucket-datacenter", connected: true, enabled: true }];
await db.insert(schema.tenant).values({ id: TENANT, name: "Policy file", slug: "policy-file", domain: "policy-file.test", config: { corbitsTriage: { repos } } });
await db.insert(schema.principal).values([
{ id: "prn_user", tenantId: TENANT, kind: "user", refId: "usr_user", status: "active" },
Expand Down Expand Up @@ -385,6 +388,39 @@
expect(await readPack(EXTENDS)).toEqual({ status: "invalid", path: YML, reason: expect.stringContaining(`${SHARED}'s policy file is refused: Line 1: The policy file is not YAML`), automatic: [] });
});

test("PostgreSQL: a name both hosts hold is read only for the host asked, a route naming the host reads it, one naming no host refuses it, and its upgrade row names its host", async () => {
onMain(BOTH, YML, FILE, "both1");
await policyFile.onPush(TENANT, BOTH, "github");
expect(await readPack(BOTH)).toMatchObject({ status: "ok", source: { file: { path: YML, sha: "both1" } } });
expect((await policyFile.fileRoute({ repo: BOTH }, MEMBER)).status).toBe(409);
expect((await (await policyFile.fileRoute({ repo: BOTH, source: "github" }, MEMBER)).json()) as unknown).toMatchObject({ file: { path: YML, sha: "both1" } });
expect(await upgrades()).toContainEqual(expect.objectContaining({ repo: BOTH, source: "bitbucket-datacenter" }));
});

test("PostgreSQL: where no webhook is heard a merged policy file is read within a minute without a push, and kept while the webhook is heard", async () => {
const start = Date.now();
function after(ms: number): void {
setSystemTime(new Date(start + ms));
}
try {
for (const [repo, source] of [[UNHEARD, "github"], [BITBUCKET_UNHEARD, "bitbucket-datacenter"]] as const) {
after(0);
expect(await readPack(repo, source)).toEqual({ status: "missing" });
onMain(repo, YML, FILE, `${source}1`);
after(61_000);
expect(await readPack(repo, source)).toMatchObject({ status: "ok", source: { file: { sha: `${source}1` } } });
activity.heard(TENANT, source, repo);
onMain(repo, YML, FILE, `${source}2`);
after(122_000);
expect(await readPack(repo, source)).toMatchObject({ status: "ok", source: { file: { sha: `${source}1` } } });
after(61_000 + 30 * 60_000);
expect(await readPack(repo, source)).toMatchObject({ status: "ok", source: { file: { sha: `${source}2` } } });
}
} finally {
setSystemTime();
}
});

const PATCH_FILE = `# Corbits Triage policy.
rules:
# leave ci-green alone
Expand Down
46 changes: 46 additions & 0 deletions apps/hub/src/manager-routes.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { matchesRunTokenRoute } from "../../../vendor/interchange/packages/hub-a
import { MANAGER_DEPLOYED_ACTION, MANAGER_RESULT_ACTION, MANAGER_RESULT_RESOURCE, MANAGER_STATE_ACTION, MANAGER_STATE_RESOURCE, type RepoSource } from "@corbits/triage-contracts";
import { mirrorCallId } from "@corbits/triage-workflows/actions/mirror";
import { createManagerRoutes, MANAGER_RUN_TOKEN_ROUTES } from "./manager-routes.js";
import { MAX_BODY_BYTES } from "./read-json.js";
import { passRequest, recordEvent, type PullRequestRef } from "./manager-state.js";
import { createVerdictRecordStore } from "./github/verdict-record-store.js";
import { mountTenantRoutes } from "./tenant-routes.js";
Expand All @@ -24,6 +25,9 @@ const SESSION = { id: "ses_member", userId: USER.id, token: "tok_member", expire
const GITHUB: PullRequestRef = { source: "github", repo: "acme/widgets", number: 7 };
const BITBUCKET: PullRequestRef = { source: "bitbucket-datacenter", repo: "ACME/widgets", number: 7 };
const REVIEW = { summary: "No bugs.", comments: [], budgetExhausted: false, complete: false, unread: [{ path: "src/a.ts", lines: [[3, 6]] }], unreadFiles: 1 };
const OpenApi = type({ paths: "Record<string, Record<string, unknown>>" });
const Operation = type({ requestBody: { content: { "application/json": { schema: "object" } } }, responses: { 200: { content: { "application/json": { schema: "object" } } }, 400: "object", 403: "object", 413: "object" } });
const FencedOperation = Operation.and({ responses: { 409: "object" } });

type Db = ReturnType<typeof createDB>["db"];

Expand Down Expand Up @@ -265,6 +269,37 @@ test("PostgreSQL: a reviewer reports only with the result grant, and the result
});
}, 60_000);

test("PostgreSQL: a malformed, non-JSON or oversized body is refused before the store is touched", async () => {
await withDatabase(async function malformed(db) {
const app = buildHub(db);
const manager = bearer(await runWithToken(db, "run_manager", [STATE, RESULT]));
const bodies: [string, unknown][] = [
["take", { ...GITHUB, source: "gitlab" }],
["take", { source: "github", repo: "widgets", number: 7 }],
["take", { ...GITHUB, number: 0 }],
["done", GITHUB],
["done", { ...GITHUB, reviewers: [{ kind: "judge" }] }],
["reviewer-result", { ...GITHUB, kind: "code-review", head: "h1", result: { verdict: "pass" } }],
["reviewer-result", { ...GITHUB, kind: "code-review", result: REVIEW }],
];
for (const [route, body] of bodies) {
const response = await post(app, route, body, manager);
expect(response.status).toBe(400);
expect(await errorCode(response)).toBe("invalid_request");
}
for (const route of ["take", "done", "reviewer-result"]) {
const response = await postText(app, route, "{not json", manager);
expect(response.status).toBe(400);
expect(await errorCode(response)).toBe("invalid_request");
}
const oversized = await post(app, "reviewer-result", { ...GITHUB, head: "h1", result: { summary: "x".repeat(MAX_BODY_BYTES) } }, manager);
expect(oversized.status).toBe(413);
expect(await errorCode(oversized)).toBe("too_large");
const [{ rows }] = await db.execute<{ rows: number }>(sql`SELECT count(*)::int AS rows FROM corbits_triage.manager_state`);
expect(rows).toBe(0);
});
}, 60_000);

test("PostgreSQL: every extra run-token route is a fixed tenant path that names exactly one hub route, a manager route", async () => {
await withDatabase(async function guard(db) {
const handlers = inspectRoutes(buildHub(db)).filter((route) => !route.isMiddleware);
Expand All @@ -278,3 +313,14 @@ test("PostgreSQL: every extra run-token route is a fixed tenant path that names
}
});
}, 60_000);

test("PostgreSQL: /openapi.json documents each manager route's request and responses", async () => {
await withDatabase(async function openapi(db) {
const spec = OpenApi.assert(await (await buildHub(db).request("/openapi.json")).json());
for (const extra of MANAGER_RUN_TOKEN_ROUTES) {
const operation = spec.paths[extra.path.replace(":tenantId", "{tenantId}")]?.post;
const fenced = extra.path.endsWith("/take") || extra.path.endsWith("/done");
expect((fenced ? FencedOperation : Operation).allows(operation)).toBe(true);
}
});
}, 60_000);
Loading
Loading