CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
self-host.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f2c00b4 | 1 | /** |
| 2 | * BLOCK W — Tests for the self-host migration. | |
| 3 | * | |
| 4 | * Coverage: | |
| 5 | * 1. The post-receive hook fires self-deploy only when SELF_HOST_REPO | |
| 6 | * matches owner/repo AND ref is refs/heads/main. | |
| 7 | * 2. It does NOT fire when SELF_HOST_REPO is unset. | |
| 8 | * 3. It does NOT fire on customer repos with the same name. | |
| 9 | * 4. The spawn is non-blocking — the stub returns synchronously and | |
| 10 | * onPostReceive resolves without awaiting any deploy work. | |
| 11 | * 5. The bootstrap script's idempotency — re-running with the same | |
| 12 | * args INSERTs the row once and finds it on the second pass. | |
| 13 | * 6. `/admin/self-host` renders for site-admin, 403s for non-admin, | |
| 14 | * redirects anon. | |
| 15 | * | |
| 16 | * K1-style spread-from-real mock pattern + afterAll cleanup so we don't | |
| 17 | * pollute the cross-test module cache. | |
| 18 | */ | |
| 19 | /* eslint-disable @typescript-eslint/no-explicit-any */ | |
| 20 | ||
| 21 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 22 | ||
| 23 | // --------------------------------------------------------------------------- | |
| 24 | // Spread-from-real `../db` mock — captures per-table next rows for tests | |
| 25 | // that need to drive admin-self-host + the bootstrap orchestrator. | |
| 26 | // --------------------------------------------------------------------------- | |
| 27 | ||
| 28 | const _real_db = await import("../db"); | |
| 29 | const _schema = await import("../db/schema"); | |
| 30 | const _schemaDeploys = await import("../db/schema-deploys"); | |
| 31 | ||
| 32 | let _nextSessionRow: any = null; | |
| 33 | let _nextUserRow: any = null; | |
| 34 | let _nextAdminRow: any = null; | |
| 35 | let _nextOwnerRow: any = null; | |
| 36 | let _nextRepoRow: any = null; | |
| 37 | let _recentDeploys: any[] = []; | |
| 38 | let _lastSelectFrom: any = null; | |
| 39 | let _userSelectCount = 0; | |
| 40 | const _inserted: { table: string; values: any }[] = []; | |
| 41 | ||
| 42 | const tableName = (t: any): string => { | |
| 43 | if (t === _schema.sessions) return "sessions"; | |
| 44 | if (t === _schema.users) return "users"; | |
| 45 | if (t === _schema.siteAdmins) return "site_admins"; | |
| 46 | if (t === _schema.repositories) return "repositories"; | |
| 47 | if (t === _schemaDeploys.platformDeploys) return "platform_deploys"; | |
| 48 | return "?"; | |
| 49 | }; | |
| 50 | ||
| 51 | const _selectChain: any = { | |
| 52 | from: (t: any) => { | |
| 53 | _lastSelectFrom = t; | |
| 54 | if (tableName(t) === "users") _userSelectCount++; | |
| 55 | return _selectChain; | |
| 56 | }, | |
| 57 | innerJoin: () => _selectChain, | |
| 58 | leftJoin: () => _selectChain, | |
| 59 | where: () => _selectChain, | |
| 60 | orderBy: () => _selectChain, | |
| 61 | limit: async () => { | |
| 62 | const name = tableName(_lastSelectFrom); | |
| 63 | if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : []; | |
| 64 | if (name === "users") { | |
| 65 | // First users-select = the softAuth session lookup; subsequent = | |
| 66 | // admin-self-host's repo-owner lookup. | |
| 67 | if (_userSelectCount === 1) return _nextUserRow ? [_nextUserRow] : []; | |
| 68 | return _nextOwnerRow ? [_nextOwnerRow] : []; | |
| 69 | } | |
| 70 | if (name === "site_admins") return _nextAdminRow ? [_nextAdminRow] : []; | |
| 71 | if (name === "repositories") return _nextRepoRow ? [_nextRepoRow] : []; | |
| 72 | if (name === "platform_deploys") return _recentDeploys; | |
| 73 | return []; | |
| 74 | }, | |
| 75 | then: (resolve: (v: any) => void) => resolve([]), | |
| 76 | }; | |
| 77 | ||
| 78 | const _fakeDb = { | |
| 79 | db: { | |
| 80 | select: () => _selectChain, | |
| 81 | insert: (t: any) => ({ | |
| 82 | values: (v: any) => { | |
| 83 | _inserted.push({ table: tableName(t), values: v }); | |
| 84 | return { | |
| 85 | returning: async () => [{ id: "new-repo-id" }], | |
| 86 | then: (r: (v: any) => void) => r(undefined), | |
| 87 | }; | |
| 88 | }, | |
| 89 | }), | |
| 90 | update: () => ({ set: () => ({ where: () => Promise.resolve() }) }), | |
| 91 | delete: () => ({ where: () => Promise.resolve() }), | |
| 92 | execute: async () => ({ rows: [] }), | |
| 93 | }, | |
| 94 | getDb: () => _fakeDb.db, | |
| 95 | }; | |
| 96 | ||
| 97 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 98 | ||
| 99 | // Import the app AFTER mock.module has installed the fake. | |
| 100 | const { default: app } = await import("../app"); | |
| 101 | const { sessionCache } = await import("../lib/cache"); | |
| 102 | const postReceive = await import("../hooks/post-receive"); | |
| 103 | const selfHostMod = await import("../routes/admin-self-host"); | |
| 104 | const bootstrapMod = await import("../../scripts/self-host-bootstrap"); | |
| 105 | ||
| 106 | // --------------------------------------------------------------------------- | |
| 107 | // Fake users + tokens | |
| 108 | // --------------------------------------------------------------------------- | |
| 109 | ||
| 110 | const ADMIN_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; | |
| 111 | const NON_ADMIN_ID = "ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb"; | |
| 112 | const ADMIN_TOKEN = "w1-admin-token"; | |
| 113 | const NON_ADMIN_TOKEN = "w1-nonadmin-token"; | |
| 114 | ||
| 115 | const ADMIN_USER = { | |
| 116 | id: ADMIN_ID, | |
| 117 | username: "ops_admin", | |
| 118 | displayName: "Ops Admin", | |
| 119 | email: "ops@example.com", | |
| 120 | passwordHash: "x", | |
| 121 | createdAt: new Date(), | |
| 122 | updatedAt: new Date(), | |
| 123 | }; | |
| 124 | const NON_ADMIN_USER = { | |
| 125 | id: NON_ADMIN_ID, | |
| 126 | username: "ops_nobody", | |
| 127 | displayName: "Nobody", | |
| 128 | email: "n@example.com", | |
| 129 | passwordHash: "x", | |
| 130 | createdAt: new Date(), | |
| 131 | updatedAt: new Date(), | |
| 132 | }; | |
| 133 | ||
| 134 | const SAME_ORIGIN_HEADERS = { | |
| 135 | host: "localhost", | |
| 136 | origin: "http://localhost", | |
| 137 | }; | |
| 138 | ||
| 139 | function authedGet(token: string | null): RequestInit { | |
| 140 | const headers: Record<string, string> = { ...SAME_ORIGIN_HEADERS }; | |
| 141 | if (token) headers.cookie = `session=${token}`; | |
| 142 | return { method: "GET", headers, redirect: "manual" }; | |
| 143 | } | |
| 144 | function authedPost(token: string): RequestInit { | |
| 145 | return { | |
| 146 | method: "POST", | |
| 147 | headers: { | |
| 148 | ...SAME_ORIGIN_HEADERS, | |
| 149 | cookie: `session=${token}`, | |
| 150 | "content-type": "application/json", | |
| 151 | }, | |
| 152 | body: JSON.stringify({}), | |
| 153 | redirect: "manual", | |
| 154 | }; | |
| 155 | } | |
| 156 | ||
| 157 | // Preserve original env so we restore it cleanly between tests. | |
| 158 | const origSelfHostRepo = process.env.SELF_HOST_REPO; | |
| 159 | const origSelfDeployScript = process.env.GLUECRON_SELF_DEPLOY_SCRIPT; | |
| 160 | ||
| 161 | beforeEach(() => { | |
| 162 | sessionCache.set(ADMIN_TOKEN, ADMIN_USER as any); | |
| 163 | sessionCache.set(NON_ADMIN_TOKEN, NON_ADMIN_USER as any); | |
| 164 | _nextSessionRow = null; | |
| 165 | _nextUserRow = null; | |
| 166 | _nextAdminRow = null; | |
| 167 | _nextOwnerRow = null; | |
| 168 | _nextRepoRow = null; | |
| 169 | _recentDeploys = []; | |
| 170 | _userSelectCount = 0; | |
| 171 | _inserted.length = 0; | |
| 172 | delete process.env.SELF_HOST_REPO; | |
| 173 | delete process.env.GLUECRON_SELF_DEPLOY_SCRIPT; | |
| 174 | postReceive.__setSelfHostSpawnForTests(null); | |
| 175 | selfHostMod.__setSelfHostDepsForTests(null); | |
| 176 | }); | |
| 177 | ||
| 178 | afterAll(() => { | |
| 179 | sessionCache.invalidate(ADMIN_TOKEN); | |
| 180 | sessionCache.invalidate(NON_ADMIN_TOKEN); | |
| 181 | postReceive.__setSelfHostSpawnForTests(null); | |
| 182 | selfHostMod.__setSelfHostDepsForTests(null); | |
| 183 | if (origSelfHostRepo === undefined) delete process.env.SELF_HOST_REPO; | |
| 184 | else process.env.SELF_HOST_REPO = origSelfHostRepo; | |
| 185 | if (origSelfDeployScript === undefined) | |
| 186 | delete process.env.GLUECRON_SELF_DEPLOY_SCRIPT; | |
| 187 | else process.env.GLUECRON_SELF_DEPLOY_SCRIPT = origSelfDeployScript; | |
| 188 | mock.module("../db", () => _real_db); | |
| 189 | }); | |
| 190 | ||
| 191 | // =========================================================================== | |
| 192 | // 1–4. Post-receive self-host gating | |
| 193 | // =========================================================================== | |
| 194 | // | |
| 195 | // onPostReceive calls into autoRepair / analyzePush / computeHealthScore | |
| 196 | // which each touch the DB. The mocked DB is intentionally empty, so each | |
| 197 | // helper logs an error and continues. We assert *only* on the self-host | |
| 9ecf5a4 | 198 | // spawn — the existing vapron/intelligence behaviour is covered by |
| f2c00b4 | 199 | // other test files and untouched here. |
| 200 | // --------------------------------------------------------------------------- | |
| 201 | ||
| 202 | function makeRefs(opts: { | |
| 203 | refName?: string; | |
| 204 | newSha?: string; | |
| 205 | oldSha?: string; | |
| 206 | } = {}) { | |
| 207 | return [ | |
| 208 | { | |
| 209 | oldSha: opts.oldSha ?? "0".repeat(40), | |
| 210 | newSha: opts.newSha ?? "a".repeat(40), | |
| 211 | refName: opts.refName ?? "refs/heads/main", | |
| 212 | }, | |
| 213 | ]; | |
| 214 | } | |
| 215 | ||
| 216 | describe("post-receive — BLOCK W self-host dispatch", () => { | |
| 217 | it("fires self-deploy when SELF_HOST_REPO matches owner/repo on push to main", async () => { | |
| 218 | process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com"; | |
| 219 | process.env.GLUECRON_SELF_DEPLOY_SCRIPT = "/fake/self-deploy.sh"; | |
| 220 | const calls: { cmd: string[]; opts: any }[] = []; | |
| 221 | postReceive.__setSelfHostSpawnForTests((cmd, opts) => { | |
| 222 | calls.push({ cmd, opts }); | |
| 223 | return { unref: () => {} } as any; | |
| 224 | }); | |
| 225 | ||
| 226 | await postReceive.onPostReceive( | |
| 227 | "ccantynz", | |
| 228 | "Gluecron.com", | |
| 229 | makeRefs({ newSha: "b".repeat(40) }) | |
| 230 | ); | |
| 231 | ||
| 232 | expect(calls.length).toBe(1); | |
| 233 | expect(calls[0]!.cmd[0]).toBe("/fake/self-deploy.sh"); | |
| 234 | expect(calls[0]!.cmd[1]).toBe("0".repeat(40)); // oldSha | |
| 235 | expect(calls[0]!.cmd[2]).toBe("b".repeat(40)); // newSha | |
| 236 | }); | |
| 237 | ||
| 238 | it("does NOT fire when SELF_HOST_REPO is unset", async () => { | |
| 239 | delete process.env.SELF_HOST_REPO; | |
| 240 | const calls: { cmd: string[] }[] = []; | |
| 241 | postReceive.__setSelfHostSpawnForTests((cmd) => { | |
| 242 | calls.push({ cmd }); | |
| 243 | return { unref: () => {} } as any; | |
| 244 | }); | |
| 245 | ||
| 246 | await postReceive.onPostReceive( | |
| 247 | "ccantynz", | |
| 248 | "Gluecron.com", | |
| 249 | makeRefs() | |
| 250 | ); | |
| 251 | expect(calls.length).toBe(0); | |
| 252 | }); | |
| 253 | ||
| 254 | it("does NOT fire on a customer repo with the same name", async () => { | |
| 255 | process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com"; | |
| 256 | const calls: { cmd: string[] }[] = []; | |
| 257 | postReceive.__setSelfHostSpawnForTests((cmd) => { | |
| 258 | calls.push({ cmd }); | |
| 259 | return { unref: () => {} } as any; | |
| 260 | }); | |
| 261 | ||
| 262 | // Different owner — same repo name. | |
| 263 | await postReceive.onPostReceive( | |
| 264 | "someone-else", | |
| 265 | "Gluecron.com", | |
| 266 | makeRefs() | |
| 267 | ); | |
| 268 | expect(calls.length).toBe(0); | |
| 269 | }); | |
| 270 | ||
| 271 | it("does NOT fire on a non-main branch", async () => { | |
| 272 | process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com"; | |
| 273 | const calls: { cmd: string[] }[] = []; | |
| 274 | postReceive.__setSelfHostSpawnForTests((cmd) => { | |
| 275 | calls.push({ cmd }); | |
| 276 | return { unref: () => {} } as any; | |
| 277 | }); | |
| 278 | ||
| 279 | await postReceive.onPostReceive( | |
| 280 | "ccantynz", | |
| 281 | "Gluecron.com", | |
| 282 | makeRefs({ refName: "refs/heads/feature" }) | |
| 283 | ); | |
| 284 | expect(calls.length).toBe(0); | |
| 285 | }); | |
| 286 | ||
| 287 | it("does NOT fire on a branch deletion (newSha all zeros)", async () => { | |
| 288 | process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com"; | |
| 289 | const calls: { cmd: string[] }[] = []; | |
| 290 | postReceive.__setSelfHostSpawnForTests((cmd) => { | |
| 291 | calls.push({ cmd }); | |
| 292 | return { unref: () => {} } as any; | |
| 293 | }); | |
| 294 | ||
| 295 | await postReceive.onPostReceive( | |
| 296 | "ccantynz", | |
| 297 | "Gluecron.com", | |
| 298 | makeRefs({ newSha: "0".repeat(40) }) | |
| 299 | ); | |
| 300 | expect(calls.length).toBe(0); | |
| 301 | }); | |
| 302 | ||
| 303 | it("spawn is non-blocking — the stub is called synchronously and onPostReceive resolves without awaiting the deploy", async () => { | |
| 304 | process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com"; | |
| 305 | let spawnReturned = false; | |
| 306 | let onPostReceiveResolved = false; | |
| 307 | postReceive.__setSelfHostSpawnForTests(() => { | |
| 308 | spawnReturned = true; | |
| 309 | // Return an object that would never resolve if the hook awaited it. | |
| 310 | return { | |
| 311 | unref: () => {}, | |
| 312 | // Intentional: no `exited` promise, no callbacks. | |
| 313 | } as any; | |
| 314 | }); | |
| 315 | ||
| 316 | const p = postReceive | |
| 317 | .onPostReceive("ccantynz", "Gluecron.com", makeRefs()) | |
| 318 | .then(() => { | |
| 319 | onPostReceiveResolved = true; | |
| 320 | }); | |
| 321 | await p; | |
| 322 | expect(spawnReturned).toBe(true); | |
| 323 | expect(onPostReceiveResolved).toBe(true); | |
| 324 | }); | |
| 325 | }); | |
| 326 | ||
| 327 | // =========================================================================== | |
| 328 | // 5. Bootstrap idempotency + cutover printing | |
| 329 | // =========================================================================== | |
| 330 | ||
| 331 | function makeFakeDepsForBootstrap(opts: { | |
| 332 | hasUser?: boolean; | |
| 333 | hasAdmin?: boolean; | |
| 334 | hasRepoRow?: boolean; | |
| 335 | bareExists?: boolean; | |
| 336 | } = {}): any { | |
| 337 | const fakeUsers: any[] = opts.hasUser ?? true ? [{ id: "u-1", username: "ccantynz" }] : []; | |
| 338 | const fakeAdmins: any[] = opts.hasAdmin | |
| 339 | ? [{ id: "u-1", username: "ccantynz" }] | |
| 340 | : []; | |
| 341 | const fakeRepoRows: any[] = opts.hasRepoRow ? [{ id: "r-1" }] : []; | |
| 342 | ||
| 343 | let currentFrom = ""; | |
| 344 | const selectChain: any = { | |
| 345 | from: (t: any) => { | |
| 346 | if (t === _schema.siteAdmins || t === _schema.users) { | |
| 347 | currentFrom = t === _schema.siteAdmins ? "site_admins" : "users"; | |
| 348 | } else if (t === _schema.repositories) { | |
| 349 | currentFrom = "repositories"; | |
| 350 | } | |
| 351 | return selectChain; | |
| 352 | }, | |
| 353 | innerJoin: () => selectChain, | |
| 354 | where: () => selectChain, | |
| 355 | orderBy: () => selectChain, | |
| 356 | limit: async () => { | |
| 357 | if (currentFrom === "site_admins") return fakeAdmins; | |
| 358 | if (currentFrom === "users") return fakeUsers; | |
| 359 | if (currentFrom === "repositories") return fakeRepoRows; | |
| 360 | return []; | |
| 361 | }, | |
| 362 | }; | |
| 363 | ||
| 364 | const inserts: any[] = []; | |
| 365 | const fakeDb: any = { | |
| 366 | select: () => selectChain, | |
| 367 | insert: (_t: any) => ({ | |
| 368 | values: (v: any) => { | |
| 369 | inserts.push(v); | |
| 370 | return { | |
| 371 | returning: async () => [{ id: "r-new" }], | |
| 372 | }; | |
| 373 | }, | |
| 374 | }), | |
| 375 | }; | |
| 376 | ||
| 377 | const calls: { cmd: string[] }[] = []; | |
| 378 | const fsExistsMap: Record<string, boolean> = {}; | |
| 379 | if (opts.bareExists) { | |
| 380 | fsExistsMap[ | |
| 381 | "/repos/ccantynz/Gluecron.com.git/HEAD" | |
| 382 | ] = true; | |
| 383 | } | |
| 384 | ||
| 385 | const writes: { path: string; body: string }[] = []; | |
| 386 | const chmods: { path: string; mode: number }[] = []; | |
| 387 | ||
| 388 | const deps = { | |
| 389 | db: fakeDb, | |
| 390 | schema: { | |
| 391 | users: _schema.users, | |
| 392 | repositories: _schema.repositories, | |
| 393 | siteAdmins: _schema.siteAdmins, | |
| 394 | }, | |
| 395 | reposPath: "/repos", | |
| 396 | sh: async (cmd: string[]) => { | |
| 397 | calls.push({ cmd }); | |
| 398 | // Pretend every shell command succeeds. The clone+push --mirror | |
| 399 | // pair is treated as a no-op; tests assert on `calls` instead. | |
| 400 | return { ok: true, stdout: "", stderr: "", exitCode: 0 }; | |
| 401 | }, | |
| 402 | fsExists: (p: string) => fsExistsMap[p] === true, | |
| 403 | fsMkdir: async (_p: string, _opts?: any) => undefined, | |
| 404 | fsWrite: async (p: string, body: string) => { | |
| 405 | writes.push({ path: p, body }); | |
| 406 | }, | |
| 407 | fsChmod: async (p: string, mode: number) => { | |
| 408 | chmods.push({ path: p, mode }); | |
| 409 | }, | |
| 410 | fsRm: async (_p: string, _opts?: any) => undefined, | |
| 411 | log: { | |
| 412 | say: () => {}, | |
| 413 | ok: () => {}, | |
| 414 | warn: () => {}, | |
| 415 | bad: () => {}, | |
| 416 | info: () => {}, | |
| 417 | }, | |
| 418 | tmpRoot: "/tmp", | |
| 419 | }; | |
| 420 | ||
| 421 | return { deps, calls, inserts, writes, chmods }; | |
| 422 | } | |
| 423 | ||
| 424 | describe("self-host bootstrap orchestrator", () => { | |
| 425 | it("INSERTs the repositories row on a fresh run", async () => { | |
| 426 | const { deps, inserts } = makeFakeDepsForBootstrap({ | |
| 427 | hasUser: true, | |
| 428 | hasAdmin: true, | |
| 429 | hasRepoRow: false, | |
| 430 | }); | |
| 431 | const result = await bootstrapMod.runBootstrap( | |
| 432 | { | |
| 433 | owner: "ccantynz", | |
| 434 | name: "Gluecron.com", | |
| 435 | source: "https://github.com/x/y.git", | |
| 436 | dryRun: false, | |
| 437 | }, | |
| 438 | deps as any | |
| 439 | ); | |
| 440 | expect(result.steps.operator).not.toBeNull(); | |
| 441 | expect(result.steps.operator?.username).toBe("ccantynz"); | |
| 442 | expect(result.steps.repoRow?.created).toBe(true); | |
| 443 | expect(inserts.length).toBe(1); | |
| 444 | expect(inserts[0].name).toBe("Gluecron.com"); | |
| 445 | expect(inserts[0].ownerId).toBe("u-1"); | |
| 446 | expect(inserts[0].defaultBranch).toBe("main"); | |
| 447 | expect(inserts[0].isPrivate).toBe(false); | |
| 448 | }); | |
| 449 | ||
| 450 | it("is idempotent — re-running with the same args is a no-op for repo + bare repo", async () => { | |
| 451 | const { deps, inserts, calls } = makeFakeDepsForBootstrap({ | |
| 452 | hasUser: true, | |
| 453 | hasAdmin: true, | |
| 454 | hasRepoRow: true, | |
| 455 | bareExists: true, | |
| 456 | }); | |
| 457 | const result = await bootstrapMod.runBootstrap( | |
| 458 | { | |
| 459 | owner: "ccantynz", | |
| 460 | name: "Gluecron.com", | |
| 461 | source: "https://github.com/x/y.git", | |
| 462 | dryRun: false, | |
| 463 | }, | |
| 464 | deps as any | |
| 465 | ); | |
| 466 | expect(result.steps.repoRow?.created).toBe(false); | |
| 467 | expect(inserts.length).toBe(0); | |
| 468 | // `git init --bare` should NOT have been called when HEAD already exists. | |
| bf19c50 | 469 | expect( |
| 470 | calls.find((c: { cmd: string[] }) => c.cmd.includes("init")) | |
| 471 | ).toBeUndefined(); | |
| f2c00b4 | 472 | expect(result.steps.bareRepoCreated).toBe(false); |
| 473 | }); | |
| 474 | ||
| 475 | it("falls back to oldest user when site_admins is empty", async () => { | |
| 476 | const { deps } = makeFakeDepsForBootstrap({ | |
| 477 | hasUser: true, | |
| 478 | hasAdmin: false, | |
| 479 | hasRepoRow: false, | |
| 480 | }); | |
| 481 | const result = await bootstrapMod.runBootstrap( | |
| 482 | { | |
| 483 | owner: "ccantynz", | |
| 484 | name: "Gluecron.com", | |
| 485 | source: "https://github.com/x/y.git", | |
| 486 | dryRun: false, | |
| 487 | }, | |
| 488 | deps as any | |
| 489 | ); | |
| 490 | expect(result.steps.operator).not.toBeNull(); | |
| 491 | expect(result.steps.operator?.id).toBe("u-1"); | |
| 492 | }); | |
| 493 | ||
| 494 | it("bails when there are no users at all", async () => { | |
| 495 | const { deps } = makeFakeDepsForBootstrap({ | |
| 496 | hasUser: false, | |
| 497 | hasAdmin: false, | |
| 498 | hasRepoRow: false, | |
| 499 | }); | |
| 500 | const result = await bootstrapMod.runBootstrap( | |
| 501 | { | |
| 502 | owner: "ccantynz", | |
| 503 | name: "Gluecron.com", | |
| 504 | source: "https://github.com/x/y.git", | |
| 505 | dryRun: false, | |
| 506 | }, | |
| 507 | deps as any | |
| 508 | ); | |
| 509 | expect(result.ok).toBe(false); | |
| 510 | expect(result.error).toMatch(/no users/i); | |
| 511 | }); | |
| 512 | ||
| 513 | it("dry-run never INSERTs and never spawns git", async () => { | |
| 514 | const { deps, inserts, calls } = makeFakeDepsForBootstrap({ | |
| 515 | hasUser: true, | |
| 516 | hasAdmin: true, | |
| 517 | hasRepoRow: false, | |
| 518 | }); | |
| 519 | await bootstrapMod.runBootstrap( | |
| 520 | { | |
| 521 | owner: "ccantynz", | |
| 522 | name: "Gluecron.com", | |
| 523 | source: "https://github.com/x/y.git", | |
| 524 | dryRun: true, | |
| 525 | }, | |
| 526 | deps as any | |
| 527 | ); | |
| 528 | expect(inserts.length).toBe(0); | |
| 529 | expect(calls.length).toBe(0); | |
| 530 | }); | |
| 531 | ||
| 532 | it("parses --owner / --name / --source / --dry-run flags", () => { | |
| 533 | const args = bootstrapMod.parseArgs([ | |
| 534 | "--owner=alice", | |
| 535 | "--name=Demo", | |
| 536 | "--source=https://example.com/foo.git", | |
| 537 | "--dry-run", | |
| 538 | ]); | |
| 539 | expect(args.owner).toBe("alice"); | |
| 540 | expect(args.name).toBe("Demo"); | |
| 541 | expect(args.source).toBe("https://example.com/foo.git"); | |
| 542 | expect(args.dryRun).toBe(true); | |
| 543 | }); | |
| 544 | }); | |
| 545 | ||
| 546 | // =========================================================================== | |
| 547 | // 6. /admin/self-host gating | |
| 548 | // =========================================================================== | |
| 549 | ||
| 550 | describe("GET /admin/self-host gating", () => { | |
| 551 | it("redirects anonymous users to /login", async () => { | |
| 552 | const res = await app.request("/admin/self-host", authedGet(null)); | |
| 553 | expect([302, 303]).toContain(res.status); | |
| 554 | const loc = res.headers.get("location") || ""; | |
| 555 | expect(loc).toContain("/login"); | |
| 556 | }); | |
| 557 | ||
| 558 | it("403s an authed non-admin", async () => { | |
| 559 | _nextAdminRow = null; | |
| 560 | const res = await app.request( | |
| 561 | "/admin/self-host", | |
| 562 | authedGet(NON_ADMIN_TOKEN) | |
| 563 | ); | |
| 564 | expect(res.status).toBe(403); | |
| 565 | }); | |
| 566 | ||
| 567 | it("renders HTML 200 for a site admin (status + bootstrap + recent cards)", async () => { | |
| 568 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 569 | selfHostMod.__setSelfHostDepsForTests({ | |
| 570 | fsExists: () => false, | |
| 571 | getEnv: () => ({ SELF_HOST_REPO: "ccantynz/Gluecron.com" }), | |
| 572 | }); | |
| 573 | const res = await app.request( | |
| 574 | "/admin/self-host", | |
| 575 | authedGet(ADMIN_TOKEN) | |
| 576 | ); | |
| 577 | expect(res.status).toBe(200); | |
| 578 | const html = await res.text(); | |
| 579 | expect(html).toContain("Self-host"); | |
| 580 | expect(html).toContain("ccantynz/Gluecron.com"); | |
| 581 | expect(html).toContain("SELF_HOST_REPO"); | |
| 582 | expect(html).toContain("Bootstrap"); | |
| 583 | expect(html).toContain("Last 10 self-deploys"); | |
| 584 | }); | |
| 585 | ||
| 586 | it("shows 'Mismatch' when SELF_HOST_REPO is set to a different value", async () => { | |
| 587 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 588 | selfHostMod.__setSelfHostDepsForTests({ | |
| 589 | fsExists: () => false, | |
| 590 | getEnv: () => ({ SELF_HOST_REPO: "someone-else/Other.com" }), | |
| 591 | }); | |
| 592 | const res = await app.request( | |
| 593 | "/admin/self-host", | |
| 594 | authedGet(ADMIN_TOKEN) | |
| 595 | ); | |
| 596 | expect(res.status).toBe(200); | |
| 597 | const html = await res.text(); | |
| 598 | expect(html).toContain("Mismatch"); | |
| 599 | }); | |
| 600 | }); | |
| 601 | ||
| 602 | describe("POST /admin/self-host/bootstrap", () => { | |
| 603 | it("redirects anon to /login", async () => { | |
| 604 | const res = await app.request("/admin/self-host/bootstrap", { | |
| 605 | method: "POST", | |
| 606 | headers: SAME_ORIGIN_HEADERS, | |
| 607 | redirect: "manual", | |
| 608 | }); | |
| 609 | expect([302, 303]).toContain(res.status); | |
| 610 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 611 | }); | |
| 612 | ||
| 613 | it("403s for non-admin", async () => { | |
| 614 | _nextAdminRow = null; | |
| 615 | const res = await app.request( | |
| 616 | "/admin/self-host/bootstrap", | |
| 617 | authedPost(NON_ADMIN_TOKEN) | |
| 618 | ); | |
| 619 | expect(res.status).toBe(403); | |
| 620 | }); | |
| 621 | ||
| 622 | it("spawns the bootstrap script and redirects success for admin", async () => { | |
| 623 | _nextAdminRow = { userId: ADMIN_ID }; | |
| 624 | const captured: { cmd: string[] }[] = []; | |
| 625 | selfHostMod.__setSelfHostDepsForTests({ | |
| 626 | spawn: (cmd) => { | |
| 627 | captured.push({ cmd }); | |
| 628 | return { unref: () => {} } as any; | |
| 629 | }, | |
| bf19c50 | 630 | // Pretend both the bun binary and the script exist so the pre-spawn |
| 631 | // path-checks (admin-self-host.tsx) pass. Production gets the real | |
| 632 | // existsSync. | |
| 633 | fsExists: () => true, | |
| f2c00b4 | 634 | getEnv: () => ({}), |
| 635 | }); | |
| 636 | const res = await app.request( | |
| 637 | "/admin/self-host/bootstrap", | |
| 638 | authedPost(ADMIN_TOKEN) | |
| 639 | ); | |
| 640 | expect([302, 303]).toContain(res.status); | |
| 641 | expect(captured.length).toBe(1); | |
| 642 | // The third arg is the script path; first is bun, second is "run". | |
| 643 | expect(captured[0]!.cmd[1]).toBe("run"); | |
| 644 | expect(captured[0]!.cmd[2]).toMatch(/self-host-bootstrap\.ts$/); | |
| 645 | const loc = res.headers.get("location") || ""; | |
| 646 | expect(loc).toContain("/admin/self-host"); | |
| 647 | expect(loc).toContain("success="); | |
| 648 | }); | |
| 649 | }); |