@nakedev/go-scaffold 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,2058 @@
1
+ #!/usr/bin/env node
2
+ // End-to-end smoke test for the go-scaffold CLI: exercises create + generate
3
+ // module (full and minimal) + generate method against a real Go toolchain in
4
+ // a scratch directory, and checks the guard rails (bad names, duplicates,
5
+ // forbidden flags) actually reject. No Postgres required — integration
6
+ // tests inside the generated project skip gracefully if the DB isn't up,
7
+ // the same behavior the CLI itself scaffolds for every project.
8
+ import { execFileSync, spawn } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync, writeSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { createSmokeRunConfig } from "../dist/utils/smoke-run.js";
15
+
16
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
17
+ const CLI = path.join(ROOT, "bin", "go-scaffold.js");
18
+
19
+ let passed = 0;
20
+ let scratch;
21
+ let fullApp = null;
22
+ let smokeEnv = null;
23
+ const activeProcesses = new Set();
24
+ let hasPsql = false;
25
+ let cleanupStarted = false;
26
+ let sharedPostgresContainerId = null;
27
+
28
+ function step(name, fn) {
29
+ process.stdout.write(`- ${name} ... `);
30
+ try {
31
+ fn();
32
+ console.log("ok");
33
+ passed++;
34
+ } catch (err) {
35
+ console.log("FAILED");
36
+ // console.error + process.exit() can race: stderr isn't a TTY when output is
37
+ // piped/redirected (every way this script actually gets run — CI, `pnpm run
38
+ // verify`, a log file), so the write can still be buffered when exit() tears
39
+ // the process down, silently dropping the one line that explains the failure.
40
+ // writeSync is synchronous, so it's flushed before exit() runs.
41
+ const detail = err.stdout?.toString() || err.stderr?.toString() || err.message;
42
+ writeSync(2, `${detail}\n`);
43
+ cleanup();
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ function run(cmd, args, cwd, env) {
49
+ const inheritedSmokeEnv = cwd && cwd === fullApp && smokeEnv ? smokeEnv : {};
50
+ return execFileSync(cmd, args, {
51
+ cwd,
52
+ encoding: "utf8",
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ env: { ...process.env, ...inheritedSmokeEnv, ...env },
55
+ });
56
+ }
57
+
58
+ function findFreePort() {
59
+ const output = execFileSync(
60
+ process.execPath,
61
+ [
62
+ "-e",
63
+ "const net=require('node:net');const server=net.createServer();server.listen(0,'127.0.0.1',()=>{const address=server.address();server.close(()=>process.stdout.write(String(address.port)))});",
64
+ ],
65
+ { encoding: "utf8" }
66
+ ).trim();
67
+ const port = Number(output);
68
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
69
+ throw new Error(`could not allocate a TCP port for the smoke test: ${output}`);
70
+ }
71
+ return port;
72
+ }
73
+
74
+ function httpStatus(args, cwd) {
75
+ try {
76
+ return run("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", ...args], cwd).trim();
77
+ } catch {
78
+ return "000";
79
+ }
80
+ }
81
+
82
+ function goScaffold(args, cwd) {
83
+ return run("node", [CLI, ...args], cwd);
84
+ }
85
+
86
+ function expectThrows(fn, messageFragment) {
87
+ try {
88
+ fn();
89
+ } catch (err) {
90
+ const msg = (err.stdout?.toString() ?? "") + (err.stderr?.toString() ?? "") + err.message;
91
+ if (!msg.includes(messageFragment)) {
92
+ throw new Error(`expected error containing "${messageFragment}", got: ${msg}`);
93
+ }
94
+ return;
95
+ }
96
+ throw new Error(`expected an error containing "${messageFragment}", but it succeeded`);
97
+ }
98
+
99
+ function assertFileContains(filePath, needle) {
100
+ if (!existsSync(filePath)) throw new Error(`missing file: ${filePath}`);
101
+ const content = readFileSync(filePath, "utf8");
102
+ if (!content.includes(needle)) throw new Error(`${filePath} doesn't contain "${needle}"`);
103
+ }
104
+
105
+ // Set by the "add worker" step once it patches fullApp's readyz to also ping
106
+ // Redis — every later step in this file that boots fullApp's cmd/api shares
107
+ // that same project, so it needs a reachable Redis from that point on too,
108
+ // not just its own concern (DB, CORS, migrations, ...). Kept alive for the
109
+ // rest of the run instead of torn down at the end of that one step; cleaned
110
+ // up here at the very end.
111
+ let sharedRedisContainerId = null;
112
+ let sharedRedisUrl = null;
113
+
114
+ function ownedDockerContainerIds() {
115
+ try {
116
+ return execFileSync("docker", ["ps", "-aq", "--filter", `label=${smoke.dockerLabel}`], { encoding: "utf8" })
117
+ .trim()
118
+ .split("\n")
119
+ .filter(Boolean);
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ function cleanup() {
126
+ if (cleanupStarted) return;
127
+ cleanupStarted = true;
128
+ // Every resource gets an independent best-effort cleanup attempt. A stubborn
129
+ // child must not prevent its siblings, run databases, or run-labelled Docker
130
+ // containers from being cleaned up.
131
+ stopAllApis();
132
+ for (const db of [fullTestDb, obsDb, fullDb]) {
133
+ try {
134
+ if (db) dropDatabase(db.dbName);
135
+ } catch {
136
+ // best-effort — a failed cleanup here shouldn't mask the real test result
137
+ }
138
+ }
139
+ const containers = new Set([
140
+ ...ownedDockerContainerIds(),
141
+ sharedPostgresContainerId,
142
+ sharedRedisContainerId,
143
+ ]);
144
+ for (const containerId of containers) {
145
+ if (!containerId) continue;
146
+ try {
147
+ execFileSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
148
+ } catch {
149
+ // best-effort — a failed cleanup here shouldn't mask the real test result
150
+ }
151
+ }
152
+ if (scratch && existsSync(scratch)) rmSync(scratch, { recursive: true, force: true });
153
+ }
154
+
155
+ if (!existsSync(path.join(ROOT, "dist", "index.js"))) {
156
+ console.error("dist/index.js missing — run `pnpm run build` first");
157
+ process.exit(1);
158
+ }
159
+ try {
160
+ run("go", ["version"]);
161
+ } catch {
162
+ console.error("no Go toolchain on PATH — required for the smoke test");
163
+ process.exit(1);
164
+ }
165
+
166
+ scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-smoke-"));
167
+ let smoke = createSmokeRunConfig(`${process.pid}-${randomUUID().slice(0, 8)}`, findFreePort());
168
+ let fullDb = smoke;
169
+ let obsDb = createSmokeRunConfig(`${smoke.runID}-observability`, smoke.port, smoke.dbPort);
170
+ let fullTestDb = createSmokeRunConfig(`${smoke.runID}-test`, smoke.port, smoke.dbPort);
171
+
172
+ function configureSmokeResources({ port = smoke.port, dbPort = smoke.dbPort } = {}) {
173
+ smoke = createSmokeRunConfig(smoke.runID, port, dbPort);
174
+ fullDb = smoke;
175
+ obsDb = createSmokeRunConfig(`${smoke.runID}-observability`, smoke.port, smoke.dbPort);
176
+ fullTestDb = createSmokeRunConfig(`${smoke.runID}-test`, smoke.port, smoke.dbPort);
177
+ smokeEnv = { ...runtimeEnv(fullDb), TEST_DB_DSN: fullTestDb.dbDsn };
178
+ }
179
+
180
+ function allocateReplacementAppPort() {
181
+ if (activeProcesses.size > 0) {
182
+ throw new Error("cannot replace the smoke-test port while an owned process is running");
183
+ }
184
+ configureSmokeResources({ port: findFreePort() });
185
+ }
186
+
187
+ function runtimeEnv(db, overrides = {}) {
188
+ return {
189
+ BASE_URL: smoke.baseURL,
190
+ DB_DSN: db.dbDsn,
191
+ PORT: String(smoke.port),
192
+ SMOKE_LOG_DIR: scratch,
193
+ GO_SCAFFOLD_SMOKE_OWNER: smoke.ownerToken,
194
+ ...overrides,
195
+ };
196
+ }
197
+
198
+ configureSmokeResources();
199
+
200
+ function dropDatabase(dbName) {
201
+ const sql = `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid(); DROP DATABASE IF EXISTS ${dbName};`;
202
+ if (sharedPostgresContainerId) {
203
+ execFileSync("docker", ["exec", "-e", "PGPASSWORD=postgres", sharedPostgresContainerId, "psql", "-U", "postgres", "-d", "postgres", "-v", "ON_ERROR_STOP=1", "-c", sql], { stdio: "ignore" });
204
+ return;
205
+ }
206
+ if (hasPsql) {
207
+ execFileSync("psql", ["-h", fullDb.dbHost, "-p", String(fullDb.dbPort), "-U", "postgres", "-d", "postgres", "-v", "ON_ERROR_STOP=1", "-c", sql], {
208
+ stdio: "ignore",
209
+ env: { ...process.env, PGPASSWORD: "postgres" },
210
+ });
211
+ }
212
+ }
213
+
214
+ function exitAfterCleanup(exitCode) {
215
+ cleanup();
216
+ process.exit(exitCode);
217
+ }
218
+
219
+ process.once("SIGINT", () => exitAfterCleanup(130));
220
+ process.once("SIGTERM", () => exitAfterCleanup(143));
221
+
222
+ function runMake(args, cwd, db = fullDb) {
223
+ return run("make", args, cwd, {
224
+ DB_HOST: db.dbHost,
225
+ DB_NAME: db.dbName,
226
+ DB_PORT: String(db.dbPort),
227
+ POSTGRES_CONTAINER: sharedPostgresContainerId ?? "",
228
+ });
229
+ }
230
+
231
+ function logPath(name) {
232
+ return path.join(scratch, `${smoke.logPrefix}-${name}.log`);
233
+ }
234
+
235
+ function isPortCollisionLog(logFile) {
236
+ return existsSync(logFile) && /address already in use|bind: address already in use/i.test(readFileSync(logFile, "utf8"));
237
+ }
238
+
239
+ function listenerPids(port) {
240
+ try {
241
+ return execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], { encoding: "utf8" })
242
+ .split("\n")
243
+ .filter((line) => line.startsWith("p"))
244
+ .map((line) => Number(line.slice(1)))
245
+ .filter(Number.isInteger);
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ function portOwnership(server, port) {
252
+ const listeners = listenerPids(port);
253
+ if (listeners.length === 0) return "none";
254
+ const owned = new Set((server.trackDescendants ? ownedProcessDescriptors(server.rootProcess) : [server.rootProcess]).map(({ pid }) => pid));
255
+ return listeners.every((pid) => owned.has(pid)) ? "owned" : "foreign";
256
+ }
257
+
258
+ function waitForPortOwnership(server, port, attempts = 50) {
259
+ for (let attempt = 0; attempt < attempts; attempt++) {
260
+ const ownership = portOwnership(server, port);
261
+ if (ownership !== "none") return ownership;
262
+ if (!isSameProcess(server.rootProcess)) return "exited";
263
+ execFileSync("sleep", ["0.1"]);
264
+ }
265
+ return "timeout";
266
+ }
267
+
268
+ function startApi(cwd, name, db = fullDb, overrides = {}) {
269
+ const binaryPath = path.join(cwd, `.smoke-api-${smoke.runID}-${name}`);
270
+ run("go", ["build", "-o", binaryPath, "./cmd/api"], cwd);
271
+
272
+ for (let attempt = 1; attempt <= 3; attempt++) {
273
+ // Close the free-port TOCTOU window before launching: never even spawn an
274
+ // API against a listener we did not create.
275
+ if (listenerPids(smoke.port).length > 0) {
276
+ if (attempt < 3) {
277
+ allocateReplacementAppPort();
278
+ continue;
279
+ }
280
+ throw new Error(`could not allocate an unoccupied API port for ${name}`);
281
+ }
282
+ const outputPath = logPath(name);
283
+ const output = openSync(outputPath, "w");
284
+ const child = spawn(binaryPath, [], {
285
+ cwd,
286
+ env: { ...process.env, ...runtimeEnv(db, overrides) },
287
+ stdio: ["ignore", output, output],
288
+ });
289
+ closeSync(output);
290
+ if (!child.pid) {
291
+ // A launch can fail before Node gives us a child PID (for example while
292
+ // a competing listener owns the selected port). Give the competing
293
+ // listener a scheduling turn before classifying the allocation race.
294
+ execFileSync("sleep", ["0.1"]);
295
+ if (attempt < 3 && listenerPids(smoke.port).length > 0) {
296
+ allocateReplacementAppPort();
297
+ continue;
298
+ }
299
+ throw new Error(`could not start API process for ${name}`);
300
+ }
301
+ const rootProcess = waitForOwnedProcess(child.pid);
302
+ if (!rootProcess) {
303
+ const portIsOccupied = listenerPids(smoke.port).length > 0;
304
+ if (attempt < 3 && (portIsOccupied || isPortCollisionLog(outputPath))) {
305
+ allocateReplacementAppPort();
306
+ continue;
307
+ }
308
+ if (portIsOccupied && attempt >= 3) {
309
+ throw new Error(`could not allocate a run-owned API port for ${name} after ${attempt} attempts`);
310
+ }
311
+ throw new Error(`could not inspect API process for ${name}; see ${outputPath}`);
312
+ }
313
+ const server = { binaryPath, rootProcess };
314
+ const ownership = waitForPortOwnership(server, smoke.port);
315
+ if (ownership === "owned") {
316
+ activeProcesses.add(server);
317
+ return server;
318
+ }
319
+ stopApi(server);
320
+ if (attempt < 3 && ownership === "foreign") {
321
+ allocateReplacementAppPort();
322
+ continue;
323
+ }
324
+ throw new Error(`API process for ${name} did not bind its run-owned port (${ownership}); see ${logPath(name)}`);
325
+ }
326
+
327
+ throw new Error(`could not allocate an API port for ${name} after 3 attempts`);
328
+ }
329
+
330
+ function startWorker(cwd, name, overrides = {}) {
331
+ const binaryPath = path.join(cwd, `.smoke-worker-${smoke.runID}-${name}`);
332
+ run("go", ["build", "-o", binaryPath, "./cmd/worker"], cwd);
333
+
334
+ const output = openSync(logPath(name), "w");
335
+ const child = spawn(binaryPath, [], {
336
+ cwd,
337
+ env: { ...process.env, ...runtimeEnv(fullDb, overrides) },
338
+ stdio: ["ignore", output, output],
339
+ });
340
+ closeSync(output);
341
+ if (!child.pid) throw new Error(`could not start worker process for ${name}`);
342
+ const rootProcess = waitForOwnedProcess(child.pid);
343
+ if (!rootProcess) throw new Error(`could not inspect worker process for ${name}`);
344
+
345
+ const worker = { binaryPath, rootProcess };
346
+ activeProcesses.add(worker);
347
+ return worker;
348
+ }
349
+
350
+ function syncMakeRunPort(cwd) {
351
+ const envFile = path.join(cwd, ".env");
352
+ if (!existsSync(envFile)) return;
353
+ const envContent = readFileSync(envFile, "utf8");
354
+ writeFileSync(envFile, envContent.replace(/^PORT=.*/m, `PORT=${smoke.port}`));
355
+ }
356
+
357
+ function startMakeRun(cwd, name, expectListener) {
358
+ for (let attempt = 1; attempt <= 3; attempt++) {
359
+ if (listenerPids(smoke.port).length > 0) {
360
+ if (attempt < 3) {
361
+ allocateReplacementAppPort();
362
+ continue;
363
+ }
364
+ throw new Error(`could not allocate an unoccupied make run port for ${name}`);
365
+ }
366
+ syncMakeRunPort(cwd);
367
+ const output = openSync(logPath(name), "w");
368
+ const child = spawn("make", ["run"], {
369
+ cwd,
370
+ detached: true,
371
+ env: { ...process.env, ...smokeEnv },
372
+ stdio: ["ignore", output, output],
373
+ });
374
+ closeSync(output);
375
+ if (!child.pid) throw new Error(`could not start make run for ${name}`);
376
+
377
+ const rootProcess = waitForOwnedProcess(child.pid, expectListener ? 300 : 20);
378
+ if (!rootProcess) {
379
+ // The intentionally blocked migration case can make `make run` exit
380
+ // before ps observes it. It owns no listener; reject and retry only if
381
+ // another process occupied this run's port.
382
+ if (!expectListener && listenerPids(smoke.port).length === 0) return null;
383
+ if (attempt < 3 && listenerPids(smoke.port).length > 0) {
384
+ allocateReplacementAppPort();
385
+ continue;
386
+ }
387
+ throw new Error(`could not inspect make run process for ${name}`);
388
+ }
389
+ const server = { rootProcess, trackDescendants: true };
390
+ const ownership = waitForPortOwnership(server, smoke.port, expectListener ? 150 : 50);
391
+ if ((expectListener && ownership === "owned") || (!expectListener && ownership !== "foreign")) {
392
+ activeProcesses.add(server);
393
+ return server;
394
+ }
395
+ stopApi(server);
396
+ if (attempt < 3 && ownership === "foreign") {
397
+ allocateReplacementAppPort();
398
+ continue;
399
+ }
400
+ throw new Error(`make run for ${name} did not bind its run-owned port (${ownership}); see ${logPath(name)}`);
401
+ }
402
+
403
+ throw new Error(`could not allocate a make run port for ${name} after 3 attempts`);
404
+ }
405
+
406
+ function processDescriptor(pid) {
407
+ try {
408
+ const output = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,stat="], { encoding: "utf8" }).trim();
409
+ const match = output.match(/^(.+)\s+(\S+)$/);
410
+ if (!match) return null;
411
+ const environment = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], { encoding: "utf8" });
412
+ if (!environment.includes(`GO_SCAFFOLD_SMOKE_OWNER=${smoke.ownerToken}`)) return null;
413
+ return { pid, startedAt: match[1], state: match[2], ownerToken: smoke.ownerToken };
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ function ownerProcessDescriptors() {
420
+ try {
421
+ const output = execFileSync("ps", ["eww", "-axo", "pid=,command="], { encoding: "utf8" });
422
+ const pids = output
423
+ .split("\n")
424
+ .filter((line) => line.includes(`GO_SCAFFOLD_SMOKE_OWNER=${smoke.ownerToken}`))
425
+ .map((line) => Number(line.trim().split(/\s+/, 1)[0]))
426
+ .filter(Number.isInteger);
427
+ return pids.map(processDescriptor).filter(Boolean);
428
+ } catch {
429
+ return [];
430
+ }
431
+ }
432
+
433
+ function waitForOwnedProcess(pid, attempts = 20) {
434
+ for (let attempt = 0; attempt < attempts; attempt++) {
435
+ const direct = processDescriptor(pid);
436
+ if (direct) return direct;
437
+ const inherited = ownerProcessDescriptors()[0];
438
+ if (inherited) return inherited;
439
+ execFileSync("sleep", ["0.05"]);
440
+ }
441
+ return null;
442
+ }
443
+
444
+ function isSameProcess(ownedProcess) {
445
+ const current = processDescriptor(ownedProcess.pid);
446
+ return current?.startedAt === ownedProcess.startedAt && current.ownerToken === ownedProcess.ownerToken && !current.state.startsWith("Z");
447
+ }
448
+
449
+ function ownedProcessDescriptors(root) {
450
+ if (!isSameProcess(root)) return [];
451
+ const rows = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" })
452
+ .trim()
453
+ .split("\n")
454
+ .map((line) => {
455
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
456
+ return match ? { pid: Number(match[1]), parentPid: Number(match[2]) } : null;
457
+ })
458
+ .filter(Boolean);
459
+ const childrenByParent = new Map();
460
+ for (const child of rows) {
461
+ const children = childrenByParent.get(child.parentPid) ?? [];
462
+ children.push(child.pid);
463
+ childrenByParent.set(child.parentPid, children);
464
+ }
465
+
466
+ const candidatePids = [root.pid];
467
+ const pending = [root.pid];
468
+ while (pending.length > 0) {
469
+ const pid = pending.pop();
470
+ for (const childPid of childrenByParent.get(pid) ?? []) {
471
+ candidatePids.push(childPid);
472
+ pending.push(childPid);
473
+ }
474
+ }
475
+ return candidatePids.map(processDescriptor).filter(Boolean);
476
+ }
477
+
478
+ function signalOwnedProcesses(processes, signal) {
479
+ for (const ownedProcess of [...processes].reverse()) {
480
+ if (!isSameProcess(ownedProcess)) continue;
481
+ try {
482
+ globalThis.process.kill(ownedProcess.pid, signal);
483
+ } catch (err) {
484
+ if (err.code !== "ESRCH") throw err;
485
+ }
486
+ }
487
+ }
488
+
489
+ function stopOwnedProcesses(processes) {
490
+ signalOwnedProcesses(processes, "SIGTERM");
491
+ for (let attempt = 0; attempt < 10; attempt++) {
492
+ const stillRunning = processes.filter(isSameProcess);
493
+ if (stillRunning.length === 0) return;
494
+ execFileSync("sleep", ["0.2"]);
495
+ }
496
+ let stillRunning = processes.filter(isSameProcess);
497
+ if (stillRunning.length > 0) {
498
+ // SIGTERM is preferred, but workers may not drain promptly. Re-check each
499
+ // process identity immediately before SIGKILL so a recycled PID can never
500
+ // target an unrelated host process.
501
+ signalOwnedProcesses(stillRunning, "SIGKILL");
502
+ execFileSync("sleep", ["0.1"]);
503
+ stillRunning = stillRunning.filter(isSameProcess);
504
+ }
505
+ if (stillRunning.length > 0) {
506
+ throw new Error(`owned smoke process did not exit: ${stillRunning.map(({ pid }) => pid).join(", ")}`);
507
+ }
508
+ }
509
+
510
+ function stopApi(server) {
511
+ if (!server) return;
512
+ const processes = server.trackDescendants ? ownedProcessDescriptors(server.rootProcess) : [server.rootProcess];
513
+ stopOwnedProcesses(processes);
514
+ activeProcesses.delete(server);
515
+ if (server.binaryPath) rmSync(server.binaryPath, { force: true });
516
+ }
517
+
518
+ function stopAllApis() {
519
+ for (const server of [...activeProcesses]) {
520
+ try {
521
+ stopApi(server);
522
+ } catch {
523
+ // Continue with every remaining owned child; cleanup must be exhaustive.
524
+ }
525
+ }
526
+ try {
527
+ // Catch descendants that appeared after a failed/fast `make run` launch,
528
+ // before its process handle could be recorded in activeProcesses.
529
+ stopOwnedProcesses(ownerProcessDescriptors());
530
+ } catch {
531
+ // Container/DB cleanup below must still run.
532
+ }
533
+ }
534
+
535
+ console.log(`scratch dir: ${scratch}\nrun: ${smoke.runID}, db: ${smoke.dbName}, port: ${smoke.port}\n`);
536
+
537
+ step("rejects an invalid project name before writing anything", () => {
538
+ expectThrows(() => goScaffold(["create", "My Cool App", "--defaults"], scratch), "invalid project name");
539
+ });
540
+
541
+ step("create --defaults scaffolds a bare project", () => {
542
+ goScaffold(["create", "full-app", "--defaults"], scratch);
543
+ assertFileContains(path.join(scratch, "full-app", "go.mod"), "module full-app");
544
+ });
545
+
546
+ step("create stamps the CLI version into go-scaffold.config.json", () => {
547
+ const cfg = JSON.parse(readFileSync(path.join(scratch, "full-app", "go-scaffold.config.json"), "utf8"));
548
+ const { version } = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
549
+ if (cfg.scaffoldVersion !== version) {
550
+ throw new Error(`expected scaffoldVersion "${version}", got "${cfg.scaffoldVersion}"`);
551
+ }
552
+ });
553
+
554
+ fullApp = path.join(scratch, "full-app");
555
+ step("bare project: go mod tidy + build + vet", () => {
556
+ run("go", ["mod", "tidy"], fullApp);
557
+ run("go", ["build", "./..."], fullApp);
558
+ run("go", ["vet", "./..."], fullApp);
559
+ });
560
+
561
+ // config.Load() is the first line of main(), before database.Open — an
562
+ // unrecognized APP_ENV must panic right there, with no DB required to prove it.
563
+ step("APP_ENV rejects an unrecognized value at boot (fails closed)", () => {
564
+ try {
565
+ run("go", ["run", "./cmd/api"], fullApp, { APP_ENV: "staging" });
566
+ throw new Error("expected `go run` to exit nonzero on an invalid APP_ENV, it exited 0");
567
+ } catch (err) {
568
+ const out = `${err.stdout ?? ""}${err.stderr ?? ""}`;
569
+ if (!out.includes('invalid APP_ENV "staging"')) {
570
+ throw new Error(`expected a panic naming the bad APP_ENV value, got:\n${out}`);
571
+ }
572
+ }
573
+ });
574
+
575
+ // middleware.Error(exposeDetail)'s whole reason to exist: prod must not leak
576
+ // Details (a validation field map, or an unexpected error's real message) to
577
+ // a caller. The generated CRUD stub's DTO has no fields, so nothing over real
578
+ // HTTP naturally exercises the field-map path — test the middleware directly
579
+ // instead, against the exact package layout `create` scaffolds.
580
+ step("middleware.Error hides Details in prod, shows them outside it", () => {
581
+ const probeDir = path.join(fullApp, "cmd", "_smoke_probe_apperr");
582
+ const { goModule } = JSON.parse(readFileSync(path.join(fullApp, "go-scaffold.config.json"), "utf8"));
583
+ execFileSync("mkdir", ["-p", probeDir]);
584
+ writeFileSync(
585
+ path.join(probeDir, "main.go"),
586
+ `package main
587
+
588
+ import (
589
+ "encoding/json"
590
+ "fmt"
591
+ "net/http"
592
+ "net/http/httptest"
593
+
594
+ "${goModule}/internal/shared/apperror"
595
+ "${goModule}/internal/shared/middleware"
596
+
597
+ "github.com/gin-gonic/gin"
598
+ )
599
+
600
+ func try(expose bool, err error) {
601
+ gin.SetMode(gin.TestMode)
602
+ r := gin.New()
603
+ r.Use(middleware.RequestID(), middleware.Error(expose))
604
+ r.GET("/x", func(c *gin.Context) { c.Error(err) })
605
+ req := httptest.NewRequest(http.MethodGet, "/x", nil)
606
+ w := httptest.NewRecorder()
607
+ r.ServeHTTP(w, req)
608
+ var body map[string]any
609
+ json.Unmarshal(w.Body.Bytes(), &body)
610
+ out, _ := json.Marshal(body)
611
+ fmt.Printf("expose=%v %s\\n", expose, out)
612
+ }
613
+
614
+ func main() {
615
+ try(true, apperror.NewValidation("invalid input", map[string]string{"email": "required"}))
616
+ try(false, apperror.NewValidation("invalid input", map[string]string{"email": "required"}))
617
+ try(true, fmt.Errorf("pq: connection refused"))
618
+ try(false, fmt.Errorf("pq: connection refused"))
619
+ }
620
+ `
621
+ );
622
+ try {
623
+ const out = run("go", ["run", "./cmd/_smoke_probe_apperr"], fullApp);
624
+ if (!/expose=true .*"details":\{"email":"required"\}/.test(out)) {
625
+ throw new Error(`expected exposed Details for a known AppError, got:\n${out}`);
626
+ }
627
+ if (/expose=false .*"details"/.test(out)) {
628
+ throw new Error(`expected no "details" key at all when exposeDetail is false, got:\n${out}`);
629
+ }
630
+ if (!/expose=true .*"details":"pq: connection refused"/.test(out)) {
631
+ throw new Error(`expected the real error text exposed for an unexpected error, got:\n${out}`);
632
+ }
633
+ } finally {
634
+ rmSync(probeDir, { recursive: true, force: true });
635
+ }
636
+ });
637
+
638
+ let hasDocker = true;
639
+ try {
640
+ run("docker", ["--version"]);
641
+ } catch {
642
+ hasDocker = false;
643
+ }
644
+
645
+ // DB-backed smoke checks always get a run-owned PostgreSQL container. The host
646
+ // port is Docker-assigned, so simultaneous suites never borrow or remove a
647
+ // developer's database or another smoke run's container.
648
+ if (hasDocker) {
649
+ try {
650
+ sharedPostgresContainerId = run("docker", [
651
+ "run",
652
+ "-d",
653
+ "--name",
654
+ `${smoke.containerNamePrefix}-postgres`,
655
+ "--label",
656
+ smoke.dockerLabel,
657
+ "-p",
658
+ "127.0.0.1::5432",
659
+ "-e",
660
+ "POSTGRES_USER=postgres",
661
+ "-e",
662
+ "POSTGRES_PASSWORD=postgres",
663
+ "postgres:16-alpine",
664
+ ]).trim();
665
+ const dbPort = Number(readMappedPort(sharedPostgresContainerId, "5432/tcp"));
666
+ configureSmokeResources({ dbPort });
667
+ let ready = false;
668
+ for (let i = 0; i < 30; i++) {
669
+ try {
670
+ run("docker", ["exec", sharedPostgresContainerId, "pg_isready", "-U", "postgres"]);
671
+ ready = true;
672
+ break;
673
+ } catch {
674
+ run("sleep", ["0.3"]);
675
+ }
676
+ }
677
+ if (!ready) throw new Error("Postgres container never became ready");
678
+ } catch (err) {
679
+ if (sharedPostgresContainerId) {
680
+ try {
681
+ run("docker", ["rm", "-f", sharedPostgresContainerId]);
682
+ } catch {
683
+ // preserve the original startup error
684
+ }
685
+ sharedPostgresContainerId = null;
686
+ }
687
+ console.warn(`warning: could not provision a run-owned smoke-test Postgres (${err.message}); DB-backed checks will be skipped`);
688
+ hasDocker = false;
689
+ }
690
+ }
691
+
692
+ let hasNpx = true;
693
+ try {
694
+ run("npx", ["--version"]);
695
+ } catch {
696
+ hasNpx = false;
697
+ }
698
+
699
+ // docker port prints one line per protocol family ("0.0.0.0:PORT" and
700
+ // "[::]:PORT") for an ephemeral (-p 0:CONTAINER_PORT) mapping — both name the
701
+ // same host port, so the last field after splitting on ":" is it regardless
702
+ // of which line answers first.
703
+ function readMappedPort(containerId, containerPort = "6379/tcp") {
704
+ const portMap = run("docker", ["port", containerId, containerPort]).trim();
705
+ return portMap.split(":").pop();
706
+ }
707
+
708
+ // polls a localhost TCP port via bash's /dev/tcp (no netcat/redis-cli
709
+ // dependency on the host) — used to wait out the gap between `docker start`
710
+ // returning and the host-mapped port actually accepting connections again.
711
+ function waitForPort(port, attempts = 30) {
712
+ for (let i = 0; i < attempts; i++) {
713
+ try {
714
+ run("bash", ["-c", `echo > /dev/tcp/127.0.0.1/${port}`]);
715
+ return;
716
+ } catch {
717
+ run("sleep", ["0.3"]);
718
+ }
719
+ }
720
+ throw new Error(`port ${port} never accepted a connection`);
721
+ }
722
+
723
+ // `add worker` end to end: readyz picks up a real Redis outage, and a task
724
+ // enqueued through mail.AsyncClient is actually processed by cmd/worker (dev
725
+ // fallback: logs instead of sending, since SMTP_HOST is unset). Runs its own
726
+ // throwaway Redis on a Docker-assigned ephemeral port — not the host's
727
+ // default 6379 — so it can't collide with a Redis someone already has
728
+ // running (this dev machine's own has auth enabled, which would otherwise
729
+ // make the readyz check fail for a reason that has nothing to do with the
730
+ // scaffold's own correctness).
731
+ step(hasDocker ? "add worker: scaffolds cache/queue/mail/cmd/worker, wires readyz, processes a real task" : "add worker: skipped (needs Docker for an isolated Redis)", () => {
732
+ if (!hasDocker) return;
733
+
734
+ goScaffold(["add", "worker"], fullApp);
735
+ run("go", ["mod", "tidy"], fullApp);
736
+ run("go", ["build", "./..."], fullApp);
737
+ run("go", ["vet", "./..."], fullApp);
738
+
739
+ // cmd/api opens Postgres before it ever gets to Redis (database.Open runs
740
+ // first in main()) — needs a real DB up before it'll boot far enough to
741
+ // reach the readyz check this step is actually testing. Let the Makefile
742
+ // target handle its own local-psql-vs-docker fallback, same as every other
743
+ // step that needs a DB. Verify the selected container sees the database so
744
+ // this test cannot silently depend on state left by an earlier smoke run.
745
+ runMake(["db-drop"], fullApp);
746
+ const dbCreateOutput = runMake(["db-create"], fullApp);
747
+ const postgresContainer = sharedPostgresContainerId;
748
+ const createdDatabase = run(
749
+ "docker",
750
+ ["exec", postgresContainer, "psql", "-U", "postgres", "-d", "postgres", "-Atc", `SELECT datname FROM pg_database WHERE datname = '${fullDb.dbName}'`],
751
+ ).trim();
752
+ if (createdDatabase !== fullDb.dbName) {
753
+ throw new Error(`db-create reported success but ${fullDb.dbName} is absent:\n${dbCreateOutput}`);
754
+ }
755
+
756
+ // no --rm: this step stops the container mid-test (to prove readyz notices
757
+ // Redis going down) then starts it again. It also stays alive past the end
758
+ // of this step (cleaned up in the top-level cleanup() instead): once
759
+ // patched, every later step that boots fullApp's cmd/api needs Redis
760
+ // reachable too, since they all share this one scratch project — not just
761
+ // this step's own concern.
762
+ const containerId = run("docker", [
763
+ "run",
764
+ "-d",
765
+ "--name",
766
+ `${smoke.containerNamePrefix}-redis`,
767
+ "--label",
768
+ smoke.dockerLabel,
769
+ "-p",
770
+ "127.0.0.1::6379",
771
+ "redis:7-alpine",
772
+ ]).trim();
773
+ sharedRedisContainerId = containerId;
774
+ {
775
+ let redisPort = readMappedPort(containerId);
776
+ let redisUrl = `redis://localhost:${redisPort}/0`;
777
+
778
+ // `docker exec redis-cli ping` only proves the container's *internal*
779
+ // network is up — it says nothing about the host-mapped port this test
780
+ // (and the app) actually connects through, which can lag behind after a
781
+ // stop/start cycle. Poll the real host port instead, via bash's /dev/tcp
782
+ // (no extra tool dependency).
783
+ waitForPort(redisPort);
784
+
785
+ const readyApi = startApi(fullApp, "worker-api-ready", fullDb, { REDIS_URL: redisUrl });
786
+ execFileSync("sleep", ["3"]);
787
+ const upCode = httpStatus([`${smoke.baseURL}/readyz`], fullApp);
788
+ stopApi(readyApi);
789
+ if (upCode !== "200") throw new Error(`expected readyz 200 with Redis reachable, got: ${upCode}`);
790
+
791
+ run("docker", ["stop", containerId]);
792
+ const unavailableApi = startApi(fullApp, "worker-api-unavailable", fullDb, { REDIS_URL: redisUrl });
793
+ execFileSync("sleep", ["3"]);
794
+ const downCode = httpStatus([`${smoke.baseURL}/readyz`], fullApp);
795
+ stopApi(unavailableApi);
796
+ if (downCode !== "503") throw new Error(`expected readyz 503 with Redis down, got: ${downCode}`);
797
+ run("docker", ["start", containerId]);
798
+ // Docker Desktop can hand out a *different* random host port on restart
799
+ // when the container was published with an ephemeral mapping (-p 0:6379)
800
+ // — re-read it rather than assuming `docker start` preserves the one from
801
+ // `docker run`. Silently reusing the stale port here is exactly what
802
+ // produced a flaky "port never accepted a connection" failure while
803
+ // testing this: the container's logs showed Redis back up and accepting
804
+ // connections in well under a second, on a port one number higher than
805
+ // what this step kept polling.
806
+ redisPort = readMappedPort(containerId);
807
+ redisUrl = `redis://localhost:${redisPort}/0`;
808
+ waitForPort(redisPort);
809
+
810
+ const probeDir = path.join(fullApp, "cmd", "_smoke_probe_enqueue");
811
+ execFileSync("mkdir", ["-p", probeDir]);
812
+ writeFileSync(
813
+ path.join(probeDir, "main.go"),
814
+ `package main
815
+
816
+ import (
817
+ "full-app/internal/platform/mail"
818
+ "full-app/internal/platform/queue"
819
+ )
820
+
821
+ func main() {
822
+ q, err := queue.NewClient("${redisUrl}")
823
+ if err != nil {
824
+ panic(err)
825
+ }
826
+ ac := mail.NewAsyncClient(q)
827
+ if err := ac.Send("someone@example.com", "smoke test", "processed by cmd/worker"); err != nil {
828
+ panic(err)
829
+ }
830
+ }
831
+ `
832
+ );
833
+
834
+ const workerLogPath = logPath("worker");
835
+ const worker = startWorker(fullApp, "worker", { REDIS_URL: redisUrl });
836
+ execFileSync("sleep", ["2"]);
837
+ run("go", ["run", "./cmd/_smoke_probe_enqueue"], fullApp);
838
+ execFileSync("sleep", ["2"]);
839
+ stopApi(worker);
840
+ const workerLog = readFileSync(workerLogPath, "utf8");
841
+ if (!workerLog.includes("email not sent (SMTP not configured)") || !workerLog.includes("processed by cmd/worker")) {
842
+ throw new Error(`expected cmd/worker to process the enqueued task (dev SMTP fallback), got:\n${workerLog}`);
843
+ }
844
+ if (workerLog.includes("!BADKEY")) {
845
+ throw new Error(`asynq's own log lines are leaking through the slog adapter malformed, got:\n${workerLog}`);
846
+ }
847
+
848
+ rmSync(probeDir, { recursive: true, force: true });
849
+ runMake(["db-drop"], fullApp);
850
+ sharedRedisUrl = redisUrl; // later steps that boot fullApp's cmd/api reuse this
851
+ }
852
+ });
853
+
854
+ // `add auth` requires `add worker` to already have run (needs Redis for the
855
+ // refresh token store) — proven against the real compiled server: register,
856
+ // a duplicate register, a wrong-password login, /me with/without a token,
857
+ // refresh rotation, and reuse-detection (replaying a rotated-out refresh
858
+ // token must revoke every session for that user, not just the replayed one).
859
+ step(hasDocker ? "add auth: register/login/refresh rotation+reuse-detection/logout/me/forgot-reset-password/verify-email/google-oauth-redirect/rate-limiting against a real server" : "add auth: skipped (needs Docker for add worker's Redis)", () => {
860
+ if (!hasDocker) return;
861
+
862
+ goScaffold(["add", "auth"], fullApp);
863
+ run("go", ["mod", "tidy"], fullApp);
864
+ run("go", ["build", "./..."], fullApp);
865
+ run("go", ["vet", "./..."], fullApp);
866
+
867
+ runMake(["db-drop"], fullApp);
868
+ runMake(["db-create"], fullApp);
869
+
870
+ const authApi = startApi(fullApp, "auth-api", fullDb, {
871
+ REDIS_URL: sharedRedisUrl,
872
+ AUTO_MIGRATE: "true",
873
+ });
874
+ execFileSync("sleep", ["3"]);
875
+
876
+ const B = `${smoke.baseURL}/v1`;
877
+ const jsonHeader = ["-H", "Content-Type: application/json"];
878
+ const status = (out) => (out.match(/HTTPSTATUS:(\d+)/) ?? [])[1];
879
+ const field = (out, key) => (out.match(new RegExp(`"${key}":"([^"]*)"`)) ?? [])[1];
880
+ const cookie = (out) => (out.match(/Set-Cookie: refresh_token=([^;]*);/) ?? [])[1];
881
+
882
+ const register = run("curl", ["-s", "-i", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"correcthorsebattery","name":"Alice"}']);
883
+ if (!/^HTTP\/1\.1 201/.test(register)) throw new Error(`expected 201 on register, got:\n${register}`);
884
+ const registerCookie = cookie(register);
885
+ if (!registerCookie) throw new Error(`expected a refresh_token cookie on register, got:\n${register}`);
886
+
887
+ const dup = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"correcthorsebattery","name":"Alice"}']);
888
+ if (status(dup) !== "409" || !dup.includes("USER_EMAIL_TAKEN")) throw new Error(`expected 409 USER_EMAIL_TAKEN on duplicate register, got:\n${dup}`);
889
+
890
+ const badLogin = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"wrong"}']);
891
+ if (status(badLogin) !== "401" || !badLogin.includes("AUTH_INVALID_CREDENTIALS")) throw new Error(`expected 401 AUTH_INVALID_CREDENTIALS on wrong password, got:\n${badLogin}`);
892
+
893
+ const login = run("curl", ["-s", "-i", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"correcthorsebattery"}']);
894
+ if (!/^HTTP\/1\.1 200/.test(login)) throw new Error(`expected 200 on login, got:\n${login}`);
895
+ const access = field(login, "access_token");
896
+ const loginCookie = cookie(login);
897
+ if (!access || !loginCookie) throw new Error(`expected access_token + refresh_token cookie on login, got:\n${login}`);
898
+
899
+ const meNoToken = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/me`]);
900
+ if (status(meNoToken) !== "401") throw new Error(`expected 401 on /me with no token, got:\n${meNoToken}`);
901
+
902
+ const meWithToken = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/me`, "-H", `Authorization: Bearer ${access}`]);
903
+ if (status(meWithToken) !== "200" || !meWithToken.includes('"email":"alice@example.com"')) {
904
+ throw new Error(`expected 200 with alice's email on /me, got:\n${meWithToken}`);
905
+ }
906
+
907
+ const rotated = run("curl", ["-s", "-i", "-X", "POST", `${B}/auth/refresh`, "-H", `Cookie: refresh_token=${loginCookie}`]);
908
+ if (!/^HTTP\/1\.1 200/.test(rotated)) throw new Error(`expected 200 on refresh, got:\n${rotated}`);
909
+ const rotatedCookie = cookie(rotated);
910
+ if (!rotatedCookie || rotatedCookie === loginCookie) throw new Error(`expected a NEW refresh_token cookie after rotation, got:\n${rotated}`);
911
+
912
+ const reuseOld = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/refresh`, "-H", `Cookie: refresh_token=${loginCookie}`]);
913
+ if (status(reuseOld) !== "401") throw new Error(`expected 401 replaying the already-rotated-out refresh token, got:\n${reuseOld}`);
914
+
915
+ // reuse-detection's whole point: replaying the old token above must have
916
+ // revoked the WHOLE session family, including the token that replaced it —
917
+ // not just refused the replay itself.
918
+ const reuseRotated = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/refresh`, "-H", `Cookie: refresh_token=${rotatedCookie}`]);
919
+ if (status(reuseRotated) !== "401") throw new Error(`expected replaying a rotated-out token to revoke the whole session family (rotated token should now 401 too), got:\n${reuseRotated}`);
920
+
921
+ // forgot-password/reset-password go through the real async queue. Track the
922
+ // direct worker binary so top-level cleanup can stop it even if an assertion
923
+ // below fails before this step reaches its normal shutdown.
924
+ const workerLogPath = logPath("auth-worker");
925
+ const authWorker = startWorker(fullApp, "auth-worker", { REDIS_URL: sharedRedisUrl });
926
+ execFileSync("sleep", ["2"]);
927
+
928
+ const forgotExisting = run("curl", ["-s", "-X", "POST", `${B}/auth/forgot-password`, ...jsonHeader, "-d", '{"email":"alice@example.com"}']);
929
+ const forgotMissing = run("curl", ["-s", "-X", "POST", `${B}/auth/forgot-password`, ...jsonHeader, "-d", '{"email":"nobody@example.com"}']);
930
+ if (forgotExisting !== forgotMissing) {
931
+ throw new Error(`expected forgot-password to respond identically for an existing vs unknown email (anti-enumeration), got:\n${forgotExisting}\nvs\n${forgotMissing}`);
932
+ }
933
+
934
+ // Register sends a verification email automatically — same queue, same worker.
935
+ const verifymeRegister = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"verifyme@example.com","password":"correcthorsebattery","name":"Verify Me"}']);
936
+ const verifymeAccess = field(verifymeRegister, "access_token");
937
+ if (!verifymeAccess) throw new Error(`expected an access token registering verifyme@example.com, got:\n${verifymeRegister}`);
938
+
939
+ execFileSync("sleep", ["2"]); // let the worker process the enqueued emails
940
+ stopApi(authWorker);
941
+ const workerLog = readFileSync(workerLogPath, "utf8");
942
+ const resetToken = (workerLog.match(/reset-password\?token=([0-9a-f]+)/) ?? [])[1];
943
+ if (!resetToken) throw new Error(`expected a password reset link in the worker log, got:\n${workerLog}`);
944
+
945
+ const verifyTokenMatch = workerLog.match(/"to":"verifyme@example\.com"[^\n]*verify-email\?token=([0-9a-f]+)/);
946
+ const verifyToken = verifyTokenMatch?.[1];
947
+ if (!verifyToken) throw new Error(`expected a verification link for verifyme@example.com in the worker log, got:\n${workerLog}`);
948
+
949
+ const meBeforeVerify = run("curl", ["-s", `${B}/users/me`, "-H", `Authorization: Bearer ${verifymeAccess}`]);
950
+ if (!meBeforeVerify.includes('"email_verified":false')) throw new Error(`expected a freshly registered user to be unverified, got:\n${meBeforeVerify}`);
951
+
952
+ const badVerify = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/verify-email`, ...jsonHeader, "-d", '{"token":"garbage"}']);
953
+ if (status(badVerify) !== "401") throw new Error(`expected 401 verifying with a garbage token, got:\n${badVerify}`);
954
+
955
+ const goodVerify = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/verify-email`, ...jsonHeader, "-d", `{"token":"${verifyToken}"}`]);
956
+ if (status(goodVerify) !== "204") throw new Error(`expected 204 on a valid email verification, got:\n${goodVerify}`);
957
+
958
+ const verifyReuse = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/verify-email`, ...jsonHeader, "-d", `{"token":"${verifyToken}"}`]);
959
+ if (status(verifyReuse) !== "401") throw new Error(`expected 401 reusing an already-consumed verify token (GETDEL is one-time), got:\n${verifyReuse}`);
960
+
961
+ const meAfterVerify = run("curl", ["-s", `${B}/users/me`, "-H", `Authorization: Bearer ${verifymeAccess}`]);
962
+ if (!meAfterVerify.includes('"email_verified":true')) throw new Error(`expected the user to show verified after a successful verify-email, got:\n${meAfterVerify}`);
963
+
964
+ const resendNoAuth = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/users/me/resend-verification`]);
965
+ if (status(resendNoAuth) !== "401") throw new Error(`expected 401 resending verification with no token, got:\n${resendNoAuth}`);
966
+
967
+ const resendAlreadyVerified = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/users/me/resend-verification`, "-H", `Authorization: Bearer ${verifymeAccess}`]);
968
+ if (status(resendAlreadyVerified) !== "409" || !resendAlreadyVerified.includes("AUTH_ALREADY_VERIFIED")) {
969
+ throw new Error(`expected 409 AUTH_ALREADY_VERIFIED resending for an already-verified user, got:\n${resendAlreadyVerified}`);
970
+ }
971
+
972
+ const resendmeRegister = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"resendme@example.com","password":"correcthorsebattery","name":"Resend Me"}']);
973
+ const resendmeAccess = field(resendmeRegister, "access_token");
974
+ const resendUnverified = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/users/me/resend-verification`, "-H", `Authorization: Bearer ${resendmeAccess}`]);
975
+ if (status(resendUnverified) !== "204") throw new Error(`expected 204 resending verification for a not-yet-verified user, got:\n${resendUnverified}`);
976
+
977
+ const badReset = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/reset-password`, ...jsonHeader, "-d", '{"token":"garbage","new_password":"irrelevant123"}']);
978
+ if (status(badReset) !== "401") throw new Error(`expected 401 resetting with a garbage token, got:\n${badReset}`);
979
+
980
+ const goodReset = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/reset-password`, ...jsonHeader, "-d", `{"token":"${resetToken}","new_password":"brandnewpassword123"}`]);
981
+ if (status(goodReset) !== "204") throw new Error(`expected 204 on a valid reset-password, got:\n${goodReset}`);
982
+
983
+ const resetReuse = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/reset-password`, ...jsonHeader, "-d", `{"token":"${resetToken}","new_password":"anotherpassword123"}`]);
984
+ if (status(resetReuse) !== "401") throw new Error(`expected 401 reusing an already-consumed reset token (GETDEL is one-time), got:\n${resetReuse}`);
985
+
986
+ const loginOldPassword = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"correcthorsebattery"}']);
987
+ if (status(loginOldPassword) !== "401") throw new Error(`expected 401 logging in with the pre-reset password, got:\n${loginOldPassword}`);
988
+
989
+ const loginNewPassword = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"alice@example.com","password":"brandnewpassword123"}']);
990
+ if (status(loginNewPassword) !== "200") throw new Error(`expected 200 logging in with the post-reset password, got:\n${loginNewPassword}`);
991
+
992
+
993
+ // Google OAuth: only what's testable without a live Google app — the login
994
+ // redirect targets Google with a signed state param, and the callback
995
+ // rejects a state that isn't a validly-signed oauth_state JWT.
996
+ const googleLogin = run("curl", ["-s", "-i", `${B}/auth/google/login`]);
997
+ if (!/^HTTP\/1\.1 302/.test(googleLogin)) throw new Error(`expected 302 on GET /auth/google/login, got:\n${googleLogin}`);
998
+ if (!/Location: https:\/\/accounts\.google\.com\/.*state=/.test(googleLogin)) {
999
+ throw new Error(`expected a redirect to accounts.google.com with a state param, got:\n${googleLogin}`);
1000
+ }
1001
+ const googleCallbackBadState = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/auth/google/callback?code=fake&state=garbage`]);
1002
+ if (status(googleCallbackBadState) !== "401") throw new Error(`expected 401 on google callback with an invalid state, got:\n${googleCallbackBadState}`);
1003
+
1004
+ const logout = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/logout`, "-H", `Cookie: refresh_token=${registerCookie}`]);
1005
+ if (status(logout) !== "204") throw new Error(`expected 204 on logout, got:\n${logout}`);
1006
+ const logoutAgain = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/logout`, "-H", `Cookie: refresh_token=${registerCookie}`]);
1007
+ if (status(logoutAgain) !== "204") throw new Error(`expected logout to be idempotent (204 again), got:\n${logoutAgain}`);
1008
+ const refreshAfterLogout = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/refresh`, "-H", `Cookie: refresh_token=${registerCookie}`]);
1009
+ if (status(refreshAfterLogout) !== "401") throw new Error(`expected 401 refreshing after logout, got:\n${refreshAfterLogout}`);
1010
+
1011
+ // Rate limiting: register/login/forgot-password/reset-password are each
1012
+ // throttled per-IP via Redis (middleware.RateLimit). Flush the shared
1013
+ // Redis first so this doesn't depend on how many times the assertions
1014
+ // above already spent the budget (also sidesteps having to guess whether
1015
+ // curl-to-localhost counts as client IP 127.0.0.1 or ::1), and flush again
1016
+ // after so a budget this test deliberately exhausts doesn't bleed into a
1017
+ // later step that shares the same container (e.g. "add rbac"'s own
1018
+ // /auth/register call). Safe to nuke everything here — this is the last
1019
+ // thing this step does before the server gets killed.
1020
+ const resetRateLimits = () => run("docker", ["exec", sharedRedisContainerId, "redis-cli", "FLUSHDB"]);
1021
+ resetRateLimits();
1022
+
1023
+ // register is capped at 5/min — fire 6 back-to-back and expect only the 6th to 429.
1024
+ for (let i = 1; i <= 6; i++) {
1025
+ const out = run("curl", [
1026
+ "-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/register`, ...jsonHeader,
1027
+ "-d", `{"email":"burst${i}@example.com","password":"correcthorsebattery","name":"Burst"}`,
1028
+ ]);
1029
+ if (i <= 5) {
1030
+ if (status(out) === "429") throw new Error(`expected request ${i}/6 to register to stay under the 5/min limit, got 429:\n${out}`);
1031
+ } else if (status(out) !== "429" || !out.includes("RATE_LIMITED")) {
1032
+ throw new Error(`expected the 6th rapid register within a minute to be rate limited (429 RATE_LIMITED), got:\n${out}`);
1033
+ }
1034
+ }
1035
+
1036
+ // a DIFFERENT route's budget must be untouched — proves limits are
1037
+ // per-route, not one shared global counter.
1038
+ const loginStillWorks = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"burst1@example.com","password":"correcthorsebattery"}']);
1039
+ if (status(loginStillWorks) !== "200") throw new Error(`expected /auth/login to be unaffected by /auth/register's exhausted rate limit, got:\n${loginStillWorks}`);
1040
+
1041
+ resetRateLimits();
1042
+
1043
+ stopApi(authApi);
1044
+
1045
+ // cmd/seed talks to Postgres directly (config.Load() + database.Open, same
1046
+ // as cmd/api) — no server, no Redis, run it as a plain one-shot process.
1047
+ const seedOut = run("go", ["run", "./cmd/seed"], fullApp, {
1048
+ SEED_ADMIN_EMAIL: "seed-admin@example.com",
1049
+ SEED_ADMIN_PASSWORD: "seedpassword123",
1050
+ SEED_ADMIN_NAME: "Seed Admin",
1051
+ });
1052
+ if (!seedOut.includes('"msg":"admin user ready"') || !seedOut.includes("seed-admin@example.com")) {
1053
+ throw new Error(`expected cmd/seed to report the admin user ready, got:\n${seedOut}`);
1054
+ }
1055
+ const seedID = field(seedOut, "id");
1056
+ if (!seedID) throw new Error(`expected cmd/seed's log line to include the user id, got:\n${seedOut}`);
1057
+
1058
+ // idempotent: re-running with a DIFFERENT password must return the SAME
1059
+ // user id (found, not recreated) rather than a fresh one.
1060
+ const seedAgainOut = run("go", ["run", "./cmd/seed"], fullApp, {
1061
+ SEED_ADMIN_EMAIL: "seed-admin@example.com",
1062
+ SEED_ADMIN_PASSWORD: "a-completely-different-password",
1063
+ SEED_ADMIN_NAME: "Seed Admin",
1064
+ });
1065
+ if (field(seedAgainOut, "id") !== seedID) {
1066
+ throw new Error(`expected re-running cmd/seed to return the same user id (idempotent), got:\n${seedAgainOut}\nfirst run:\n${seedOut}`);
1067
+ }
1068
+
1069
+ let missingPasswordFailed = false;
1070
+ try {
1071
+ run("go", ["run", "./cmd/seed"], fullApp, { SEED_ADMIN_EMAIL: "no-password@example.com" });
1072
+ } catch {
1073
+ missingPasswordFailed = true;
1074
+ }
1075
+ if (!missingPasswordFailed) throw new Error("expected cmd/seed to exit nonzero when SEED_ADMIN_PASSWORD is missing but SEED_ADMIN_EMAIL is set");
1076
+
1077
+ const fixturesOut = run("go", ["run", "./cmd/seed", "--fixtures"], fullApp);
1078
+ if (!fixturesOut.includes("dev.one@example.com") || !fixturesOut.includes("dev.two@example.com")) {
1079
+ throw new Error(`expected --fixtures to seed both dev sample users, got:\n${fixturesOut}`);
1080
+ }
1081
+
1082
+ runMake(["db-drop"], fullApp);
1083
+ });
1084
+
1085
+ step(
1086
+ hasDocker
1087
+ ? "add auth: unit tests cover refresh rotation + reuse-detection (go test ./internal/app/user/...)"
1088
+ : "add auth: unit tests skipped (needs Docker for add worker's Redis)",
1089
+ () => {
1090
+ if (!hasDocker) return;
1091
+ run("go", ["test", "./internal/app/user/..."], fullApp);
1092
+ }
1093
+ );
1094
+
1095
+ step(hasDocker ? "add auth: wires /auth/* and /users/me* into docs/openapi.yaml, bundle resolves" : "add auth: openapi wiring skipped (needs Docker)", () => {
1096
+ if (!hasDocker) return;
1097
+ const openapi = readFileSync(path.join(fullApp, "docs", "openapi.yaml"), "utf8");
1098
+ for (const p of [
1099
+ "/v1/auth/register:",
1100
+ "/v1/auth/login:",
1101
+ "/v1/auth/refresh:",
1102
+ "/v1/auth/logout:",
1103
+ "/v1/auth/forgot-password:",
1104
+ "/v1/auth/reset-password:",
1105
+ "/v1/auth/verify-email:",
1106
+ "/v1/auth/google/login:",
1107
+ "/v1/auth/google/callback:",
1108
+ "/v1/users/me:",
1109
+ "/v1/users/me/resend-verification:",
1110
+ "/v1/users/me/logout-all:",
1111
+ ]) {
1112
+ if (!openapi.includes(p)) throw new Error(`expected ${p} in docs/openapi.yaml after add auth, got:\n${openapi}`);
1113
+ }
1114
+ if (hasNpx) run("npx", ["--yes", "@redocly/cli", "bundle", "docs/openapi.yaml", "-o", "docs/openapi.bundled.yaml"], fullApp);
1115
+ });
1116
+
1117
+ step("bare project: CI workflow renders with the right db name, valid trigger keys", () => {
1118
+ assertFileContains(path.join(fullApp, ".github", "workflows", "ci.yml"), "POSTGRES_DB: full_app_test");
1119
+ assertFileContains(path.join(fullApp, ".github", "workflows", "ci.yml"), "golangci-lint-action");
1120
+ });
1121
+
1122
+ let hasGolangciLint = true;
1123
+ try {
1124
+ run("golangci-lint", ["--version"]);
1125
+ // golangci-lint's result cache is keyed by file content, not absolute path —
1126
+ // this suite regenerates byte-identical "widget"/"order" packages across many
1127
+ // scratch dirs, so a stale cache entry (e.g. from before a template fix) can
1128
+ // get served with a file path from a since-deleted run. Start from empty.
1129
+ run("golangci-lint", ["cache", "clean"]);
1130
+ } catch {
1131
+ hasGolangciLint = false;
1132
+ }
1133
+
1134
+ step(
1135
+ hasGolangciLint
1136
+ ? "bare project: golangci-lint is clean out of the box (the CI gate the scaffold ships would pass)"
1137
+ : "bare project: golangci-lint not installed locally — skipping a real run",
1138
+ () => {
1139
+ if (!hasGolangciLint) return;
1140
+ const out = run("golangci-lint", ["run"], fullApp);
1141
+ if (out.trim() && !out.includes("0 issues")) throw new Error(`expected 0 issues, got:\n${out}`);
1142
+ }
1143
+ );
1144
+
1145
+ hasPsql = Boolean(sharedPostgresContainerId);
1146
+ if (hasPsql) {
1147
+ try {
1148
+ run("psql", ["--version"]);
1149
+ } catch {
1150
+ hasPsql = false;
1151
+ }
1152
+ }
1153
+
1154
+ const dockerPgContainer = sharedPostgresContainerId;
1155
+
1156
+ function listDatabases() {
1157
+ return hasPsql
1158
+ ? run("psql", ["-h", fullDb.dbHost, "-p", String(fullDb.dbPort), "-U", "postgres", "-lqt"], undefined, { PGPASSWORD: "postgres" })
1159
+ : run("docker", ["exec", "-e", "PGPASSWORD=postgres", dockerPgContainer, "psql", "-U", "postgres", "-lqt"]);
1160
+ }
1161
+
1162
+ function psqlExec(db, sql) {
1163
+ return hasPsql
1164
+ ? run("psql", ["-h", fullDb.dbHost, "-p", String(fullDb.dbPort), "-U", "postgres", "-d", db, "-tAc", sql], undefined, { PGPASSWORD: "postgres" })
1165
+ : run("docker", [
1166
+ "exec",
1167
+ "-e",
1168
+ "PGPASSWORD=postgres",
1169
+ dockerPgContainer,
1170
+ "psql",
1171
+ "-U",
1172
+ "postgres",
1173
+ "-d",
1174
+ db,
1175
+ "-tAc",
1176
+ sql,
1177
+ ]);
1178
+ }
1179
+
1180
+ step(
1181
+ hasPsql
1182
+ ? "make db-create is idempotent and actually creates the DB"
1183
+ : dockerPgContainer
1184
+ ? "make db-create falls back to docker exec (no local psql) and actually creates the DB"
1185
+ : "make db-create parses (no psql, no Postgres container — skipping a real run)",
1186
+ () => {
1187
+ if (!hasPsql && !dockerPgContainer) {
1188
+ run("make", ["-n", "db-create"], fullApp); // dry run: catches Makefile/shell syntax errors
1189
+ return;
1190
+ }
1191
+ runMake(["db-drop"], fullApp); // start from a clean slate in case a prior run left it
1192
+ runMake(["db-create"], fullApp);
1193
+ runMake(["db-create"], fullApp); // must not error the second time
1194
+ const list = listDatabases();
1195
+ if (!list.includes(fullDb.dbName)) throw new Error(`expected database "${fullDb.dbName}" to exist, got:\n${list}`);
1196
+ runMake(["db-drop"], fullApp);
1197
+ }
1198
+ );
1199
+
1200
+ // middleware.CORS's whole point: "*" can't be combined with Allow-Credentials
1201
+ // per the fetch spec, so an allowed origin is echoed back explicitly and a
1202
+ // disallowed one gets nothing — proven against the real compiled server, not
1203
+ // just the middleware in isolation.
1204
+ step(
1205
+ hasPsql || dockerPgContainer
1206
+ ? "CORS: an allowed origin gets the full header set, a disallowed one gets none"
1207
+ : "CORS: skipped (needs psql/a Postgres container)",
1208
+ () => {
1209
+ if (!hasPsql && !dockerPgContainer) return;
1210
+ runMake(["db-drop"], fullApp); // clean slate
1211
+ runMake(["db-create"], fullApp);
1212
+
1213
+ const corsApi = startApi(fullApp, "cors-api", fullDb, { REDIS_URL: sharedRedisUrl });
1214
+ execFileSync("sleep", ["3"]);
1215
+
1216
+ const allowed = run("curl", [
1217
+ "-s",
1218
+ "-i",
1219
+ "-X",
1220
+ "OPTIONS",
1221
+ `${smoke.baseURL}/livez`,
1222
+ "-H",
1223
+ "Origin: http://localhost:3000",
1224
+ "-H",
1225
+ "Access-Control-Request-Method: GET",
1226
+ ]);
1227
+ const disallowed = run("curl", [
1228
+ "-s",
1229
+ "-i",
1230
+ "-X",
1231
+ "OPTIONS",
1232
+ `${smoke.baseURL}/livez`,
1233
+ "-H",
1234
+ "Origin: http://evil.example",
1235
+ "-H",
1236
+ "Access-Control-Request-Method: GET",
1237
+ ]);
1238
+
1239
+ if (!/HTTP\/1\.1 204/.test(allowed)) throw new Error(`expected 204 on the allowed-origin preflight, got:\n${allowed}`);
1240
+ if (!allowed.includes("Access-Control-Allow-Origin: http://localhost:3000")) {
1241
+ throw new Error(`expected Allow-Origin echoed back for an allowed origin, got:\n${allowed}`);
1242
+ }
1243
+ if (!allowed.includes("Access-Control-Allow-Credentials: true")) {
1244
+ throw new Error(`expected Allow-Credentials for an allowed origin, got:\n${allowed}`);
1245
+ }
1246
+
1247
+ if (!/HTTP\/1\.1 204/.test(disallowed)) throw new Error(`expected 204 on the disallowed-origin preflight too, got:\n${disallowed}`);
1248
+ if (disallowed.includes("Access-Control-Allow-Origin")) {
1249
+ throw new Error(`expected no Allow-Origin at all for a disallowed origin, got:\n${disallowed}`);
1250
+ }
1251
+
1252
+ stopApi(corsApi);
1253
+ runMake(["db-drop"], fullApp);
1254
+ }
1255
+ );
1256
+
1257
+ step("generate migration reserves a timestamped up/down pair, TODO-stubbed", () => {
1258
+ goScaffold(["generate", "migration", "add_status_to_orders"], fullApp);
1259
+ const files = readdirSync(path.join(fullApp, "migrations")).filter((f) => f.includes("add_status_to_orders"));
1260
+ if (files.length !== 2) throw new Error(`expected exactly 2 files (up+down), got: ${files.join(", ")}`);
1261
+ if (!files.every((f) => /^\d{14}_add_status_to_orders\.(up|down)\.sql$/.test(f))) {
1262
+ throw new Error(`expected a 14-digit timestamp prefix, got: ${files.join(", ")}`);
1263
+ }
1264
+ const upContent = readFileSync(
1265
+ path.join(fullApp, "migrations", files.find((f) => f.endsWith(".up.sql"))),
1266
+ "utf8"
1267
+ );
1268
+ if (!upContent.includes("TODO")) throw new Error(`expected a TODO stub in the up migration, got:\n${upContent}`);
1269
+ });
1270
+
1271
+ let hasMigrate = true;
1272
+ try {
1273
+ run("migrate", ["-version"]);
1274
+ } catch {
1275
+ hasMigrate = false;
1276
+ }
1277
+
1278
+ // Two things at once: (1) a project with old-style sequential migrations
1279
+ // (0000NN_*, from before this convention) still applies cleanly alongside a
1280
+ // newly generated timestamped one, in the right order — proven against the
1281
+ // real migrate CLI, not just this repo's own string-sorting assumptions; (2)
1282
+ // `make migrate-verify` (up -> down -all -> up) round-trips without error.
1283
+ step(
1284
+ (hasPsql || dockerPgContainer) && hasMigrate
1285
+ ? "a legacy sequential migration and a new timestamped one apply in order; migrate-verify round-trips"
1286
+ : "migration ordering / migrate-verify: skipped (needs psql/a Postgres container, and the migrate CLI)",
1287
+ () => {
1288
+ if (!((hasPsql || dockerPgContainer) && hasMigrate)) return;
1289
+
1290
+ writeFileSync(path.join(fullApp, "migrations", "000001_create_legacy.up.sql"), "CREATE TABLE legacy (id uuid PRIMARY KEY);\n");
1291
+ writeFileSync(path.join(fullApp, "migrations", "000001_create_legacy.down.sql"), "DROP TABLE legacy;\n");
1292
+
1293
+ runMake(["db-drop"], fullApp);
1294
+ runMake(["db-create"], fullApp);
1295
+ const dsn = fullDb.dbDsn;
1296
+
1297
+ // migrate logs each applied step to stderr, not stdout — merge via bash so
1298
+ // `run`'s stdout-only capture actually sees it.
1299
+ const upOut = run("bash", ["-c", `migrate -path migrations -database '${dsn}' up 2>&1`], fullApp);
1300
+ if (!/^1\/u create_legacy/m.test(upOut) || !upOut.includes("add_status_to_orders")) {
1301
+ throw new Error(`expected both the legacy migration and the new one to apply, in order, got:\n${upOut}`);
1302
+ }
1303
+
1304
+ const version = psqlExec(fullDb.dbName, "SELECT version FROM schema_migrations;").trim();
1305
+ if (!/^\d{14}$/.test(version)) {
1306
+ throw new Error(`expected schema_migrations to land on the 14-digit timestamped migration, got: "${version}"`);
1307
+ }
1308
+
1309
+ run("bash", ["-c", `DB_DSN="${dsn}" make migrate-verify`], fullApp);
1310
+
1311
+ // migrate-verify's whole point: catch a down.sql that's stopped reversing
1312
+ // cleanly. A check that only exercises the happy path above would still
1313
+ // pass even if the down step were silently skipped — corrupt one and
1314
+ // confirm the target actually fails instead of succeeding anyway. Ends
1315
+ // "up" after the run above, so this second call's down-all step is the
1316
+ // first thing to touch the corrupted file.
1317
+ writeFileSync(path.join(fullApp, "migrations", "000001_create_legacy.down.sql"), "DROP TABLE this_table_does_not_exist;\n");
1318
+ let verifyCaughtTheBreak = false;
1319
+ try {
1320
+ run("bash", ["-c", `DB_DSN="${dsn}" make migrate-verify`], fullApp);
1321
+ } catch {
1322
+ verifyCaughtTheBreak = true;
1323
+ }
1324
+ if (!verifyCaughtTheBreak) throw new Error("expected migrate-verify to fail against a broken down.sql, it succeeded");
1325
+
1326
+ runMake(["db-drop"], fullApp);
1327
+ }
1328
+ );
1329
+
1330
+ // `add rbac` requires real seed data (roles/permissions rows), which only
1331
+ // the SQL migration provides — AUTO_MIGRATE=true creates the tables via
1332
+ // AutoMigrate but never runs the migration's INSERT statements, so this
1333
+ // step applies the real migration via the `migrate` CLI rather than
1334
+ // AUTO_MIGRATE=true like earlier steps.
1335
+ step(
1336
+ hasDocker && (hasPsql || dockerPgContainer) && hasMigrate
1337
+ ? "add rbac: default role, list/view/set-role admin routes, last-role-manager lockout guard, configurable authz cache TTL, logout-all, cmd/seed promotes to admin"
1338
+ : "add rbac: skipped (needs Docker, psql/a Postgres container, and the migrate CLI)",
1339
+ () => {
1340
+ if (!(hasDocker && (hasPsql || dockerPgContainer) && hasMigrate)) return;
1341
+
1342
+ const rbacOut = goScaffold(["add", "rbac"], fullApp);
1343
+ // AUTO_MIGRATE=true creates the tables but never runs the migration's
1344
+ // seed INSERTs — a dev following the normal AUTO_MIGRATE=true dev flow
1345
+ // would otherwise hit "unknown role code" from `make seed` with no clue
1346
+ // why, so `add rbac` must say so loudly, not just in a doc.
1347
+ if (!rbacOut.includes("AUTO_MIGRATE=true") || !rbacOut.includes("does NOT seed")) {
1348
+ throw new Error(`expected \`add rbac\` to warn that AUTO_MIGRATE=true doesn't seed role/permission data, got:\n${rbacOut}`);
1349
+ }
1350
+ run("go", ["mod", "tidy"], fullApp);
1351
+ run("go", ["build", "./..."], fullApp);
1352
+ run("go", ["vet", "./..."], fullApp);
1353
+
1354
+ runMake(["db-drop"], fullApp);
1355
+ runMake(["db-create"], fullApp);
1356
+ const dsn = fullDb.dbDsn;
1357
+ run("migrate", ["-path", "migrations", "-database", dsn, "up"], fullApp);
1358
+
1359
+ // also proves SetRole works standalone (not just reachable via HTTP)
1360
+ run("go", ["run", "./cmd/seed"], fullApp, {
1361
+ SEED_ADMIN_EMAIL: "rbac-admin@example.com",
1362
+ SEED_ADMIN_PASSWORD: "adminpassword123",
1363
+ SEED_ADMIN_NAME: "RBAC Admin",
1364
+ });
1365
+
1366
+ // AUTHZ_CACHE_TTL_MIN=0 (vs. the .env.example default of 1) proves the
1367
+ // value is actually threaded through config -> main.go -> NewAuthz, not
1368
+ // just accepted and ignored — a permission grant takes effect on the
1369
+ // very next request instead of needing to wait out any cache window.
1370
+ const rbacApi = startApi(fullApp, "rbac-api", fullDb, {
1371
+ REDIS_URL: sharedRedisUrl,
1372
+ AUTO_MIGRATE: "false",
1373
+ AUTHZ_CACHE_TTL_MIN: "0",
1374
+ });
1375
+ execFileSync("sleep", ["3"]);
1376
+
1377
+ const B = `${smoke.baseURL}/v1`;
1378
+ const jsonHeader = ["-H", "Content-Type: application/json"];
1379
+ const status = (out) => (out.match(/HTTPSTATUS:(\d+)/) ?? [])[1];
1380
+ const field = (out, key) => (out.match(new RegExp(`"${key}":"([^"]*)"`)) ?? [])[1];
1381
+
1382
+ const staffRegister = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"rbac-staff@example.com","password":"correcthorsebattery","name":"Staff"}']);
1383
+ const staffAccess = field(staffRegister, "access_token");
1384
+ if (!staffAccess) throw new Error(`expected an access token on register, got:\n${staffRegister}`);
1385
+
1386
+ const staffMe = run("curl", ["-s", `${B}/users/me`, "-H", `Authorization: Bearer ${staffAccess}`]);
1387
+ if (!staffMe.includes('"role":"staff"')) throw new Error(`expected a freshly registered user's default role to be "staff", got:\n${staffMe}`);
1388
+ const staffID = field(staffMe, "id");
1389
+
1390
+ const noAuthRoles = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/roles`]);
1391
+ if (status(noAuthRoles) !== "401") throw new Error(`expected 401 (not 403) listing /roles unauthenticated, got:\n${noAuthRoles}`);
1392
+
1393
+ const staffForbidden = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/roles`, "-H", `Authorization: Bearer ${staffAccess}`]);
1394
+ if (status(staffForbidden) !== "403") throw new Error(`expected 403 listing /roles as staff (no role:manage granted), got:\n${staffForbidden}`);
1395
+
1396
+ const adminLogin = run("curl", ["-s", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"rbac-admin@example.com","password":"adminpassword123"}']);
1397
+ const adminAccess = field(adminLogin, "access_token");
1398
+ if (!adminAccess) throw new Error(`expected the seeded admin to log in, got:\n${adminLogin}`);
1399
+
1400
+ const adminRoles = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/roles`, "-H", `Authorization: Bearer ${adminAccess}`]);
1401
+ if (status(adminRoles) !== "200" || !adminRoles.includes('"code":"admin"') || !adminRoles.includes('"code":"staff"')) {
1402
+ throw new Error(`expected the admin to list both seeded roles, got:\n${adminRoles}`);
1403
+ }
1404
+
1405
+ // the trickiest business rule here: revoking role:manage from the only
1406
+ // role that grants it must be blocked, or an admin could lock everyone
1407
+ // (including themselves) out of role management with no way back in.
1408
+ const lastManager = run("curl", [
1409
+ "-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "PATCH", `${B}/roles/admin/permissions`,
1410
+ "-H", `Authorization: Bearer ${adminAccess}`, ...jsonHeader, "-d", '{"permission_codes":["user:manage-role"]}',
1411
+ ]);
1412
+ if (status(lastManager) !== "409" || !lastManager.includes("ROLE_LAST_MANAGER")) {
1413
+ throw new Error(`expected 409 ROLE_LAST_MANAGER revoking role:manage from the only manager, got:\n${lastManager}`);
1414
+ }
1415
+
1416
+ const promote = run("curl", [
1417
+ "-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "PATCH", `${B}/users/${staffID}/set-role`,
1418
+ "-H", `Authorization: Bearer ${adminAccess}`, ...jsonHeader, "-d", '{"role":"admin"}',
1419
+ ]);
1420
+ if (status(promote) !== "200" || !promote.includes('"role":"admin"')) {
1421
+ throw new Error(`expected 200 with role "admin" after set-role, got:\n${promote}`);
1422
+ }
1423
+
1424
+ const unknownRole = run("curl", [
1425
+ "-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "PATCH", `${B}/users/${staffID}/set-role`,
1426
+ "-H", `Authorization: Bearer ${adminAccess}`, ...jsonHeader, "-d", '{"role":"superuser"}',
1427
+ ]);
1428
+ if (status(unknownRole) !== "422" || !unknownRole.includes("USER_UNKNOWN_ROLE")) {
1429
+ throw new Error(`expected 422 USER_UNKNOWN_ROLE for an unknown role code, got:\n${unknownRole}`);
1430
+ }
1431
+
1432
+ // the promoted user's role only changes in a FRESH token — the JWT is
1433
+ // stateless, so a re-login (or refresh) is what actually applies it.
1434
+ const promotedLogin = run("curl", ["-s", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"rbac-staff@example.com","password":"correcthorsebattery"}']);
1435
+ const promotedAccess = field(promotedLogin, "access_token");
1436
+ const promotedRoles = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/roles`, "-H", `Authorization: Bearer ${promotedAccess}`]);
1437
+ if (status(promotedRoles) !== "200") throw new Error(`expected the promoted user's new token to grant /roles access, got:\n${promotedRoles}`);
1438
+
1439
+ // GET /users, GET /users/:id — admin can't manage anyone through the API
1440
+ // without a way to find their id first; staffID above got promoted to
1441
+ // admin already, so register a fresh unprivileged user for the 403 checks.
1442
+ const staff2Register = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"rbac-staff2@example.com","password":"correcthorsebattery","name":"Staff Two"}']);
1443
+ const staff2Access = field(staff2Register, "access_token");
1444
+ if (!staff2Access) throw new Error(`expected an access token registering a second staff user, got:\n${staff2Register}`);
1445
+
1446
+ const noAuthUsers = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users`]);
1447
+ if (status(noAuthUsers) !== "401") throw new Error(`expected 401 (not 403) listing /users unauthenticated, got:\n${noAuthUsers}`);
1448
+
1449
+ const staff2Forbidden = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users`, "-H", `Authorization: Bearer ${staff2Access}`]);
1450
+ if (status(staff2Forbidden) !== "403") throw new Error(`expected 403 listing /users as staff (no user:read granted), got:\n${staff2Forbidden}`);
1451
+
1452
+ const adminUsers = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users`, "-H", `Authorization: Bearer ${adminAccess}`]);
1453
+ if (status(adminUsers) !== "200" || !adminUsers.includes("rbac-staff2@example.com")) {
1454
+ throw new Error(`expected the admin to list users including the freshly registered one (user:read auto-granted from the same seed migration), got:\n${adminUsers}`);
1455
+ }
1456
+
1457
+ const adminGetByID = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/${staffID}`, "-H", `Authorization: Bearer ${adminAccess}`]);
1458
+ if (status(adminGetByID) !== "200") throw new Error(`expected 200 viewing a specific user by id as admin, got:\n${adminGetByID}`);
1459
+
1460
+ const adminGetMissing = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/00000000-0000-0000-0000-000000000000`, "-H", `Authorization: Bearer ${adminAccess}`]);
1461
+ if (status(adminGetMissing) !== "404") throw new Error(`expected 404 for a well-formed but nonexistent user id, got:\n${adminGetMissing}`);
1462
+
1463
+ const adminGetInvalid = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/not-a-uuid`, "-H", `Authorization: Bearer ${adminAccess}`]);
1464
+ if (status(adminGetInvalid) !== "400") throw new Error(`expected 400 for a malformed user id, got:\n${adminGetInvalid}`);
1465
+
1466
+ const staff2GetByID = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/${staffID}`, "-H", `Authorization: Bearer ${staff2Access}`]);
1467
+ if (status(staff2GetByID) !== "403") throw new Error(`expected 403 viewing another user by id as staff (no user:read granted), got:\n${staff2GetByID}`);
1468
+
1469
+ // /me must be completely unaffected by adding /users and /users/:id next to it.
1470
+ const staff2Me = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users/me`, "-H", `Authorization: Bearer ${staff2Access}`]);
1471
+ if (status(staff2Me) !== "200") throw new Error(`expected /users/me to still work unaffected by the new /users routes, got:\n${staff2Me}`);
1472
+
1473
+ // authz cache TTL: grant "staff" the permission it was just denied above
1474
+ // and confirm it takes effect on the very next request — this server was
1475
+ // booted with AUTHZ_CACHE_TTL_MIN=0, so there's no window to wait out.
1476
+ const grantStaffRead = run("curl", [
1477
+ "-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "PATCH", `${B}/roles/staff/permissions`,
1478
+ "-H", `Authorization: Bearer ${adminAccess}`, ...jsonHeader, "-d", '{"permission_codes":["user:read"]}',
1479
+ ]);
1480
+ if (status(grantStaffRead) !== "200") throw new Error(`expected 200 granting user:read to the staff role, got:\n${grantStaffRead}`);
1481
+ const staff2AfterGrant = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", `${B}/users`, "-H", `Authorization: Bearer ${staff2Access}`]);
1482
+ if (status(staff2AfterGrant) !== "200") {
1483
+ throw new Error(`expected the staff role's new permission to apply immediately with AUTHZ_CACHE_TTL_MIN=0, got:\n${staff2AfterGrant}`);
1484
+ }
1485
+
1486
+ // logout-all: revoking through ONE session's token must kill every
1487
+ // session for that user, not just the one making the request. Session 1
1488
+ // (register) supplies the access token that calls logout-all; session 2
1489
+ // (a separate login) is the "other device" whose refresh cookie should
1490
+ // die too, even though logout-all never sees its cookie at all.
1491
+ const laRegister = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"rbac-logoutall@example.com","password":"correcthorsebattery","name":"Logout All"}']);
1492
+ const laAccess1 = field(laRegister, "access_token");
1493
+ const laLoginFull = run("curl", ["-s", "-i", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"rbac-logoutall@example.com","password":"correcthorsebattery"}']);
1494
+ const laCookie2 = (laLoginFull.match(/Set-Cookie: refresh_token=([^;]*);/) ?? [])[1];
1495
+ if (!laAccess1 || !laCookie2) throw new Error(`expected an access token and a second session's refresh cookie for the logout-all test, got register:\n${laRegister}\nand login:\n${laLoginFull}`);
1496
+
1497
+ const noAuthLogoutAll = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/users/me/logout-all`]);
1498
+ if (status(noAuthLogoutAll) !== "401") throw new Error(`expected 401 calling logout-all with no token, got:\n${noAuthLogoutAll}`);
1499
+
1500
+ const logoutAll = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/users/me/logout-all`, "-H", `Authorization: Bearer ${laAccess1}`]);
1501
+ if (status(logoutAll) !== "204") throw new Error(`expected 204 from logout-all, got:\n${logoutAll}`);
1502
+
1503
+ const refreshOtherSessionAfterLogoutAll = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/auth/refresh`, "-H", `Cookie: refresh_token=${laCookie2}`]);
1504
+ if (status(refreshOtherSessionAfterLogoutAll) !== "401") {
1505
+ throw new Error(`expected logout-all (called from session 1, no cookie needed) to also kill session 2's refresh token, got:\n${refreshOtherSessionAfterLogoutAll}`);
1506
+ }
1507
+
1508
+ stopApi(rbacApi);
1509
+ runMake(["db-drop"], fullApp);
1510
+ }
1511
+ );
1512
+
1513
+ step(
1514
+ hasDocker && (hasPsql || dockerPgContainer) && hasMigrate
1515
+ ? "add rbac: wires /roles, /permissions, /users(/{id}) into docs/openapi.yaml, patches MeResponse.role, bundle resolves"
1516
+ : "add rbac: openapi wiring skipped (needs Docker, psql/a Postgres container, and the migrate CLI)",
1517
+ () => {
1518
+ if (!(hasDocker && (hasPsql || dockerPgContainer) && hasMigrate)) return;
1519
+ const openapi = readFileSync(path.join(fullApp, "docs", "openapi.yaml"), "utf8");
1520
+ for (const p of ["/v1/roles:", "/v1/roles/{code}/permissions:", "/v1/roles/{code}:", "/v1/permissions:", "/v1/users:", "/v1/users/{id}:", "/v1/users/{id}/set-role:"]) {
1521
+ if (!openapi.includes(p)) throw new Error(`expected ${p} in docs/openapi.yaml after add rbac, got:\n${openapi}`);
1522
+ }
1523
+ assertFileContains(path.join(fullApp, "docs", "auth", "schemas.yaml"), "role: { type: string }");
1524
+ if (hasNpx) run("npx", ["--yes", "@redocly/cli", "bundle", "docs/openapi.yaml", "-o", "docs/openapi.bundled.yaml"], fullApp);
1525
+ }
1526
+ );
1527
+
1528
+ step(
1529
+ hasDocker && (hasPsql || dockerPgContainer) && hasMigrate
1530
+ ? "add rbac: unit tests cover the last-role-manager lockout guard and the Authz permission cache (hit/TTL-expiry, go test)"
1531
+ : "add rbac: unit tests skipped (needs Docker, psql/a Postgres container, and the migrate CLI)",
1532
+ () => {
1533
+ if (!(hasDocker && (hasPsql || dockerPgContainer) && hasMigrate)) return;
1534
+ run("go", ["test", "./internal/app/role/...", "./internal/shared/middleware/..."], fullApp);
1535
+ }
1536
+ );
1537
+
1538
+ // generate module --auth [--permission <code>]: without this, every module
1539
+ // this suite generates from here on would be reachable with no token at all
1540
+ // even though fullApp already has auth+rbac installed — the exact gap this
1541
+ // flag exists to close.
1542
+ step(
1543
+ hasDocker && (hasPsql || dockerPgContainer) && hasMigrate
1544
+ ? "generate module --auth [--permission]: wires RequireAuth/authz.Require, validates flag combos, seeds the permission"
1545
+ : "generate module --auth: skipped (needs Docker, psql/a Postgres container, and the migrate CLI)",
1546
+ () => {
1547
+ if (!(hasDocker && (hasPsql || dockerPgContainer) && hasMigrate)) return;
1548
+
1549
+ // --permission without --auth must be rejected before anything is written.
1550
+ expectThrows(() => goScaffold(["generate", "module", "shouldfail", "--permission", "shouldfail:manage"], fullApp), "--permission requires --auth");
1551
+ if (existsSync(path.join(fullApp, "internal", "app", "shouldfail"))) {
1552
+ throw new Error("expected the rejected --permission-without-auth call to write nothing");
1553
+ }
1554
+
1555
+ // invalid permission code shape.
1556
+ expectThrows(() => goScaffold(["generate", "module", "shouldfail2", "--auth", "--permission", "Not Valid"], fullApp), "invalid permission code");
1557
+
1558
+ goScaffold(["generate", "module", "cart", "--full", "--auth"], fullApp);
1559
+ goScaffold(["generate", "module", "secret", "--full", "--auth", "--permission", "secret:manage"], fullApp);
1560
+ const noteOut = goScaffold(["generate", "module", "note", "--full"], fullApp);
1561
+ if (!noteOut.includes("PUBLIC")) throw new Error(`expected a PUBLIC-route reminder since fullApp already has auth installed, got:\n${noteOut}`);
1562
+
1563
+ assertFileContains(path.join(fullApp, "internal", "app", "cart", "handler.go"), "middleware.RequireAuth(h.jwtSecret)");
1564
+ assertFileContains(path.join(fullApp, "internal", "app", "secret", "handler.go"), 'h.authz.Require("secret:manage")');
1565
+
1566
+ const permMigration = readdirSync(path.join(fullApp, "migrations")).find((f) => f.endsWith("_add_secrets_permission.up.sql"));
1567
+ if (!permMigration) throw new Error("expected a *_add_secrets_permission.up.sql migration to be generated");
1568
+ assertFileContains(path.join(fullApp, "migrations", permMigration), "secret:manage");
1569
+
1570
+ run("go", ["build", "./..."], fullApp);
1571
+ run("go", ["vet", "./..."], fullApp);
1572
+
1573
+ runMake(["db-drop"], fullApp);
1574
+ runMake(["db-create"], fullApp);
1575
+ const dsn = fullDb.dbDsn;
1576
+ run("migrate", ["-path", "migrations", "-database", dsn, "up"], fullApp);
1577
+
1578
+ run("go", ["run", "./cmd/seed"], fullApp, {
1579
+ SEED_ADMIN_EMAIL: "genmod-admin@example.com",
1580
+ SEED_ADMIN_PASSWORD: "adminpassword123",
1581
+ SEED_ADMIN_NAME: "Genmod Admin",
1582
+ });
1583
+
1584
+ const genmodApi = startApi(fullApp, "genmod-api", fullDb, {
1585
+ REDIS_URL: sharedRedisUrl,
1586
+ AUTO_MIGRATE: "false",
1587
+ });
1588
+ execFileSync("sleep", ["3"]);
1589
+
1590
+ const B = `${smoke.baseURL}/v1`;
1591
+ const jsonHeader = ["-H", "Content-Type: application/json"];
1592
+ const status = (out) => (out.match(/HTTPSTATUS:(\d+)/) ?? [])[1];
1593
+ const field = (out, key) => (out.match(new RegExp(`"${key}":"([^"]*)"`)) ?? [])[1];
1594
+
1595
+ const noAuthCart = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/carts`, ...jsonHeader, "-d", "{}"]);
1596
+ if (status(noAuthCart) !== "401") throw new Error(`expected 401 posting to an --auth-only module with no token, got:\n${noAuthCart}`);
1597
+
1598
+ const staffRegister = run("curl", ["-s", "-X", "POST", `${B}/auth/register`, ...jsonHeader, "-d", '{"email":"genmod-staff@example.com","password":"correcthorsebattery","name":"Staff"}']);
1599
+ const staffAccess = field(staffRegister, "access_token");
1600
+ if (!staffAccess) throw new Error(`expected an access token registering the staff user, got:\n${staffRegister}`);
1601
+
1602
+ const staffCart = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/carts`, ...jsonHeader, "-H", `Authorization: Bearer ${staffAccess}`, "-d", "{}"]);
1603
+ if (status(staffCart) !== "201") throw new Error(`expected 201 posting to an --auth-only module with a valid token (no specific permission needed), got:\n${staffCart}`);
1604
+
1605
+ const staffSecret = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/secrets`, ...jsonHeader, "-H", `Authorization: Bearer ${staffAccess}`, "-d", "{}"]);
1606
+ if (status(staffSecret) !== "403") throw new Error(`expected 403 posting to a --permission-gated module as staff (no secret:manage granted), got:\n${staffSecret}`);
1607
+
1608
+ const adminLogin = run("curl", ["-s", "-X", "POST", `${B}/auth/login`, ...jsonHeader, "-d", '{"email":"genmod-admin@example.com","password":"adminpassword123"}']);
1609
+ const adminAccess = field(adminLogin, "access_token");
1610
+ const adminSecretBeforeGrant = run("curl", ["-s", "-w", "HTTPSTATUS:%{http_code}", "-X", "POST", `${B}/secrets`, ...jsonHeader, "-H", `Authorization: Bearer ${adminAccess}`, "-d", "{}"]);
1611
+ if (status(adminSecretBeforeGrant) !== "403") {
1612
+ throw new Error(`expected 403 even for the seeded admin — the permission exists but isn't auto-granted to any role, got:\n${adminSecretBeforeGrant}`);
1613
+ }
1614
+
1615
+ stopApi(genmodApi);
1616
+ runMake(["db-drop"], fullApp);
1617
+ }
1618
+ );
1619
+
1620
+ step("generate module order (full CRUD)", () => {
1621
+ goScaffold(["generate", "module", "order", "--full"], fullApp);
1622
+ });
1623
+
1624
+ step("full module: docs wired into openapi.yaml", () => {
1625
+ assertFileContains(path.join(fullApp, "docs", "openapi.yaml"), "/v1/orders:");
1626
+ assertFileContains(path.join(fullApp, "docs", "openapi.yaml"), "OrderResponse");
1627
+ });
1628
+
1629
+ step("main.go serves the whole docs/ tree, not just the index (or $ref resolution 404s over HTTP)", () => {
1630
+ assertFileContains(path.join(fullApp, "cmd", "api", "main.go"), 'r.Static("/docs", "./docs")');
1631
+ });
1632
+
1633
+ step(
1634
+ hasNpx
1635
+ ? "make openapi-bundle resolves every $ref into one file (for importers like Bruno that don't)"
1636
+ : "make openapi-bundle skipped (npx not available)",
1637
+ () => {
1638
+ if (!hasNpx) return;
1639
+ run("make", ["openapi-bundle"], fullApp);
1640
+ const bundlePath = path.join(fullApp, "docs", "openapi.bundled.yaml");
1641
+ assertFileContains(bundlePath, "get:");
1642
+ assertFileContains(bundlePath, "post:");
1643
+ // the whole point: no $ref left pointing at a sibling file
1644
+ const bundled = readFileSync(bundlePath, "utf8");
1645
+ if (bundled.includes("$ref: './")) throw new Error("bundled spec still has unresolved external $refs");
1646
+ }
1647
+ );
1648
+
1649
+ step("re-generating after deleting only the folder doesn't duplicate wiring (would panic gin)", () => {
1650
+ // simulate: user rm -rf's the module dir but main.go/openapi.yaml still
1651
+ // reference it, then re-runs generate module. Must stay a single Register.
1652
+ rmSync(path.join(fullApp, "internal", "app", "order"), { recursive: true, force: true });
1653
+ goScaffold(["generate", "module", "order", "--full"], fullApp);
1654
+ const mainGo = readFileSync(path.join(fullApp, "cmd", "api", "main.go"), "utf8");
1655
+ const registers = (mainGo.match(/order\.NewHandler\(/g) ?? []).length;
1656
+ if (registers !== 1) throw new Error(`expected exactly 1 order route registration, got ${registers}`);
1657
+ const openapi = readFileSync(path.join(fullApp, "docs", "openapi.yaml"), "utf8");
1658
+ const paths = (openapi.match(/\/v1\/orders:/g) ?? []).length;
1659
+ if (paths !== 1) throw new Error(`expected exactly 1 /v1/orders path in openapi.yaml, got ${paths}`);
1660
+ const migrations = readdirSync(path.join(fullApp, "migrations")).filter((f) => f.endsWith("_create_orders.up.sql")).length;
1661
+ if (migrations !== 1) throw new Error(`expected exactly 1 create_orders migration, got ${migrations}`);
1662
+ });
1663
+
1664
+ step("full module: build + vet + gofmt clean", () => {
1665
+ run("go", ["build", "./..."], fullApp);
1666
+ run("go", ["vet", "./..."], fullApp);
1667
+ const dirty = run("gofmt", ["-l", "."], fullApp).trim();
1668
+ if (dirty) throw new Error(`gofmt found unformatted files:\n${dirty}`);
1669
+ });
1670
+
1671
+ step("full module: go test ./... (integration tests skip without a DB)", () => {
1672
+ run("go", ["test", "./..."], fullApp);
1673
+ });
1674
+
1675
+ // The harness in handler_test.go does DropTable+AutoMigrate on whatever DSN it
1676
+ // gets. Pointed at the app's own database that destroys the schema `migrate up`
1677
+ // built — FK constraints and seed data included — so the default has to be a
1678
+ // separate <db>_test. This is the check that the default actually holds.
1679
+ step(
1680
+ hasPsql || dockerPgContainer
1681
+ ? "go test wipes only <db>_test, never the app's own database"
1682
+ : "go test DB isolation: skipped (no psql, no Postgres container)",
1683
+ () => {
1684
+ if (!hasPsql && !dockerPgContainer) return;
1685
+ // stand up a "dev database" that looks like one `make migrate-up` produced,
1686
+ // holding a row the test run must not be allowed to destroy
1687
+ runMake(["db-create"], fullApp);
1688
+ psqlExec(fullDb.dbName, "CREATE TABLE orders (id uuid PRIMARY KEY); INSERT INTO orders VALUES (gen_random_uuid());");
1689
+ runMake(["db-create"], fullApp, fullTestDb);
1690
+ // Repository integration tests use generated modules too (cart, secret,
1691
+ // order, ...), so the dedicated test DB must receive the complete
1692
+ // migration schema before packages exercise their repositories.
1693
+ run("migrate", ["-path", "migrations", "-database", fullTestDb.dbDsn, "up"], fullApp);
1694
+
1695
+ // -count=1 defeats the test cache: the step above already ran `go test ./...`
1696
+ // TEST_DB_DSN comes from this run's environment, so the harness must use
1697
+ // its dedicated test database rather than the app database above.
1698
+ run("go", ["test", "-count=1", "./..."], fullApp);
1699
+
1700
+ const rows = psqlExec(fullDb.dbName, "SELECT count(*) FROM orders;").trim();
1701
+ if (rows !== "1") {
1702
+ throw new Error(`the app's database lost data to the test run (expected 1 row, got "${rows}")`);
1703
+ }
1704
+ runMake(["db-drop"], fullApp);
1705
+ runMake(["db-drop"], fullApp, fullTestDb);
1706
+ }
1707
+ );
1708
+
1709
+ // The whole point of CheckMigrationVersion: AUTO_MIGRATE=false must not boot
1710
+ // against a DB nothing has migrated yet, and must boot fine once `migrate up`
1711
+ // has actually run — proven here against the real compiled server, not just a
1712
+ // unit test of the function. Invokes `migrate` directly rather than through
1713
+ // `make migrate-up`, so this step exercises only this PR's own code.
1714
+ step(
1715
+ (hasPsql || dockerPgContainer) && hasMigrate
1716
+ ? "AUTO_MIGRATE=false refuses to boot with no migrations applied, boots once `migrate up` has run"
1717
+ : "migration guard: skipped (needs psql/a Postgres container, and the migrate CLI)",
1718
+ () => {
1719
+ if (!((hasPsql || dockerPgContainer) && hasMigrate)) return;
1720
+
1721
+ stopAllApis(); // in case a prior assertion left one of this run's APIs behind
1722
+ runMake(["db-drop"], fullApp);
1723
+ runMake(["db-create"], fullApp);
1724
+
1725
+ // fullApp already has Redis wired into readyz once "add worker" has run
1726
+ // earlier in this suite (same shared scratch project) — .env.example's
1727
+ // own REDIS_URL default (port 6379) won't reach the throwaway container's
1728
+ // actual ephemeral port. A shell-level `REDIS_URL=... make run` prefix
1729
+ // doesn't survive this: the Makefile's own `export $(... .env ...)` step
1730
+ // re-exports .env's REDIS_URL line and clobbers it. Bake the real URL
1731
+ // into .env itself instead.
1732
+ let envContent = readFileSync(path.join(fullApp, ".env.example"), "utf8")
1733
+ .replace("AUTO_MIGRATE=true", "AUTO_MIGRATE=false")
1734
+ .replace(/^DB_DSN=.*/m, `DB_DSN=${fullDb.dbDsn}`)
1735
+ .replace(/^PORT=.*/m, `PORT=${smoke.port}`);
1736
+ if (sharedRedisUrl) envContent = envContent.replace(/REDIS_URL=.*/, `REDIS_URL=${sharedRedisUrl}`);
1737
+ writeFileSync(path.join(fullApp, ".env"), envContent);
1738
+
1739
+ const beforeApi = startMakeRun(fullApp, "migration-before", false);
1740
+ execFileSync("sleep", ["3"]);
1741
+ const beforeReady = httpStatus([`${smoke.baseURL}/readyz`], fullApp);
1742
+ stopApi(beforeApi);
1743
+ if (beforeReady !== "000") {
1744
+ throw new Error(`expected the server to refuse to boot (READYZ=000), got: ${beforeReady}`);
1745
+ }
1746
+ const beforeLog = readFileSync(logPath("migration-before"), "utf8");
1747
+ if (!beforeLog.includes("migration version check")) {
1748
+ throw new Error(`expected a "migration version check" error in the boot log, got:\n${beforeLog}`);
1749
+ }
1750
+
1751
+ const migratedDSN = fullDb.dbDsn;
1752
+ run("migrate", ["-path", "migrations", "-database", migratedDSN, "up"], fullApp);
1753
+ // Repository integration tests use the production migration schema and are
1754
+ // required here — no AutoMigrate and no false-green skip.
1755
+ run("go", ["test", "-count=1", "./..."], fullApp, {
1756
+ TEST_DB_DSN: migratedDSN,
1757
+ REQUIRE_TEST_DB: "true",
1758
+ });
1759
+
1760
+ const afterApi = startMakeRun(fullApp, "migration-after", true);
1761
+ execFileSync("sleep", ["3"]);
1762
+ const afterReady = httpStatus([`${smoke.baseURL}/readyz`], fullApp);
1763
+ const afterCreate = httpStatus([
1764
+ "-X",
1765
+ "POST",
1766
+ `${smoke.baseURL}/v1/orders`,
1767
+ "-H",
1768
+ "Content-Type: application/json",
1769
+ "-d",
1770
+ "{}",
1771
+ ], fullApp);
1772
+ stopApi(afterApi);
1773
+ if (afterReady !== "200" || afterCreate !== "201") {
1774
+ throw new Error(`expected migrated schema to boot and serve CRUD (READYZ=200 CREATE=201), got: READYZ=${afterReady} CREATE=${afterCreate}`);
1775
+ }
1776
+
1777
+ runMake(["db-drop"], fullApp);
1778
+ }
1779
+ );
1780
+
1781
+ for (const [name, args] of Object.entries({
1782
+ "patch (resource action)": ["approve", "--type", "patch"],
1783
+ "get --get-mode all": ["findActive", "--type", "get", "--get-mode", "all"],
1784
+ "get --get-mode one --field": ["findByStatus", "--type", "get", "--get-mode", "one", "--field", "status"],
1785
+ post: ["archive", "--type", "post"],
1786
+ delete: ["removeAttachment", "--type", "delete"],
1787
+ })) {
1788
+ step(`generate method order: ${name}`, () => {
1789
+ goScaffold(["generate", "method", "order", ...args], fullApp);
1790
+ });
1791
+ }
1792
+
1793
+ step("after 5 generate method calls: build + vet + gofmt + test + OpenAPI bundle", () => {
1794
+ run("go", ["build", "./..."], fullApp);
1795
+ run("go", ["vet", "./..."], fullApp);
1796
+ const dirty = run("gofmt", ["-l", "."], fullApp).trim();
1797
+ if (dirty) throw new Error(`gofmt found unformatted files:\n${dirty}`);
1798
+ run("go", ["test", "./..."], fullApp);
1799
+ if (hasNpx) {
1800
+ run("npx", ["--yes", "@redocly/cli", "bundle", "docs/openapi.yaml", "-o", "docs/openapi.bundled.yaml"], fullApp);
1801
+ }
1802
+ });
1803
+
1804
+ step(
1805
+ hasGolangciLint
1806
+ ? "after 5 generate method calls: still lint-clean"
1807
+ : "after 5 generate method calls: lint check skipped (golangci-lint not installed)",
1808
+ () => {
1809
+ if (!hasGolangciLint) return;
1810
+ const out = run("golangci-lint", ["run"], fullApp);
1811
+ if (out.trim() && !out.includes("0 issues")) throw new Error(`expected 0 issues, got:\n${out}`);
1812
+ }
1813
+ );
1814
+
1815
+ step("generate method rejects a duplicate method name", () => {
1816
+ expectThrows(() => goScaffold(["generate", "method", "order", "approve", "--type", "patch"], fullApp), "already exists");
1817
+ });
1818
+
1819
+ step("generate method rejects --field id", () => {
1820
+ expectThrows(
1821
+ () => goScaffold(["generate", "method", "order", "findById", "--type", "get", "--get-mode", "one", "--field", "id"], fullApp),
1822
+ 'cannot be "id"'
1823
+ );
1824
+ });
1825
+
1826
+ step("generate module rejects a name that already exists", () => {
1827
+ expectThrows(() => goScaffold(["generate", "module", "order"], fullApp), "already exists");
1828
+ });
1829
+
1830
+ step("rejects reserved Go words before writing broken code (module/method/field)", () => {
1831
+ expectThrows(() => goScaffold(["generate", "module", "type"], fullApp), "reserved Go word");
1832
+ expectThrows(() => goScaffold(["generate", "module", "string"], fullApp), "reserved Go word");
1833
+ expectThrows(() => goScaffold(["generate", "method", "order", "func", "--type", "post"], fullApp), "Go keyword");
1834
+ expectThrows(
1835
+ () => goScaffold(["generate", "method", "order", "findByType", "--type", "get", "--get-mode", "one", "--field", "type"], fullApp),
1836
+ "Go keyword"
1837
+ );
1838
+ expectThrows(() => goScaffold(["generate", "module", "2fa"], fullApp), "starts with a digit");
1839
+ });
1840
+
1841
+ step("remove module reverses wiring and re-generating stays clean", () => {
1842
+ goScaffold(["generate", "module", "widget"], fullApp);
1843
+ run("go", ["build", "./..."], fullApp);
1844
+ goScaffold(["remove", "module", "widget", "--yes"], fullApp);
1845
+ if (existsSync(path.join(fullApp, "internal", "app", "widget"))) throw new Error("widget folder not deleted");
1846
+ const mainGo = readFileSync(path.join(fullApp, "cmd", "api", "main.go"), "utf8");
1847
+ if (mainGo.includes("widget.NewHandler")) throw new Error("main.go still wires widget after remove");
1848
+ const openapi = readFileSync(path.join(fullApp, "docs", "openapi.yaml"), "utf8");
1849
+ if (openapi.includes("/v1/widgets:")) throw new Error("openapi still lists widgets after remove");
1850
+ run("go", ["build", "./..."], fullApp); // must still compile with widget gone
1851
+ goScaffold(["generate", "module", "widget"], fullApp); // re-adding must not duplicate
1852
+ const registers = (readFileSync(path.join(fullApp, "cmd", "api", "main.go"), "utf8").match(/widget\.NewHandler\(/g) ?? []).length;
1853
+ if (registers !== 1) throw new Error(`expected 1 widget registration after re-add, got ${registers}`);
1854
+ run("go", ["build", "./..."], fullApp);
1855
+ });
1856
+
1857
+ step("create --api-prefix beta scaffolds routes under a custom prefix", () => {
1858
+ goScaffold(["create", "beta-app", "--defaults", "--api-prefix", "beta"], scratch);
1859
+ });
1860
+
1861
+ const betaApp = path.join(scratch, "beta-app");
1862
+ step("custom prefix: generate module + method, routes land under /beta", () => {
1863
+ run("go", ["mod", "tidy"], betaApp);
1864
+ goScaffold(["generate", "module", "product"], betaApp);
1865
+ goScaffold(["generate", "method", "product", "findByStatus", "--type", "get", "--get-mode", "one", "--field", "status"], betaApp);
1866
+ const mainGo = readFileSync(path.join(betaApp, "cmd", "api", "main.go"), "utf8");
1867
+ if (!mainGo.includes('api := r.Group("/beta")')) throw new Error('expected api := r.Group("/beta") in main.go');
1868
+ const openapi = readFileSync(path.join(betaApp, "docs", "openapi.yaml"), "utf8");
1869
+ if (!openapi.includes("/beta/products/status/{status}:")) {
1870
+ throw new Error("expected generated method path under /beta/products in openapi.yaml");
1871
+ }
1872
+ run("go", ["build", "./..."], betaApp);
1873
+ run("go", ["vet", "./..."], betaApp);
1874
+ });
1875
+
1876
+ step(
1877
+ hasDocker && (hasPsql || dockerPgContainer)
1878
+ ? "create --observability: /metrics is real Prometheus output, tracing no-ops with no OTEL endpoint configured, go.mod stays free of quic-go/mysql/clickhouse/mongo"
1879
+ : "create --observability: skipped (needs Docker, psql/a Postgres container)",
1880
+ () => {
1881
+ if (!(hasDocker && (hasPsql || dockerPgContainer))) return;
1882
+
1883
+ goScaffold(["create", "obs-app", "--defaults", "--observability"], scratch);
1884
+ const obsApp = path.join(scratch, "obs-app");
1885
+ run("go", ["mod", "tidy"], obsApp);
1886
+
1887
+ // the forced-upgrade risk this step exists to catch: otelgin/gorm.io's
1888
+ // opentelemetry plugin drag in a newer Gin (-> HTTP/3/quic-go) and every
1889
+ // DB driver they trace (MySQL, ClickHouse, MongoDB) respectively — this
1890
+ // project hand-rolls both instead specifically to avoid that.
1891
+ const goSum = readFileSync(path.join(obsApp, "go.sum"), "utf8");
1892
+ for (const unwanted of ["quic-go", "go-sql-driver/mysql", "ClickHouse", "mongo-driver"]) {
1893
+ if (goSum.includes(unwanted)) throw new Error(`expected go.sum to stay free of ${unwanted}, the whole point of hand-rolling tracing instead of otelgin/gorm.io's plugin`);
1894
+ }
1895
+
1896
+ run("go", ["build", "./..."], obsApp);
1897
+ run("go", ["vet", "./..."], obsApp);
1898
+ if (hasGolangciLint) {
1899
+ const lintOut = run("golangci-lint", ["run"], obsApp);
1900
+ if (lintOut.trim() && !lintOut.includes("0 issues")) throw new Error(`expected 0 lint issues, got:\n${lintOut}`);
1901
+ }
1902
+
1903
+ goScaffold(["generate", "module", "widget", "--full"], obsApp);
1904
+ run("go", ["build", "./..."], obsApp);
1905
+
1906
+ runMake(["db-create"], obsApp, obsDb);
1907
+ const obsApi = startApi(obsApp, "obs-api", obsDb);
1908
+ execFileSync("sleep", ["3"]);
1909
+
1910
+ const createOut = run("curl", [
1911
+ "-s",
1912
+ "-w",
1913
+ "HTTPSTATUS:%{http_code}",
1914
+ "-X",
1915
+ "POST",
1916
+ `${smoke.baseURL}/v1/widgets`,
1917
+ "-H",
1918
+ "Content-Type: application/json",
1919
+ "-d",
1920
+ "{}",
1921
+ ]);
1922
+ const metricsOut = run("curl", ["-s", `${smoke.baseURL}/metrics`]);
1923
+ // OTel's default BatchSpanProcessor flushes every 5s — if Init's
1924
+ // empty-endpoint no-op regresses (an exporter gets created anyway), this
1925
+ // is the window for its first failed dial attempt to reach the log.
1926
+ execFileSync("sleep", ["6"]);
1927
+ stopApi(obsApi);
1928
+
1929
+ if (!createOut.includes("HTTPSTATUS:201")) throw new Error(`expected 201 creating a widget through the metrics+tracing middleware chain, got:\n${createOut}`);
1930
+ if (!metricsOut.includes('http_requests_total{method="POST",path="/v1/widgets",status="201"} 1')) {
1931
+ throw new Error(`expected the widget create request counted in /metrics, got:\n${metricsOut}`);
1932
+ }
1933
+ if (!/^# (HELP|TYPE) http_request_duration_seconds/m.test(metricsOut)) {
1934
+ throw new Error(`expected real Prometheus HELP/TYPE headers for the duration histogram, got:\n${metricsOut}`);
1935
+ }
1936
+
1937
+ const bootLog = readFileSync(logPath("obs-api"), "utf8");
1938
+ if (bootLog.toLowerCase().includes("panic")) throw new Error(`server panicked with tracing enabled but no OTEL endpoint configured:\n${bootLog}`);
1939
+ if (bootLog.includes("traces export")) {
1940
+ throw new Error(`expected no trace export attempt with OTEL_EXPORTER_OTLP_ENDPOINT unset (should no-op, not try to dial a collector), got:\n${bootLog}`);
1941
+ }
1942
+
1943
+ runMake(["db-drop"], obsApp, obsDb);
1944
+ }
1945
+ );
1946
+
1947
+ step("create --api-prefix '' scaffolds routes with no prefix at all", () => {
1948
+ goScaffold(["create", "noprefix-app", "--defaults", "--api-prefix", ""], scratch);
1949
+ const app = path.join(scratch, "noprefix-app");
1950
+ run("go", ["mod", "tidy"], app);
1951
+ goScaffold(["generate", "module", "widget", "--full"], app);
1952
+ const mainGo = readFileSync(path.join(app, "cmd", "api", "main.go"), "utf8");
1953
+ if (!mainGo.includes('api := r.Group("/")')) throw new Error('expected api := r.Group("/") in main.go');
1954
+ const openapi = readFileSync(path.join(app, "docs", "openapi.yaml"), "utf8");
1955
+ if (!openapi.includes("/widgets:")) throw new Error("expected /widgets (no prefix) in openapi.yaml");
1956
+ if (openapi.includes("/v1/widgets:")) throw new Error("should not have a /v1 prefix");
1957
+ run("go", ["build", "./..."], app);
1958
+ run("go", ["vet", "./..."], app);
1959
+ });
1960
+
1961
+ step("create --api-prefix api/v1 supports multi-segment prefixes (gin joins them fine)", () => {
1962
+ goScaffold(["create", "multiseg-app", "--defaults", "--api-prefix", "/api/v1/"], scratch);
1963
+ const app = path.join(scratch, "multiseg-app");
1964
+ const cfg = JSON.parse(readFileSync(path.join(app, "go-scaffold.config.json"), "utf8"));
1965
+ if (cfg.apiPrefix !== "api/v1") throw new Error(`expected leading/trailing slashes stripped, got "${cfg.apiPrefix}"`);
1966
+ run("go", ["mod", "tidy"], app);
1967
+ goScaffold(["generate", "module", "order", "--full"], app);
1968
+ const mainGo = readFileSync(path.join(app, "cmd", "api", "main.go"), "utf8");
1969
+ if (!mainGo.includes('api := r.Group("/api/v1")')) throw new Error('expected api := r.Group("/api/v1") in main.go');
1970
+ const openapi = readFileSync(path.join(app, "docs", "openapi.yaml"), "utf8");
1971
+ if (!openapi.includes("/api/v1/orders:")) throw new Error("expected /api/v1/orders in openapi.yaml");
1972
+ run("go", ["build", "./..."], app);
1973
+ run("go", ["vet", "./..."], app);
1974
+ });
1975
+
1976
+ step("default minimal module layers up to full build", () => {
1977
+ goScaffold(["create", "min-app", "--defaults"], scratch);
1978
+ const minApp = path.join(scratch, "min-app");
1979
+ run("go", ["mod", "tidy"], minApp);
1980
+ goScaffold(["generate", "module", "widget"], minApp);
1981
+ run("go", ["build", "./..."], minApp);
1982
+ if (hasGolangciLint) {
1983
+ // bare minimal module, zero methods yet: the ahead-of-use plumbing
1984
+ // (repository stub, test harness, wrapFindErr, response/toResponse) must not
1985
+ // trip `unused` before anything has wired it in.
1986
+ const out = run("golangci-lint", ["run"], minApp);
1987
+ if (out.trim() && !out.includes("0 issues")) throw new Error(`bare minimal module: expected 0 issues, got:\n${out}`);
1988
+ }
1989
+ goScaffold(["generate", "method", "widget", "create", "--type", "post"], minApp);
1990
+ goScaffold(["generate", "method", "widget", "list", "--type", "get", "--get-mode", "all"], minApp);
1991
+ goScaffold(["generate", "method", "widget", "findByStatus", "--type", "get", "--get-mode", "one", "--field", "status"], minApp);
1992
+ run("go", ["build", "./..."], minApp);
1993
+ run("go", ["vet", "./..."], minApp);
1994
+ const dirty = run("gofmt", ["-l", "."], minApp).trim();
1995
+ if (dirty) throw new Error(`gofmt found unformatted files:\n${dirty}`);
1996
+ if (hasGolangciLint) {
1997
+ const out = run("golangci-lint", ["run"], minApp);
1998
+ if (out.trim() && !out.includes("0 issues")) throw new Error(`layered minimal module: expected 0 issues, got:\n${out}`);
1999
+ }
2000
+ });
2001
+
2002
+ // The two halves of drift detection. `create` emits the shared/ layer once;
2003
+ // `generate` renders templates written against that exact layer. A project that
2004
+ // later edits shared/ — normal, expected work — silently falls out of sync, and
2005
+ // the break lands in generated files the user never wrote. These two steps pin
2006
+ // down both "catches it" and "doesn't cry wolf".
2007
+ step("generate fails loudly when the project's shared/ layer has drifted", () => {
2008
+ goScaffold(["create", "drift-app", "--defaults"], scratch);
2009
+ const app = path.join(scratch, "drift-app");
2010
+ run("go", ["mod", "tidy"], app);
2011
+
2012
+ // Simulates a project whose shared/ layer moved on after scaffolding —
2013
+ // middleware.Error grows a parameter and its one caller is updated, but the
2014
+ // CLI's own frozen template (what `generate module` renders) doesn't know
2015
+ // that happened. Prepends a new first parameter via a plain anchored string
2016
+ // replace rather than trying to capture-and-reinsert whatever's already
2017
+ // inside the parens: existing call sites can contain their own nested
2018
+ // parens (e.g. `middleware.Error(!cfg.IsProd())`), which a `[^)]*` regex
2019
+ // can't balance — it matches up to the *inner* `)` and mangles the
2020
+ // rewrite. A left-anchored prepend never needs to look past `Error(`, so it
2021
+ // stays correct regardless of what's already inside.
2022
+ const errPath = path.join(app, "internal", "shared", "middleware", "error.go");
2023
+ const errSrc = readFileSync(errPath, "utf8");
2024
+ const mutatedErr = errSrc.replace("func Error(", "func Error(_extraDrift bool, ");
2025
+ if (mutatedErr === errSrc) throw new Error("middleware.Error signature not found — update this test's mutation");
2026
+ writeFileSync(errPath, mutatedErr);
2027
+
2028
+ const mainPath = path.join(app, "cmd", "api", "main.go");
2029
+ const mainSrc = readFileSync(mainPath, "utf8");
2030
+ const mutatedMain = mainSrc.replace("middleware.Error(", "middleware.Error(true, ");
2031
+ if (mutatedMain === mainSrc) throw new Error("middleware.Error call site not found — update this test's mutation");
2032
+ writeFileSync(mainPath, mutatedMain);
2033
+
2034
+ run("go", ["vet", "./..."], app); // the project itself is still perfectly fine
2035
+
2036
+ // the generated handler_test.go builds the middleware chain by hand using
2037
+ // generate module's frozen template, which doesn't know about the mutation
2038
+ // above — `go build` wouldn't see it (test file), `go vet` does.
2039
+ expectThrows(
2040
+ () => goScaffold(["generate", "module", "order", "--full"], app),
2041
+ "drift"
2042
+ );
2043
+ });
2044
+
2045
+ step("generate doesn't blame itself for a project that was already broken", () => {
2046
+ goScaffold(["create", "prebroken-app", "--defaults"], scratch);
2047
+ const app = path.join(scratch, "prebroken-app");
2048
+ run("go", ["mod", "tidy"], app);
2049
+ // a type error the user introduced, nothing to do with the generator
2050
+ writeFileSync(path.join(app, "internal", "shared", "id", "wip.go"), 'package id\n\nfunc wip() int { return "nope" }\n');
2051
+ goScaffold(["generate", "module", "order"], app); // must still succeed
2052
+ if (!existsSync(path.join(app, "internal", "app", "order", "handler.go"))) {
2053
+ throw new Error("module wasn't generated");
2054
+ }
2055
+ });
2056
+
2057
+ cleanup();
2058
+ console.log(`\n${passed} checks passed.`);