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.tsBlame643 lines · 1 contributor
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
198// spawn — the existing crontech/intelligence behaviour is covered by
199// 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.
469 expect(calls.find((c) => c.cmd.includes("init"))).toBeUndefined();
470 expect(result.steps.bareRepoCreated).toBe(false);
471 });
472
473 it("falls back to oldest user when site_admins is empty", async () => {
474 const { deps } = makeFakeDepsForBootstrap({
475 hasUser: true,
476 hasAdmin: false,
477 hasRepoRow: false,
478 });
479 const result = await bootstrapMod.runBootstrap(
480 {
481 owner: "ccantynz",
482 name: "Gluecron.com",
483 source: "https://github.com/x/y.git",
484 dryRun: false,
485 },
486 deps as any
487 );
488 expect(result.steps.operator).not.toBeNull();
489 expect(result.steps.operator?.id).toBe("u-1");
490 });
491
492 it("bails when there are no users at all", async () => {
493 const { deps } = makeFakeDepsForBootstrap({
494 hasUser: false,
495 hasAdmin: false,
496 hasRepoRow: false,
497 });
498 const result = await bootstrapMod.runBootstrap(
499 {
500 owner: "ccantynz",
501 name: "Gluecron.com",
502 source: "https://github.com/x/y.git",
503 dryRun: false,
504 },
505 deps as any
506 );
507 expect(result.ok).toBe(false);
508 expect(result.error).toMatch(/no users/i);
509 });
510
511 it("dry-run never INSERTs and never spawns git", async () => {
512 const { deps, inserts, calls } = makeFakeDepsForBootstrap({
513 hasUser: true,
514 hasAdmin: true,
515 hasRepoRow: false,
516 });
517 await bootstrapMod.runBootstrap(
518 {
519 owner: "ccantynz",
520 name: "Gluecron.com",
521 source: "https://github.com/x/y.git",
522 dryRun: true,
523 },
524 deps as any
525 );
526 expect(inserts.length).toBe(0);
527 expect(calls.length).toBe(0);
528 });
529
530 it("parses --owner / --name / --source / --dry-run flags", () => {
531 const args = bootstrapMod.parseArgs([
532 "--owner=alice",
533 "--name=Demo",
534 "--source=https://example.com/foo.git",
535 "--dry-run",
536 ]);
537 expect(args.owner).toBe("alice");
538 expect(args.name).toBe("Demo");
539 expect(args.source).toBe("https://example.com/foo.git");
540 expect(args.dryRun).toBe(true);
541 });
542});
543
544// ===========================================================================
545// 6. /admin/self-host gating
546// ===========================================================================
547
548describe("GET /admin/self-host gating", () => {
549 it("redirects anonymous users to /login", async () => {
550 const res = await app.request("/admin/self-host", authedGet(null));
551 expect([302, 303]).toContain(res.status);
552 const loc = res.headers.get("location") || "";
553 expect(loc).toContain("/login");
554 });
555
556 it("403s an authed non-admin", async () => {
557 _nextAdminRow = null;
558 const res = await app.request(
559 "/admin/self-host",
560 authedGet(NON_ADMIN_TOKEN)
561 );
562 expect(res.status).toBe(403);
563 });
564
565 it("renders HTML 200 for a site admin (status + bootstrap + recent cards)", async () => {
566 _nextAdminRow = { userId: ADMIN_ID };
567 selfHostMod.__setSelfHostDepsForTests({
568 fsExists: () => false,
569 getEnv: () => ({ SELF_HOST_REPO: "ccantynz/Gluecron.com" }),
570 });
571 const res = await app.request(
572 "/admin/self-host",
573 authedGet(ADMIN_TOKEN)
574 );
575 expect(res.status).toBe(200);
576 const html = await res.text();
577 expect(html).toContain("Self-host");
578 expect(html).toContain("ccantynz/Gluecron.com");
579 expect(html).toContain("SELF_HOST_REPO");
580 expect(html).toContain("Bootstrap");
581 expect(html).toContain("Last 10 self-deploys");
582 });
583
584 it("shows 'Mismatch' when SELF_HOST_REPO is set to a different value", async () => {
585 _nextAdminRow = { userId: ADMIN_ID };
586 selfHostMod.__setSelfHostDepsForTests({
587 fsExists: () => false,
588 getEnv: () => ({ SELF_HOST_REPO: "someone-else/Other.com" }),
589 });
590 const res = await app.request(
591 "/admin/self-host",
592 authedGet(ADMIN_TOKEN)
593 );
594 expect(res.status).toBe(200);
595 const html = await res.text();
596 expect(html).toContain("Mismatch");
597 });
598});
599
600describe("POST /admin/self-host/bootstrap", () => {
601 it("redirects anon to /login", async () => {
602 const res = await app.request("/admin/self-host/bootstrap", {
603 method: "POST",
604 headers: SAME_ORIGIN_HEADERS,
605 redirect: "manual",
606 });
607 expect([302, 303]).toContain(res.status);
608 expect(res.headers.get("location") || "").toContain("/login");
609 });
610
611 it("403s for non-admin", async () => {
612 _nextAdminRow = null;
613 const res = await app.request(
614 "/admin/self-host/bootstrap",
615 authedPost(NON_ADMIN_TOKEN)
616 );
617 expect(res.status).toBe(403);
618 });
619
620 it("spawns the bootstrap script and redirects success for admin", async () => {
621 _nextAdminRow = { userId: ADMIN_ID };
622 const captured: { cmd: string[] }[] = [];
623 selfHostMod.__setSelfHostDepsForTests({
624 spawn: (cmd) => {
625 captured.push({ cmd });
626 return { unref: () => {} } as any;
627 },
628 getEnv: () => ({}),
629 });
630 const res = await app.request(
631 "/admin/self-host/bootstrap",
632 authedPost(ADMIN_TOKEN)
633 );
634 expect([302, 303]).toContain(res.status);
635 expect(captured.length).toBe(1);
636 // The third arg is the script path; first is bun, second is "run".
637 expect(captured[0]!.cmd[1]).toBe("run");
638 expect(captured[0]!.cmd[2]).toMatch(/self-host-bootstrap\.ts$/);
639 const loc = res.headers.get("location") || "";
640 expect(loc).toContain("/admin/self-host");
641 expect(loc).toContain("success=");
642 });
643});