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.tsBlame651 lines · 4 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 },
032ae5fccanty labs402 // Normalize separators: the orchestrator builds paths with the platform
403 // join (backslashes on a Windows checkout), but the map keys are POSIX.
404 fsExists: (p: string) => fsExistsMap[p.replace(/\\/g, "/")] === true,
f2c00b4CC LABS App405 fsMkdir: async (_p: string, _opts?: any) => undefined,
406 fsWrite: async (p: string, body: string) => {
407 writes.push({ path: p, body });
408 },
409 fsChmod: async (p: string, mode: number) => {
410 chmods.push({ path: p, mode });
411 },
412 fsRm: async (_p: string, _opts?: any) => undefined,
413 log: {
414 say: () => {},
415 ok: () => {},
416 warn: () => {},
417 bad: () => {},
418 info: () => {},
419 },
420 tmpRoot: "/tmp",
421 };
422
423 return { deps, calls, inserts, writes, chmods };
424}
425
426describe("self-host bootstrap orchestrator", () => {
427 it("INSERTs the repositories row on a fresh run", async () => {
428 const { deps, inserts } = makeFakeDepsForBootstrap({
429 hasUser: true,
430 hasAdmin: true,
431 hasRepoRow: false,
432 });
433 const result = await bootstrapMod.runBootstrap(
434 {
435 owner: "ccantynz",
436 name: "Gluecron.com",
437 source: "https://github.com/x/y.git",
438 dryRun: false,
439 },
440 deps as any
441 );
442 expect(result.steps.operator).not.toBeNull();
443 expect(result.steps.operator?.username).toBe("ccantynz");
444 expect(result.steps.repoRow?.created).toBe(true);
445 expect(inserts.length).toBe(1);
446 expect(inserts[0].name).toBe("Gluecron.com");
447 expect(inserts[0].ownerId).toBe("u-1");
448 expect(inserts[0].defaultBranch).toBe("main");
449 expect(inserts[0].isPrivate).toBe(false);
450 });
451
452 it("is idempotent — re-running with the same args is a no-op for repo + bare repo", async () => {
453 const { deps, inserts, calls } = makeFakeDepsForBootstrap({
454 hasUser: true,
455 hasAdmin: true,
456 hasRepoRow: true,
457 bareExists: true,
458 });
459 const result = await bootstrapMod.runBootstrap(
460 {
461 owner: "ccantynz",
462 name: "Gluecron.com",
463 source: "https://github.com/x/y.git",
464 dryRun: false,
465 },
466 deps as any
467 );
468 expect(result.steps.repoRow?.created).toBe(false);
469 expect(inserts.length).toBe(0);
470 // `git init --bare` should NOT have been called when HEAD already exists.
bf19c50Test User471 expect(
472 calls.find((c: { cmd: string[] }) => c.cmd.includes("init"))
473 ).toBeUndefined();
f2c00b4CC LABS App474 expect(result.steps.bareRepoCreated).toBe(false);
475 });
476
477 it("falls back to oldest user when site_admins is empty", async () => {
478 const { deps } = makeFakeDepsForBootstrap({
479 hasUser: true,
480 hasAdmin: false,
481 hasRepoRow: false,
482 });
483 const result = await bootstrapMod.runBootstrap(
484 {
485 owner: "ccantynz",
486 name: "Gluecron.com",
487 source: "https://github.com/x/y.git",
488 dryRun: false,
489 },
490 deps as any
491 );
492 expect(result.steps.operator).not.toBeNull();
493 expect(result.steps.operator?.id).toBe("u-1");
494 });
495
496 it("bails when there are no users at all", async () => {
497 const { deps } = makeFakeDepsForBootstrap({
498 hasUser: false,
499 hasAdmin: false,
500 hasRepoRow: false,
501 });
502 const result = await bootstrapMod.runBootstrap(
503 {
504 owner: "ccantynz",
505 name: "Gluecron.com",
506 source: "https://github.com/x/y.git",
507 dryRun: false,
508 },
509 deps as any
510 );
511 expect(result.ok).toBe(false);
512 expect(result.error).toMatch(/no users/i);
513 });
514
515 it("dry-run never INSERTs and never spawns git", async () => {
516 const { deps, inserts, calls } = makeFakeDepsForBootstrap({
517 hasUser: true,
518 hasAdmin: true,
519 hasRepoRow: false,
520 });
521 await bootstrapMod.runBootstrap(
522 {
523 owner: "ccantynz",
524 name: "Gluecron.com",
525 source: "https://github.com/x/y.git",
526 dryRun: true,
527 },
528 deps as any
529 );
530 expect(inserts.length).toBe(0);
531 expect(calls.length).toBe(0);
532 });
533
534 it("parses --owner / --name / --source / --dry-run flags", () => {
535 const args = bootstrapMod.parseArgs([
536 "--owner=alice",
537 "--name=Demo",
538 "--source=https://example.com/foo.git",
539 "--dry-run",
540 ]);
541 expect(args.owner).toBe("alice");
542 expect(args.name).toBe("Demo");
543 expect(args.source).toBe("https://example.com/foo.git");
544 expect(args.dryRun).toBe(true);
545 });
546});
547
548// ===========================================================================
549// 6. /admin/self-host gating
550// ===========================================================================
551
552describe("GET /admin/self-host gating", () => {
553 it("redirects anonymous users to /login", async () => {
554 const res = await app.request("/admin/self-host", authedGet(null));
555 expect([302, 303]).toContain(res.status);
556 const loc = res.headers.get("location") || "";
557 expect(loc).toContain("/login");
558 });
559
560 it("403s an authed non-admin", async () => {
561 _nextAdminRow = null;
562 const res = await app.request(
563 "/admin/self-host",
564 authedGet(NON_ADMIN_TOKEN)
565 );
566 expect(res.status).toBe(403);
567 });
568
569 it("renders HTML 200 for a site admin (status + bootstrap + recent cards)", async () => {
570 _nextAdminRow = { userId: ADMIN_ID };
571 selfHostMod.__setSelfHostDepsForTests({
572 fsExists: () => false,
573 getEnv: () => ({ SELF_HOST_REPO: "ccantynz/Gluecron.com" }),
574 });
575 const res = await app.request(
576 "/admin/self-host",
577 authedGet(ADMIN_TOKEN)
578 );
579 expect(res.status).toBe(200);
580 const html = await res.text();
581 expect(html).toContain("Self-host");
582 expect(html).toContain("ccantynz/Gluecron.com");
583 expect(html).toContain("SELF_HOST_REPO");
584 expect(html).toContain("Bootstrap");
585 expect(html).toContain("Last 10 self-deploys");
586 });
587
588 it("shows 'Mismatch' when SELF_HOST_REPO is set to a different value", async () => {
589 _nextAdminRow = { userId: ADMIN_ID };
590 selfHostMod.__setSelfHostDepsForTests({
591 fsExists: () => false,
592 getEnv: () => ({ SELF_HOST_REPO: "someone-else/Other.com" }),
593 });
594 const res = await app.request(
595 "/admin/self-host",
596 authedGet(ADMIN_TOKEN)
597 );
598 expect(res.status).toBe(200);
599 const html = await res.text();
600 expect(html).toContain("Mismatch");
601 });
602});
603
604describe("POST /admin/self-host/bootstrap", () => {
605 it("redirects anon to /login", async () => {
606 const res = await app.request("/admin/self-host/bootstrap", {
607 method: "POST",
608 headers: SAME_ORIGIN_HEADERS,
609 redirect: "manual",
610 });
611 expect([302, 303]).toContain(res.status);
612 expect(res.headers.get("location") || "").toContain("/login");
613 });
614
615 it("403s for non-admin", async () => {
616 _nextAdminRow = null;
617 const res = await app.request(
618 "/admin/self-host/bootstrap",
619 authedPost(NON_ADMIN_TOKEN)
620 );
621 expect(res.status).toBe(403);
622 });
623
624 it("spawns the bootstrap script and redirects success for admin", async () => {
625 _nextAdminRow = { userId: ADMIN_ID };
626 const captured: { cmd: string[] }[] = [];
627 selfHostMod.__setSelfHostDepsForTests({
628 spawn: (cmd) => {
629 captured.push({ cmd });
630 return { unref: () => {} } as any;
631 },
bf19c50Test User632 // Pretend both the bun binary and the script exist so the pre-spawn
633 // path-checks (admin-self-host.tsx) pass. Production gets the real
634 // existsSync.
635 fsExists: () => true,
f2c00b4CC LABS App636 getEnv: () => ({}),
637 });
638 const res = await app.request(
639 "/admin/self-host/bootstrap",
640 authedPost(ADMIN_TOKEN)
641 );
642 expect([302, 303]).toContain(res.status);
643 expect(captured.length).toBe(1);
644 // The third arg is the script path; first is bun, second is "run".
645 expect(captured[0]!.cmd[1]).toBe("run");
646 expect(captured[0]!.cmd[2]).toMatch(/self-host-bootstrap\.ts$/);
647 const loc = res.headers.get("location") || "";
648 expect(loc).toContain("/admin/self-host");
649 expect(loc).toContain("success=");
650 });
651});