Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

migration-onboarding.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.

migration-onboarding.test.tsBlame149 lines · 1 contributor
8ae1b1fccantynz-alt1/**
2 * Post-import onboarding for a team migrating from GitHub.
3 *
4 * POST /import/bulk cloned repositories and stopped. A team that migrated an
5 * org landed on bare repos — no branch protection, no labels, no gate
6 * settings — and no evidence the migration was worth doing. The platform's
7 * whole pitch (it finds problems in your code) was something they had to go
8 * and discover for themselves, one repo at a time.
9 *
10 * runMigrationOnboarding bootstraps green defaults and scans the imported
11 * code, returning a report the results page renders.
12 *
13 * The property that matters most here is FAULT ISOLATION: a migration that
14 * imported 40 repositories must not be reported as a failure because the
15 * scanner threw on one of them. These use injected failures to prove that.
16 */
17
18import { describe, expect, it, mock, afterEach } from "bun:test";
19import { readFileSync } from "fs";
20
21const SRC = readFileSync("src/lib/migration-onboarding.ts", "utf8");
22const ROUTE = readFileSync("src/routes/import-bulk.tsx", "utf8");
23
24describe("wiring", () => {
25 it("import-bulk runs onboarding after a real import", () => {
26 expect(ROUTE).toContain("runMigrationOnboarding");
27 });
28
29 it("only newly cloned repos are onboarded", () => {
30 // "skipped-exists" repos were already on the platform and have settings
31 // their owner chose; re-bootstrapping would fight those.
32 expect(ROUTE).toContain('r.status === "success"');
33 expect(ROUTE).not.toContain('r.status === "imported"');
34 });
35
36 it("a failure in onboarding cannot fail the completed migration", () => {
37 // lastIndexOf: there is an earlier `return c.html(` for the dry-run
38 // preview, which would slice to an empty string and pass vacuously.
39 const block = ROUTE.slice(
40 ROUTE.indexOf("const importedNames"),
41 ROUTE.lastIndexOf("return c.html(")
42 );
43 expect(block.length).toBeGreaterThan(100);
44 expect(block).toContain("try {");
45 expect(block).toContain("catch");
46 // The repositories are already cloned at this point; the results page
47 // must still render.
48 expect(block).not.toMatch(/throw\s/);
49 });
50
51 it("skips the welcome issue on imported repos", () => {
52 // An imported repo arrives with real history and real issues. A
53 // "welcome" issue on top of 400 migrated ones is noise.
54 expect(SRC).toContain("skipWelcomeIssue: true");
55 });
56
57 it("bounds concurrency rather than scanning every repo at once", () => {
58 // Each scan is a tree walk plus blob reads; an unbounded Promise.all over
59 // a 100-repo org opens 100 concurrent walks against one disk and pool.
60 expect(SRC).toContain("mapWithConcurrency");
61 expect(SRC).toContain("DB_FANOUT_LIMIT");
62 });
63});
64
65describe("fault isolation", () => {
66 afterEach(() => {
67 mock.restore();
68 });
69
70 it("one repo's scan failure does not sink the others", async () => {
71 mock.module("../lib/repo-bootstrap", () => ({
72 bootstrapRepository: async () => ({
73 settingsCreated: true,
74 protectionCreated: true,
75 labelsCreated: 5,
76 }),
77 }));
78 mock.module("../lib/gate", () => ({
79 runSecretAndSecurityScan: async (_o: string, repo: string) => {
80 if (repo === "explodes") throw new Error("scanner blew up");
81 return {
82 secretResult: { name: "Secrets", passed: true, details: "clean" },
83 securityResult: { name: "Security", passed: true, details: "clean" },
84 secrets: repo === "leaky" ? [{ a: 1 }, { b: 2 }] : [],
85 securityIssues: [],
86 };
87 },
88 }));
89
90 const { runMigrationOnboarding } = await import("../lib/migration-onboarding");
91 const report = await runMigrationOnboarding(
92 [
93 { id: "1", owner: "acme", name: "ok" },
94 { id: "2", owner: "acme", name: "explodes" },
95 { id: "3", owner: "acme", name: "leaky" },
96 ],
97 "user-1"
98 );
99
100 expect(report.totalRepos).toBe(3);
101 // The thrower is reported, not thrown.
102 const bad = report.repos.find((r) => r.name === "explodes")!;
103 expect(bad.error).toContain("scanner blew up");
104 // ...and it still got its green defaults.
105 expect(bad.bootstrapped).toBe(true);
106 // The others are unaffected and their findings are counted.
107 expect(report.totalSecrets).toBe(2);
108 expect(report.reposWithFindings).toBe(1);
109 expect(report.reposBootstrapped).toBe(3);
110 });
111
112 it("a bootstrap failure still lets the scan run", async () => {
113 mock.module("../lib/repo-bootstrap", () => ({
114 bootstrapRepository: async () => {
115 throw new Error("no protection for you");
116 },
117 }));
118 mock.module("../lib/gate", () => ({
119 runSecretAndSecurityScan: async () => ({
120 secretResult: { name: "Secrets", passed: false, details: "1 secret" },
121 securityResult: { name: "Security", passed: true, details: "clean" },
122 secrets: [{ a: 1 }],
123 securityIssues: [],
124 }),
125 }));
126
127 const { runMigrationOnboarding } = await import("../lib/migration-onboarding");
128 const report = await runMigrationOnboarding(
129 [{ id: "1", owner: "acme", name: "repo" }],
130 "user-1"
131 );
132
133 const r = report.repos[0];
134 expect(r.bootstrapped).toBe(false);
135 expect(r.error).toContain("bootstrap");
136 // The scan is the value proposition — it must not be skipped because
137 // the bootstrap step failed.
138 expect(r.secretsFound).toBe(1);
139 expect(report.totalSecrets).toBe(1);
140 });
141
142 it("returns an empty report for an empty import rather than throwing", async () => {
143 const { runMigrationOnboarding } = await import("../lib/migration-onboarding");
144 const report = await runMigrationOnboarding([], "user-1");
145 expect(report.totalRepos).toBe(0);
146 expect(report.totalSecrets).toBe(0);
147 expect(report.reposWithFindings).toBe(0);
148 });
149});