|
| 1 | +/** |
| 2 | + * Regression tests for CLI argv parsing. |
| 3 | + * |
| 4 | + * These stay separate from cli-e2e.test.mjs so parser failures are fast and |
| 5 | + * focused: no command should reach the network when argv itself is invalid. |
| 6 | + */ |
| 7 | + |
| 8 | +import { describe, it, before, after, beforeEach } from "node:test"; |
| 9 | +import assert from "node:assert/strict"; |
| 10 | +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 11 | +import { join } from "node:path"; |
| 12 | +import { tmpdir } from "node:os"; |
| 13 | + |
| 14 | +const tempDir = mkdtempSync(join(tmpdir(), "run402-argv-")); |
| 15 | +const API = "https://test-api.run402.com"; |
| 16 | +process.env.RUN402_CONFIG_DIR = tempDir; |
| 17 | +process.env.RUN402_API_BASE = API; |
| 18 | + |
| 19 | +const originalFetch = globalThis.fetch; |
| 20 | +const originalLog = console.log; |
| 21 | +const originalError = console.error; |
| 22 | +const originalExit = process.exit; |
| 23 | +let stdout = []; |
| 24 | +let stderr = []; |
| 25 | +let calls = []; |
| 26 | + |
| 27 | +function json(data, status = 200) { |
| 28 | + return new Response(JSON.stringify(data), { |
| 29 | + status, |
| 30 | + headers: { "Content-Type": "application/json" }, |
| 31 | + }); |
| 32 | +} |
| 33 | + |
| 34 | +function requestInfo(input, init) { |
| 35 | + const url = typeof input === "string" ? input : (input instanceof Request ? input.url : String(input)); |
| 36 | + const method = (init?.method || (input instanceof Request ? input.method : "GET") || "GET").toUpperCase(); |
| 37 | + const path = url.startsWith(API) ? url.slice(API.length) : url; |
| 38 | + return { url, method, path, init }; |
| 39 | +} |
| 40 | + |
| 41 | +function mockFetch(input, init) { |
| 42 | + const info = requestInfo(input, init); |
| 43 | + calls.push(info); |
| 44 | + const pathNoQuery = info.path.split("?")[0]; |
| 45 | + |
| 46 | + if (pathNoQuery === "/storage/v1/blobs" && info.method === "GET") { |
| 47 | + return Promise.resolve(json({ blobs: [{ key: "file.txt" }] })); |
| 48 | + } |
| 49 | + if (/\/functions\/hello\/logs$/.test(pathNoQuery) && info.method === "GET") { |
| 50 | + return Promise.resolve(json({ logs: [{ timestamp: "2026-05-01T00:00:00Z", message: "ok" }] })); |
| 51 | + } |
| 52 | + if (/\/functions\/hello$/.test(pathNoQuery) && info.method === "PATCH") { |
| 53 | + return Promise.resolve(json({ name: "hello", status: "updated" })); |
| 54 | + } |
| 55 | + |
| 56 | + return Promise.resolve(json({ ok: true })); |
| 57 | +} |
| 58 | + |
| 59 | +function captureStart() { |
| 60 | + stdout = []; |
| 61 | + stderr = []; |
| 62 | + console.log = (...args) => stdout.push(args.map(String).join(" ")); |
| 63 | + console.error = (...args) => stderr.push(args.map(String).join(" ")); |
| 64 | +} |
| 65 | + |
| 66 | +function captureStop() { |
| 67 | + console.log = originalLog; |
| 68 | + console.error = originalError; |
| 69 | +} |
| 70 | + |
| 71 | +function stderrJson() { |
| 72 | + const line = stderr.find((s) => s.trim().startsWith("{")); |
| 73 | + assert.ok(line, `expected JSON stderr, got: ${stderr.join("\n")}`); |
| 74 | + return JSON.parse(line); |
| 75 | +} |
| 76 | + |
| 77 | +async function expectExit1(fn) { |
| 78 | + let threw = null; |
| 79 | + captureStart(); |
| 80 | + try { |
| 81 | + await fn(); |
| 82 | + } catch (err) { |
| 83 | + threw = err; |
| 84 | + } finally { |
| 85 | + captureStop(); |
| 86 | + } |
| 87 | + assert.equal(threw?.message, "process.exit(1)"); |
| 88 | + return stderrJson(); |
| 89 | +} |
| 90 | + |
| 91 | +before(async () => { |
| 92 | + globalThis.fetch = mockFetch; |
| 93 | + process.exit = (code) => { throw new Error(`process.exit(${code})`); }; |
| 94 | + const { saveProject, setActiveProjectId } = await import("./cli/core-dist/keystore.js"); |
| 95 | + saveProject("prj_test123", { |
| 96 | + anon_key: "anon_test_key", |
| 97 | + service_key: "svc_test_key", |
| 98 | + }); |
| 99 | + setActiveProjectId("prj_test123"); |
| 100 | +}); |
| 101 | + |
| 102 | +after(() => { |
| 103 | + globalThis.fetch = originalFetch; |
| 104 | + console.log = originalLog; |
| 105 | + console.error = originalError; |
| 106 | + process.exit = originalExit; |
| 107 | + delete process.env.RUN402_CONFIG_DIR; |
| 108 | + delete process.env.RUN402_API_BASE; |
| 109 | + rmSync(tempDir, { recursive: true, force: true }); |
| 110 | +}); |
| 111 | + |
| 112 | +beforeEach(() => { |
| 113 | + calls = []; |
| 114 | + captureStop(); |
| 115 | +}); |
| 116 | + |
| 117 | +describe("unknown flags", () => { |
| 118 | + it("status rejects unknown flags before doing any work (GH-190)", async () => { |
| 119 | + const { run } = await import("./cli/lib/status.mjs"); |
| 120 | + const err = await expectExit1(() => run(["--unknownflag"])); |
| 121 | + |
| 122 | + assert.equal(err.code, "UNKNOWN_FLAG"); |
| 123 | + assert.equal(err.details.flag, "--unknownflag"); |
| 124 | + assert.equal(calls.length, 0, "invalid argv must not hit the network"); |
| 125 | + }); |
| 126 | + |
| 127 | + it("functions logs rejects unknown flags before fetching logs (GH-190)", async () => { |
| 128 | + const { run } = await import("./cli/lib/functions.mjs"); |
| 129 | + const err = await expectExit1(() => |
| 130 | + run("logs", ["prj_test123", "hello", "--no-such-flag", "value"])); |
| 131 | + |
| 132 | + assert.equal(err.code, "UNKNOWN_FLAG"); |
| 133 | + assert.equal(err.details.flag, "--no-such-flag"); |
| 134 | + assert.equal(calls.length, 0, "invalid argv must not hit the network"); |
| 135 | + }); |
| 136 | +}); |
| 137 | + |
| 138 | +describe("--flag=value", () => { |
| 139 | + it("blob ls accepts equals-form flags (GH-189)", async () => { |
| 140 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 141 | + captureStart(); |
| 142 | + await run("ls", ["--project=prj_test123", "--limit=500"]); |
| 143 | + captureStop(); |
| 144 | + |
| 145 | + const call = calls.find((c) => c.path.startsWith("/storage/v1/blobs?")); |
| 146 | + assert.ok(call, `expected blob list request, got ${JSON.stringify(calls)}`); |
| 147 | + assert.match(call.url, /limit=500/); |
| 148 | + assert.ok(stdout.join("\n").includes("file.txt")); |
| 149 | + }); |
| 150 | + |
| 151 | + it("functions logs accepts equals-form numeric flags (GH-189)", async () => { |
| 152 | + const { run } = await import("./cli/lib/functions.mjs"); |
| 153 | + captureStart(); |
| 154 | + await run("logs", ["prj_test123", "hello", "--tail=10"]); |
| 155 | + captureStop(); |
| 156 | + |
| 157 | + const call = calls.find((c) => /\/logs\?/.test(c.path)); |
| 158 | + assert.ok(call, `expected logs request, got ${JSON.stringify(calls)}`); |
| 159 | + assert.match(call.url, /tail=10/); |
| 160 | + }); |
| 161 | +}); |
| 162 | + |
| 163 | +describe("numeric flag validation", () => { |
| 164 | + it("blob ls validates --limit before network (GH-186)", async () => { |
| 165 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 166 | + for (const value of ["notanumber", "0", "999999"]) { |
| 167 | + calls = []; |
| 168 | + const err = await expectExit1(() => run("ls", ["--project", "prj_test123", "--limit", value])); |
| 169 | + assert.equal(err.code, "BAD_FLAG"); |
| 170 | + assert.match(err.message, /--limit/); |
| 171 | + assert.equal(calls.length, 0, `bad --limit ${value} must not hit network`); |
| 172 | + } |
| 173 | + }); |
| 174 | + |
| 175 | + it("blob sign validates --ttl before network (GH-186)", async () => { |
| 176 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 177 | + for (const value of ["abc", "-1", "99999999"]) { |
| 178 | + calls = []; |
| 179 | + const err = await expectExit1(() => run("sign", ["reports/a.pdf", "--project", "prj_test123", "--ttl", value])); |
| 180 | + assert.equal(err.code, "BAD_FLAG"); |
| 181 | + assert.match(err.message, /--ttl/); |
| 182 | + assert.equal(calls.length, 0, `bad --ttl ${value} must not hit network`); |
| 183 | + } |
| 184 | + }); |
| 185 | + |
| 186 | + it("blob put validates --concurrency before upload init (GH-186)", async () => { |
| 187 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 188 | + const file = join(tempDir, "upload.txt"); |
| 189 | + writeFileSync(file, "hello"); |
| 190 | + const err = await expectExit1(() => |
| 191 | + run("put", [file, "--project", "prj_test123", "--concurrency", "0"])); |
| 192 | + |
| 193 | + assert.equal(err.code, "BAD_FLAG"); |
| 194 | + assert.match(err.message, /--concurrency/); |
| 195 | + assert.equal(calls.length, 0, "bad --concurrency must not init an upload"); |
| 196 | + }); |
| 197 | + |
| 198 | + it("blob put surfaces upload-init gateway errors as structured JSON (GH-186)", async () => { |
| 199 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 200 | + const file = join(tempDir, "upload-init-fails.txt"); |
| 201 | + writeFileSync(file, "hello"); |
| 202 | + const prevFetch = globalThis.fetch; |
| 203 | + globalThis.fetch = (input, init) => { |
| 204 | + const info = requestInfo(input, init); |
| 205 | + calls.push(info); |
| 206 | + if (info.path === "/storage/v1/uploads" && info.method === "POST") { |
| 207 | + return Promise.resolve(json({ |
| 208 | + error: "Invalid apikey", |
| 209 | + message: "Invalid apikey", |
| 210 | + code: "INVALID_AUTH", |
| 211 | + trace_id: "trc_init", |
| 212 | + }, 401)); |
| 213 | + } |
| 214 | + return mockFetch(input, init); |
| 215 | + }; |
| 216 | + const err = await expectExit1(() => |
| 217 | + run("put", [file, "--project", "prj_test123", "--concurrency", "1"])); |
| 218 | + globalThis.fetch = prevFetch; |
| 219 | + |
| 220 | + assert.equal(err.http, 401); |
| 221 | + assert.equal(err.code, "INVALID_AUTH"); |
| 222 | + assert.equal(err.trace_id, "trc_init"); |
| 223 | + assert.ok(!/\\\"code\\\"/.test(err.message ?? ""), `message should not contain stringified JSON: ${err.message}`); |
| 224 | + }); |
| 225 | + |
| 226 | + it("functions update validates --memory before network (GH-186)", async () => { |
| 227 | + const { run } = await import("./cli/lib/functions.mjs"); |
| 228 | + const err = await expectExit1(() => |
| 229 | + run("update", ["prj_test123", "hello", "--memory", "abc"])); |
| 230 | + |
| 231 | + assert.equal(err.code, "BAD_FLAG"); |
| 232 | + assert.match(err.message, /--memory/); |
| 233 | + assert.equal(calls.length, 0, "bad --memory must not hit network"); |
| 234 | + }); |
| 235 | +}); |
| 236 | + |
| 237 | +describe("project-id heuristic", () => { |
| 238 | + it("projects info refuses non-prj first positional instead of using active project (GH-184)", async () => { |
| 239 | + const { run } = await import("./cli/lib/projects.mjs"); |
| 240 | + const err = await expectExit1(() => run("info", ["proj-001"])); |
| 241 | + |
| 242 | + assert.equal(err.code, "BAD_PROJECT_ID"); |
| 243 | + assert.match(err.message, /proj-001/); |
| 244 | + assert.equal(calls.length, 0); |
| 245 | + }); |
| 246 | + |
| 247 | + it("projects sql keeps one-arg query active-project shorthand but rejects bad-id plus extra query (GH-184)", async () => { |
| 248 | + const { run } = await import("./cli/lib/projects.mjs"); |
| 249 | + |
| 250 | + captureStart(); |
| 251 | + await run("sql", ["SELECT 1"]); |
| 252 | + captureStop(); |
| 253 | + assert.equal(calls.some((c) => /\/projects\/v1\/admin\/prj_test123\/sql$/.test(c.path)), true); |
| 254 | + |
| 255 | + calls = []; |
| 256 | + const err = await expectExit1(() => run("sql", ["badly-typed-id", "DELETE FROM users"])); |
| 257 | + assert.equal(err.code, "BAD_PROJECT_ID"); |
| 258 | + assert.match(err.message, /badly-typed-id/); |
| 259 | + assert.equal(calls.length, 0); |
| 260 | + }); |
| 261 | + |
| 262 | + it("projects sql refuses a non-prj positional before --file (GH-184)", async () => { |
| 263 | + const { run } = await import("./cli/lib/projects.mjs"); |
| 264 | + const sqlFile = join(tempDir, "danger.sql"); |
| 265 | + writeFileSync(sqlFile, "SELECT 1"); |
| 266 | + |
| 267 | + const err = await expectExit1(() => run("sql", ["proj-001", "--file", sqlFile])); |
| 268 | + assert.equal(err.code, "BAD_PROJECT_ID"); |
| 269 | + assert.match(err.message, /proj-001/); |
| 270 | + assert.equal(calls.length, 0); |
| 271 | + }); |
| 272 | +}); |
0 commit comments