CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
env-health.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.
| bc519ae | 1 | /** |
| 2 | * Environment / feature health tests (src/lib/env-health.ts). | |
| 3 | * | |
| 4 | * Pure-function checks against synthetic env objects — no DB, no real | |
| 5 | * process.env mutation. Also smoke-tests the /admin/env-health route's | |
| 6 | * auth gate against the standalone Hono module (the route is mounted in | |
| 7 | * app.tsx, but testing the module directly keeps this independent of | |
| 8 | * mount order). | |
| 9 | */ | |
| 10 | ||
| 11 | import { describe, it, expect } from "bun:test"; | |
| 12 | import { | |
| 13 | collectEnvHealth, | |
| 14 | groupBySeverity, | |
| 15 | SEVERITY_ORDER, | |
| 16 | type EnvHealthItem, | |
| 17 | } from "../lib/env-health"; | |
| 18 | import envHealthRoutes from "../routes/admin-env-health"; | |
| 19 | ||
| 20 | /** A fully-wired synthetic env — every feature should report configured. */ | |
| 21 | const FULL_ENV: NodeJS.ProcessEnv = { | |
| 22 | ANTHROPIC_API_KEY: "sk-ant-secret-aaa", | |
| 23 | EMAIL_PROVIDER: "resend", | |
| 24 | RESEND_API_KEY: "re_secret_bbb", | |
| 25 | APP_BASE_URL: "https://gluecron.com", | |
| 26 | VOYAGE_API_KEY: "pa-secret-ccc", | |
| 27 | GATETEST_URL: "https://gatetest.ai/api/events/push", | |
| 28 | GATETEST_API_KEY: "gt_secret_ddd", | |
| 29 | GLUECRON_WEBHOOK_SECRET: "whsec_secret_eee", | |
| 30 | PREVIEW_DOMAIN: "https://previews.gluecron.com", | |
| 31 | SENTRY_DSN: "https://abc@o1.ingest.sentry.io/1", | |
| 32 | ERROR_WEBHOOK_URL: "https://hooks.example.com/errors", | |
| 33 | SSH_HOST_KEY: "-----BEGIN OPENSSH PRIVATE KEY-----secret-fff", | |
| 34 | REDIS_URL: "redis://:redis-secret-ggg@localhost:6379", | |
| 35 | GOOGLE_OAUTH_CLIENT_ID: "abc.apps.googleusercontent.com", | |
| 36 | GOOGLE_OAUTH_CLIENT_SECRET: "google-secret-hhh", | |
| 37 | AI_AUTO_ISSUES: "1", | |
| 38 | DEPENDENCY_SCAN_ENABLED: "1", | |
| 39 | }; | |
| 40 | ||
| 41 | describe("collectEnvHealth — configured detection", () => { | |
| 42 | it("reports every feature missing on an empty env", () => { | |
| 43 | const items = collectEnvHealth({}); | |
| 44 | expect(items.length).toBeGreaterThanOrEqual(12); | |
| 45 | expect(items.every((i) => i.configured === false)).toBe(true); | |
| 46 | }); | |
| 47 | ||
| 48 | it("reports every feature configured on a fully-wired env", () => { | |
| 49 | const items = collectEnvHealth(FULL_ENV); | |
| 50 | expect(items.every((i) => i.configured === true)).toBe(true); | |
| 51 | }); | |
| 52 | ||
| 53 | it("email needs EMAIL_PROVIDER=resend, not just the key", () => { | |
| 54 | const find = (env: NodeJS.ProcessEnv) => | |
| 55 | collectEnvHealth(env).find((i) => i.envVars.includes("RESEND_API_KEY"))!; | |
| 56 | // Key alone: still the dev "log" provider — mail goes to stderr. | |
| 57 | expect(find({ RESEND_API_KEY: "re_x" }).configured).toBe(false); | |
| 58 | // Provider alone: nothing to authenticate with. | |
| 59 | expect(find({ EMAIL_PROVIDER: "resend" }).configured).toBe(false); | |
| 60 | expect( | |
| 61 | find({ EMAIL_PROVIDER: "resend", RESEND_API_KEY: "re_x" }).configured | |
| 62 | ).toBe(true); | |
| 63 | }); | |
| 64 | ||
| 65 | it("APP_BASE_URL pointing at localhost does not count", () => { | |
| 66 | const find = (env: NodeJS.ProcessEnv) => | |
| 67 | collectEnvHealth(env).find((i) => i.envVars.includes("APP_BASE_URL"))!; | |
| 68 | expect(find({}).configured).toBe(false); | |
| 69 | expect(find({ APP_BASE_URL: "http://localhost:3000" }).configured).toBe( | |
| 70 | false | |
| 71 | ); | |
| 72 | expect(find({ APP_BASE_URL: "https://gluecron.com" }).configured).toBe( | |
| 73 | true | |
| 74 | ); | |
| 75 | }); | |
| 76 | ||
| 77 | it("either-or pairs: REDIS_URL/VALKEY_URL and SENTRY_DSN/ERROR_WEBHOOK_URL", () => { | |
| 78 | const sse = (env: NodeJS.ProcessEnv) => | |
| 79 | collectEnvHealth(env).find((i) => i.envVars.includes("REDIS_URL"))!; | |
| 80 | expect(sse({ REDIS_URL: "redis://x" }).configured).toBe(true); | |
| 81 | expect(sse({ VALKEY_URL: "redis://y" }).configured).toBe(true); | |
| 82 | expect(sse({}).configured).toBe(false); | |
| 83 | ||
| 84 | const errs = (env: NodeJS.ProcessEnv) => | |
| 85 | collectEnvHealth(env).find((i) => i.envVars.includes("SENTRY_DSN"))!; | |
| 86 | expect(errs({ SENTRY_DSN: "https://x" }).configured).toBe(true); | |
| 87 | expect(errs({ ERROR_WEBHOOK_URL: "https://y" }).configured).toBe(true); | |
| 88 | expect(errs({}).configured).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | it("opt-in flags require the literal \"1\"", () => { | |
| 92 | const auto = (env: NodeJS.ProcessEnv) => | |
| 93 | collectEnvHealth(env).find((i) => i.envVars.includes("AI_AUTO_ISSUES"))!; | |
| 94 | expect(auto({ AI_AUTO_ISSUES: "1" }).configured).toBe(true); | |
| 95 | expect(auto({ AI_AUTO_ISSUES: "true" }).configured).toBe(false); | |
| 96 | expect(auto({ AI_AUTO_ISSUES: "0" }).configured).toBe(false); | |
| 97 | }); | |
| 98 | ||
| 99 | it("whitespace-only values count as unset", () => { | |
| 100 | const items = collectEnvHealth({ ANTHROPIC_API_KEY: " " }); | |
| 101 | const ai = items.find((i) => i.envVars.includes("ANTHROPIC_API_KEY"))!; | |
| 102 | expect(ai.configured).toBe(false); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | describe("collectEnvHealth — shape + severity", () => { | |
| 107 | it("every item has the full shape and a valid severity", () => { | |
| 108 | for (const item of collectEnvHealth(FULL_ENV)) { | |
| 109 | expect(typeof item.feature).toBe("string"); | |
| 110 | expect(item.feature.length).toBeGreaterThan(0); | |
| 111 | expect(Array.isArray(item.envVars)).toBe(true); | |
| 112 | expect(item.envVars.length).toBeGreaterThan(0); | |
| 113 | expect(typeof item.configured).toBe("boolean"); | |
| 114 | expect(typeof item.impact).toBe("string"); | |
| 115 | expect(item.impact.length).toBeGreaterThan(0); | |
| 116 | expect(SEVERITY_ORDER).toContain(item.severity); | |
| 117 | } | |
| 118 | }); | |
| 119 | ||
| 120 | it("groupBySeverity returns critical → recommended → optional, no empties", () => { | |
| 121 | const groups = groupBySeverity(collectEnvHealth({})); | |
| 122 | expect(groups.map((g) => g.severity)).toEqual([ | |
| 123 | "critical", | |
| 124 | "recommended", | |
| 125 | "optional", | |
| 126 | ]); | |
| 127 | for (const g of groups) { | |
| 128 | expect(g.items.length).toBeGreaterThan(0); | |
| 129 | expect(g.items.every((i) => i.severity === g.severity)).toBe(true); | |
| 130 | } | |
| 131 | }); | |
| 132 | ||
| 133 | it("groupBySeverity drops empty buckets", () => { | |
| 134 | const only: EnvHealthItem[] = [ | |
| 135 | { | |
| 136 | feature: "x", | |
| 137 | envVars: ["X"], | |
| 138 | configured: false, | |
| 139 | impact: "y", | |
| 140 | severity: "optional", | |
| 141 | }, | |
| 142 | ]; | |
| 143 | const groups = groupBySeverity(only); | |
| 144 | expect(groups.length).toBe(1); | |
| 145 | expect(groups[0]!.severity).toBe("optional"); | |
| 146 | }); | |
| 147 | }); | |
| 148 | ||
| 149 | describe("collectEnvHealth — never leaks values", () => { | |
| 150 | it("no secret value from the env appears anywhere in the output", () => { | |
| 151 | const serialized = JSON.stringify(collectEnvHealth(FULL_ENV)); | |
| 152 | // Short non-secret knobs ("1", "resend") legitimately appear in the | |
| 153 | // impact prose — only assert on values long enough to be secrets/URLs. | |
| 154 | for (const value of Object.values(FULL_ENV)) { | |
| 155 | if (!value || value.length < 8) continue; | |
| 156 | expect(serialized).not.toContain(value); | |
| 157 | } | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("/admin/env-health — auth gate", () => { | |
| 162 | it("GET without auth → 302 /login", async () => { | |
| 163 | const res = await envHealthRoutes.request("/admin/env-health"); | |
| 164 | expect(res.status).toBe(302); | |
| 165 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 166 | }); | |
| 167 | }); |