diff --git a/apps/hub/src/auth.test.ts b/apps/hub/src/auth.test.ts index a6cfd214..defa0acb 100644 --- a/apps/hub/src/auth.test.ts +++ b/apps/hub/src/auth.test.ts @@ -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"]; @@ -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(); +}); diff --git a/apps/hub/src/github/bridge.test.ts b/apps/hub/src/github/bridge.test.ts index 132878dc..6f1a0b6b 100644 --- a/apps/hub/src/github/bridge.test.ts +++ b/apps/hub/src/github/bridge.test.ts @@ -182,11 +182,18 @@ function bridge({ refreshed = [], ...overrides }: Partial & { 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", () => { @@ -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); @@ -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()], { diff --git a/apps/hub/src/github/github-app-credential-adapter.test.ts b/apps/hub/src/github/github-app-credential-adapter.test.ts index a8d25f56..b8807871 100644 --- a/apps/hub/src/github/github-app-credential-adapter.test.ts +++ b/apps/hub/src/github/github-app-credential-adapter.test.ts @@ -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 }); } @@ -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 { + 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[] = []; @@ -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 { + 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); + }); }); diff --git a/apps/hub/src/github/manifest.test.ts b/apps/hub/src/github/manifest.test.ts index 49ad951e..41e066ef 100644 --- a/apps/hub/src/github/manifest.test.ts +++ b/apps/hub/src/github/manifest.test.ts @@ -130,6 +130,29 @@ describe("GitHub manifest authorization", () => { }); } + 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({ diff --git a/apps/hub/src/github/policy-file.postgres.test.ts b/apps/hub/src/github/policy-file.postgres.test.ts index d8584eb5..2a0912d4 100644 --- a/apps/hub/src/github/policy-file.postgres.test.ts +++ b/apps/hub/src/github/policy-file.postgres.test.ts @@ -27,6 +27,9 @@ const EXTENDS = "acme/extends"; 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" }; @@ -181,7 +184,7 @@ beforeAll(async () => { 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" }, @@ -385,6 +388,39 @@ test("PostgreSQL: a policy file extending another repository runs over that repo 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 diff --git a/apps/hub/src/manager-routes.postgres.test.ts b/apps/hub/src/manager-routes.postgres.test.ts index b1a55c47..47dcf29f 100644 --- a/apps/hub/src/manager-routes.postgres.test.ts +++ b/apps/hub/src/manager-routes.postgres.test.ts @@ -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"; @@ -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>" }); +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["db"]; @@ -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); @@ -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); diff --git a/apps/hub/src/sidecar/local-provisioner.test.ts b/apps/hub/src/sidecar/local-provisioner.test.ts index d81fd72c..c23097dc 100644 --- a/apps/hub/src/sidecar/local-provisioner.test.ts +++ b/apps/hub/src/sidecar/local-provisioner.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { errorText } from "@corbits/common"; import type { EnsureSidecarRequest } from "@intx/hub-sessions"; import type { AdapterManifest } from "@intx/types"; import { createLocalProcessSidecarProvisioner } from "./local-provisioner.js"; @@ -10,6 +11,10 @@ import { createLocalSidecarManifestStore } from "./manifest-store.js"; const HUB_ADAPTERS: AdapterManifest = [ { provider: "corbits-system-one", specifier: "@corbits/system-one", export: "createSystemOneAdapterFactory" }, ]; +async function allOpen(ids: readonly string[]): Promise> { + return new Set(ids); +} + const roots: string[] = []; afterEach(async () => { @@ -28,48 +33,79 @@ function request(allocationId: string, generation = 1): EnsureSidecarRequest { }; } -async function started(maxSidecars: number) { +async function setup({ maxSidecars = 4, freeBytes = async () => undefined }: { maxSidecars?: number; freeBytes?: () => Promise } = {}) { const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), "local-provisioner-")); roots.push(dataRoot); - const spawned: string[] = []; + const spawned: { sidecarId: string; token: string; dataDir: string; adapterManifest: AdapterManifest }[] = []; const crashes = new Map void>(); const reported: { sidecarId: string; options: unknown }[] = []; - const retired = Promise.withResolvers(); const manifests = createLocalSidecarManifestStore({ dataRoot, log: () => {} }); - const local = createLocalProcessSidecarProvisioner({ - dataRoot, - capacity: { freeBytes: async () => undefined }, - adapterManifest: HUB_ADAPTERS, - maxSidecars, - spawnSidecar: function spawnSidecar({ sidecarId }) { - spawned.push(sidecarId); - const exit = Promise.withResolvers<{ code: number | null; signal: string | null }>(); - crashes.set(sidecarId, () => exit.resolve({ code: 1, signal: null })); - return { - pid: spawned.length, - exited: exit.promise, - kill(signal) { - exit.resolve({ code: null, signal }); - }, - }; - }, - manifests, - log(entry) { - if (entry["msg"] === "local_sidecar_retired") retired.resolve(); - }, - }); - local.provisioner.attach?.({ - async sidecarStopped(sidecarId, options) { - reported.push({ sidecarId, options }); - return { kind: "retired" }; - }, - }); - await local.boot({ stillOpen: async (ids) => new Set(ids), isConnected: () => false }); - return { local, spawned, crashes, reported, retired: retired.promise, manifests }; + const retired = Promise.withResolvers(); + const records = { stillOpen: allOpen, isConnected: () => false }; + function create() { + return createLocalProcessSidecarProvisioner({ + dataRoot, + capacity: { freeBytes }, + adapterManifest: HUB_ADAPTERS, + maxSidecars, + spawnSidecar: function spawnSidecar({ sidecarId, token, dataDir, adapterManifest }) { + spawned.push({ sidecarId, token, dataDir, adapterManifest }); + const exit = Promise.withResolvers<{ code: number | null; signal: string | null }>(); + crashes.set(sidecarId, () => exit.resolve({ code: 1, signal: null })); + return { + pid: spawned.length, + exited: exit.promise, + kill(signal) { + exit.resolve({ code: null, signal }); + }, + }; + }, + manifests, + log(entry) { + if (entry["msg"] === "local_sidecar_retired") retired.resolve(); + }, + }); + } + function attach(local: ReturnType, fail?: string) { + local.provisioner.attach?.({ + async sidecarStopped(sidecarId, options) { + reported.push({ sidecarId, options }); + if (sidecarId === fail) throw new Error("database unavailable"); + return { kind: "retired" }; + }, + }); + } + async function started(fail?: string) { + const local = create(); + attach(local, fail); + await local.boot(records); + return local; + } + return { dataRoot, manifests, spawned, crashes, reported, records, retired: retired.promise, create, attach, started }; } +test("each allocation starts its own sidecar, and destroying it stops the process and removes its files", async () => { + const { dataRoot, manifests, spawned, started } = await setup(); + const local = await started(); + + expect(await local.provisioner.ensure(request("a"))).toEqual({ kind: "accepted", externalRef: "1" }); + expect(await local.provisioner.ensure(request("b"))).toEqual({ kind: "accepted", externalRef: "2" }); + expect(spawned.map(({ sidecarId, token, adapterManifest }) => ({ sidecarId, token, adapterManifest }))).toEqual([ + { sidecarId: "sc_a", token: "token_a", adapterManifest: HUB_ADAPTERS }, + { sidecarId: "sc_b", token: "token_b", adapterManifest: HUB_ADAPTERS }, + ]); + expect(new Set(spawned.map(({ dataDir }) => dataDir)).size).toBe(2); + + expect(await local.provisioner.destroy({ allocationId: "a", generation: 2, sidecarId: "sc_a" })).toEqual({ kind: "destroyed", cleanup: "confirmed" }); + expect(await manifests.listed()).toEqual(["sc_b"]); + expect(await fs.readdir(dataRoot)).not.toContain(path.basename(spawned[0]!.dataDir)); + + await local.shutdown(); +}); + test("past the cap an ensure is refused for capacity and retried, and the slot a destroy frees takes it", async () => { - const { local, spawned } = await started(2); + const { spawned, started } = await setup({ maxSidecars: 2 }); + const local = await started(); const placed = await Promise.all([local.provisioner.ensure(request("a")), local.provisioner.ensure(request("b")), local.provisioner.ensure(request("c"))]); expect(placed.map((result) => result.kind)).toEqual(["accepted", "accepted", "rejected"]); @@ -83,7 +119,8 @@ test("past the cap an ensure is refused for capacity and retried, and the slot a }); test("a sidecar that exits on its own is retired at once, freeing its slot and its files; one a destroy stopped is not", async () => { - const { local, crashes, reported, retired, manifests } = await started(1); + const { manifests, crashes, reported, retired, started } = await setup({ maxSidecars: 1 }); + const local = await started(); await local.provisioner.ensure(request("a")); await local.provisioner.destroy({ allocationId: "a", generation: 2, sidecarId: "sc_a" }); @@ -97,3 +134,25 @@ test("a sidecar that exits on its own is retired at once, freeing its slot and i await local.shutdown(); }); + +test("boot retires every sidecar earlier boots started, from either layout, and removes their files; one it cannot retire fails boot and stays", async () => { + const { dataRoot, manifests, reported, records, create, attach, started } = await setup(); + const earlier = await started(); + await earlier.provisioner.ensure(request("a")); + await earlier.provisioner.ensure(request("b")); + await earlier.shutdown(); + await fs.writeFile(path.join(dataRoot, "sidecar.json"), JSON.stringify({ sidecarId: "sc_shared", token: "sealed" })); + await fs.mkdir(path.join(dataRoot, "sidecar")); + + const failing = create(); + attach(failing, "sc_b"); + const outcomes = await Promise.allSettled([failing.provisioner.ensure(request("c")), failing.boot(records)]); + expect(outcomes.map((outcome) => outcome.status === "rejected" && errorText(outcome.reason))).toEqual(["database unavailable", "database unavailable"]); + expect((await manifests.listed()).sort()).toEqual(["sc_a", "sc_b", "sc_shared"]); + + reported.length = 0; + await started(); + expect(reported.map(({ sidecarId }) => sidecarId).sort()).toEqual(["sc_a", "sc_b", "sc_shared"]); + expect(reported.every(({ options }) => (options as { endReconnectWindow: boolean }).endReconnectWindow)).toBe(true); + expect(await fs.readdir(dataRoot)).toEqual([]); +});