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.
| 8ae1b1f | 1 | /** |
| 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 | ||
| 18 | import { describe, expect, it, mock, afterEach } from "bun:test"; | |
| 19 | import { readFileSync } from "fs"; | |
| 20 | ||
| 21 | const SRC = readFileSync("src/lib/migration-onboarding.ts", "utf8"); | |
| 22 | const ROUTE = readFileSync("src/routes/import-bulk.tsx", "utf8"); | |
| 23 | ||
| 24 | describe("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 | ||
| 65 | describe("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 | }); |