Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

self-host.test.tsBlame649 lines · 3 contributors
f2c00b4CC LABS App1/**
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
21import { 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
28const _real_db = await import("../db");
29const _schema = await import("../db/schema");
30const _schemaDeploys = await import("../db/schema-deploys");
31
32let _nextSessionRow: any = null;
33let _nextUserRow: any = null;
34let _nextAdminRow: any = null;
35let _nextOwnerRow: any = null;
36let _nextRepoRow: any = null;
37let _recentDeploys: any[] = [];
38let _lastSelectFrom: any = null;
39let _userSelectCount = 0;
40const _inserted: { table: string; values: any }[] = [];
41
42const 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
51const _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
78const _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
97mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
98
99// Import the app AFTER mock.module has installed the fake.
100const { default: app } = await import("../app");
101const { sessionCache } = await import("../lib/cache");
102const postReceive = await import("../hooks/post-receive");
103const selfHostMod = await import("../routes/admin-self-host");
104const bootstrapMod = await import("../../scripts/self-host-bootstrap");
105
106// ---------------------------------------------------------------------------
107// Fake users + tokens
108// ---------------------------------------------------------------------------
109
110const ADMIN_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
111const NON_ADMIN_ID = "ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb";
112const ADMIN_TOKEN = "w1-admin-token";
113const NON_ADMIN_TOKEN = "w1-nonadmin-token";
114
115const 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};
124const 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
134const SAME_ORIGIN_HEADERS = {
135 host: "localhost",
136 origin: "http://localhost",
137};
138
139function 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}
144function 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.
158const origSelfHostRepo = process.env.SELF_HOST_REPO;
159const origSelfDeployScript = process.env.GLUECRON_SELF_DEPLOY_SCRIPT;
160
161beforeEach(() => {
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
178afterAll(() => {
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
9ecf5a4Claude198// spawn — the existing vapron/intelligence behaviour is covered by
f2c00b4CC LABS App199// other test files and untouched here.
200// ---------------------------------------------------------------------------
201
202function 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
216describe("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
331function 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
424describe("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.
bf19c50Test User469 expect(
470 calls.find((c: { cmd: string[] }) => c.cmd.includes("init"))
471 ).toBeUndefined();
f2c00b4CC LABS App472 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
550describe("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
602describe("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 },
bf19c50Test User630 // 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,
f2c00b4CC LABS App634 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});