|
| 1 | +package cmd |
| 2 | + |
| 3 | +// dx_gaps_2026_06_10_test.go — four contained DX-gap fixes (2026-06-10): |
| 4 | +// |
| 5 | +// 1. PAT fallback signpost on `instant login` poll timeout AND in |
| 6 | +// `login --help`. A stranded device-flow user must learn the |
| 7 | +// --token / INSTANT_TOKEN escape hatch. |
| 8 | +// 2. `instant deploy new --name foo` must reach the helpful MCP/curl |
| 9 | +// pointer instead of dying with cobra `unknown flag: --name`. |
| 10 | +// 3. `instant resources` (no auth) must print the "not logged in" |
| 11 | +// guidance EXACTLY ONCE (main.go owns the print; the handler no |
| 12 | +// longer also writes to os.Stderr). |
| 13 | +// 4. `instant --version` falls back to runtime/debug VCS metadata when |
| 14 | +// ldflags are empty (the `go install` / `go build` path). |
| 15 | + |
| 16 | +import ( |
| 17 | + "runtime/debug" |
| 18 | + "strings" |
| 19 | + "testing" |
| 20 | +) |
| 21 | + |
| 22 | +// ── Fix 1: PAT fallback signpost ──────────────────────────────────────────── |
| 23 | + |
| 24 | +// TestLoginTimeout_PrintsPATWorkaround pins that the login-poll timeout error |
| 25 | +// surfaces the Personal Access Token escape hatch (settings URL + both |
| 26 | +// --token and INSTANT_TOKEN forms). A user whose browser flow stalls must be |
| 27 | +// told how to authenticate without it. |
| 28 | +func TestLoginTimeout_PrintsPATWorkaround(t *testing.T) { |
| 29 | + withCleanState(t) |
| 30 | + withShortPolls(t) |
| 31 | + |
| 32 | + prev := APIBaseURL |
| 33 | + // Unroutable host → every poll attempt errors and the loop eventually |
| 34 | + // hits the deadline, returning the timeout error. |
| 35 | + APIBaseURL = "http://127.0.0.1:1" |
| 36 | + t.Cleanup(func() { APIBaseURL = prev }) |
| 37 | + |
| 38 | + _, err := pollForAuthCompletion("s1") |
| 39 | + if err == nil { |
| 40 | + t.Fatal("expected timeout error") |
| 41 | + } |
| 42 | + msg := err.Error() |
| 43 | + for _, want := range []string{ |
| 44 | + "https://instanode.dev/app/settings", |
| 45 | + "--token", |
| 46 | + "INSTANT_TOKEN", |
| 47 | + } { |
| 48 | + if !strings.Contains(msg, want) { |
| 49 | + t.Errorf("timeout error must mention %q for the PAT workaround; got %q", want, msg) |
| 50 | + } |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +// TestLoginHelp_DocumentsPATWorkaround pins that `instant login --help` |
| 55 | +// (the Long text) tells the user about the PAT escape hatch before they |
| 56 | +// even start a flow that might time out / fail on a headless box. |
| 57 | +func TestLoginHelp_DocumentsPATWorkaround(t *testing.T) { |
| 58 | + long := loginCmd.Long |
| 59 | + for _, want := range []string{ |
| 60 | + "https://instanode.dev/app/settings", |
| 61 | + "--token", |
| 62 | + "INSTANT_TOKEN", |
| 63 | + } { |
| 64 | + if !strings.Contains(long, want) { |
| 65 | + t.Errorf("login --help Long must mention %q; got %q", want, long) |
| 66 | + } |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +// TestPATHint_SingleSource guards rule 16 (one token, all sites): the timeout |
| 71 | +// error and the help text must both derive their core sentence from the same |
| 72 | +// const so they can never drift apart. |
| 73 | +func TestPATHint_SingleSource(t *testing.T) { |
| 74 | + if !strings.Contains(patWorkaroundHint, "https://instanode.dev/app/settings") { |
| 75 | + t.Errorf("patWorkaroundHint missing settings URL: %q", patWorkaroundHint) |
| 76 | + } |
| 77 | + if !strings.Contains(patWorkaroundHint, "--token") || |
| 78 | + !strings.Contains(patWorkaroundHint, "INSTANT_TOKEN") { |
| 79 | + t.Errorf("patWorkaroundHint missing one of the two PAT auth forms: %q", patWorkaroundHint) |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +// ── Fix 2: deploy stub tolerates unknown flags ────────────────────────────── |
| 84 | + |
| 85 | +// TestDeployStub_UnknownFlagReachesPointer pins that an agent passing the |
| 86 | +// REAL deploy flags (`--name`, `--env`) to the stub still lands on the |
| 87 | +// helpful MCP/curl pointer rather than cobra's `unknown flag: --name` |
| 88 | +// flag-parse error (which fires BEFORE RunE and looks like a bug in the |
| 89 | +// agent's own command). |
| 90 | +func TestDeployStub_UnknownFlagReachesPointer(t *testing.T) { |
| 91 | + newITContext(t) |
| 92 | + |
| 93 | + cases := [][]string{ |
| 94 | + {"deploy", "new", "--name", "foo"}, |
| 95 | + {"deploy", "new", "--name", "foo", "--env", "production"}, |
| 96 | + {"deploy", "logs", "some-id", "--follow"}, |
| 97 | + {"deploy", "--name", "foo"}, // bare parent + unknown flag |
| 98 | + } |
| 99 | + for _, args := range cases { |
| 100 | + args := args |
| 101 | + t.Run(strings.Join(args, "_"), func(t *testing.T) { |
| 102 | + _, stderr, err := run(args...) |
| 103 | + if err == nil { |
| 104 | + t.Fatalf("%v: MUST exit non-zero (stub not implemented)", args) |
| 105 | + } |
| 106 | + // The whole point: the error must be the not-implemented |
| 107 | + // pointer, NOT an "unknown flag" flag-parse failure. |
| 108 | + combined := strings.ToLower(stderr + err.Error()) |
| 109 | + if strings.Contains(combined, "unknown flag") { |
| 110 | + t.Errorf("%v: reached cobra unknown-flag error instead of the pointer; got %q", |
| 111 | + args, combined) |
| 112 | + } |
| 113 | + if !strings.Contains(combined, "implement") { |
| 114 | + t.Errorf("%v: error must mention not-implemented pointer; got %q", args, combined) |
| 115 | + } |
| 116 | + }) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +// ── Fix 3: single not-logged-in print ─────────────────────────────────────── |
| 121 | + |
| 122 | +// TestResources_NoAuth_NoDuplicateStderrHint pins that the resources 401 |
| 123 | +// no-auth branch no longer ALSO writes "Not logged in. Run `instant login` |
| 124 | +// first." to os.Stderr. Pre-fix the handler printed that sentence AND main.go |
| 125 | +// printed the returned errAuthRequired message — two not-logged-in lines for |
| 126 | +// one failure. main.go now owns the single print; the handler is silent. |
| 127 | +// |
| 128 | +// We capture the OS-level stderr (the pre-fix print used fmt.Fprintln(os.Stderr, |
| 129 | +// …), which bypasses cobra's SetErr buffer that run() captures). |
| 130 | +func TestResources_NoAuth_NoDuplicateStderrHint(t *testing.T) { |
| 131 | + c := newITContext(t) |
| 132 | + c.mock.mu.Lock() |
| 133 | + c.mock.requireAuth = true |
| 134 | + c.mock.mu.Unlock() |
| 135 | + |
| 136 | + resetJSONFlags() |
| 137 | + |
| 138 | + var err error |
| 139 | + _, osStderr := captureStdout(t, func() { |
| 140 | + _, _, err = run("resources") |
| 141 | + }) |
| 142 | + if err == nil { |
| 143 | + t.Fatal("resources w/o auth: expected non-nil err (exit 3)") |
| 144 | + } |
| 145 | + if ExitCodeFor(err) != ExitAuthRequired { |
| 146 | + t.Errorf("exit code = %d, want %d (ExitAuthRequired)", ExitCodeFor(err), ExitAuthRequired) |
| 147 | + } |
| 148 | + // The handler must no longer write the legacy "Not logged in." sentence |
| 149 | + // to os.Stderr; main.go owns the single user-facing print. |
| 150 | + if strings.Contains(osStderr, "Not logged in. Run") { |
| 151 | + t.Errorf("handler must not print the legacy not-logged-in hint to os.Stderr (main.go owns it); got %q", |
| 152 | + osStderr) |
| 153 | + } |
| 154 | + // The returned error still names `instant login` so no guidance is lost. |
| 155 | + if !strings.Contains(err.Error(), "instant login") { |
| 156 | + t.Errorf("returned error must still point at `instant login`; got %q", err.Error()) |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +// ── Fix 4: --version VCS fallback ─────────────────────────────────────────── |
| 161 | + |
| 162 | +// fakeBuildInfo builds a *debug.BuildInfo carrying the given vcs settings so |
| 163 | +// resolveBuildInfo's backfill path is exercised without a real git checkout. |
| 164 | +func fakeBuildInfo(rev, vtime string) func() (*debug.BuildInfo, bool) { |
| 165 | + return func() (*debug.BuildInfo, bool) { |
| 166 | + bi := &debug.BuildInfo{} |
| 167 | + if rev != "" { |
| 168 | + bi.Settings = append(bi.Settings, debug.BuildSetting{Key: "vcs.revision", Value: rev}) |
| 169 | + } |
| 170 | + if vtime != "" { |
| 171 | + bi.Settings = append(bi.Settings, debug.BuildSetting{Key: "vcs.time", Value: vtime}) |
| 172 | + } |
| 173 | + return bi, true |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +// TestResolveBuildInfo_LdflagsWin: when ldflags are stamped, the VCS reader is |
| 178 | +// never consulted — the stamped values pass straight through. |
| 179 | +func TestResolveBuildInfo_LdflagsWin(t *testing.T) { |
| 180 | + v, c, b := resolveBuildInfo("v1.2.3", "abc1234", "2026-06-10T00:00:00Z", |
| 181 | + fakeBuildInfo("ffffffffffffffffffffffffffffffffffffffff", "2099-01-01T00:00:00Z")) |
| 182 | + if v != "v1.2.3" || c != "abc1234" || b != "2026-06-10T00:00:00Z" { |
| 183 | + t.Errorf("ldflag values must win, got (%q,%q,%q)", v, c, b) |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +// TestResolveBuildInfo_VCSFallback: empty commit/buildTime (the go install / |
| 188 | +// go build path) are backfilled from VCS metadata, and the revision is |
| 189 | +// shortened to the platform's 7-char form. |
| 190 | +func TestResolveBuildInfo_VCSFallback(t *testing.T) { |
| 191 | + v, c, b := resolveBuildInfo("", "", "", |
| 192 | + fakeBuildInfo("0123456789abcdef0123456789abcdef01234567", "2026-06-09T12:00:00Z")) |
| 193 | + if v != "dev" { |
| 194 | + t.Errorf("version = %q, want dev (no ldflag, no VCS version)", v) |
| 195 | + } |
| 196 | + if c != "0123456" { |
| 197 | + t.Errorf("commit = %q, want short VCS sha 0123456", c) |
| 198 | + } |
| 199 | + if b != "2026-06-09T12:00:00Z" { |
| 200 | + t.Errorf("buildTime = %q, want VCS time", b) |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +// TestResolveBuildInfo_SentinelInputFallsBack pins the REAL production path: |
| 205 | +// main.go declares the un-stamped defaults as the SENTINELS "dev"/"unknown" |
| 206 | +// (not ""), so SetBuildInfo passes sentinels. The fallback must treat those |
| 207 | +// as unset and still backfill from VCS — a naive `== ""` guard would not. |
| 208 | +func TestResolveBuildInfo_SentinelInputFallsBack(t *testing.T) { |
| 209 | + v, c, b := resolveBuildInfo("dev", "unknown", "unknown", |
| 210 | + fakeBuildInfo("abcdef0123456789abcdef0123456789abcdef01", "2026-06-08T08:00:00Z")) |
| 211 | + if v != "dev" { |
| 212 | + t.Errorf("version = %q, want dev", v) |
| 213 | + } |
| 214 | + if c != "abcdef0" { |
| 215 | + t.Errorf("commit = %q, want short VCS sha abcdef0 (sentinel must not block fallback)", c) |
| 216 | + } |
| 217 | + if b != "2026-06-08T08:00:00Z" { |
| 218 | + t.Errorf("buildTime = %q, want VCS time (sentinel must not block fallback)", b) |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +// TestResolveBuildInfo_NoVCSNoLdflags: nothing available anywhere → the |
| 223 | +// pre-existing dev/unknown/unknown sentinel is preserved (e.g. `go run`). |
| 224 | +func TestResolveBuildInfo_NoVCSNoLdflags(t *testing.T) { |
| 225 | + v, c, b := resolveBuildInfo("", "", "", func() (*debug.BuildInfo, bool) { return nil, false }) |
| 226 | + if v != "dev" || c != "unknown" || b != "unknown" { |
| 227 | + t.Errorf("sentinel fallback broken, got (%q,%q,%q)", v, c, b) |
| 228 | + } |
| 229 | + // nil reader must also be tolerated. |
| 230 | + v, c, b = resolveBuildInfo("", "", "", nil) |
| 231 | + if v != "dev" || c != "unknown" || b != "unknown" { |
| 232 | + t.Errorf("nil-reader fallback broken, got (%q,%q,%q)", v, c, b) |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +// TestShortSHA_ShortInputUnchanged: a sub-7-char revision is returned as-is so |
| 237 | +// an unexpected/dirty value still surfaces rather than being mangled. |
| 238 | +func TestShortSHA_ShortInputUnchanged(t *testing.T) { |
| 239 | + if got := shortSHA("abc"); got != "abc" { |
| 240 | + t.Errorf("shortSHA(abc) = %q, want abc", got) |
| 241 | + } |
| 242 | + if got := shortSHA("0123456789"); got != "0123456" { |
| 243 | + t.Errorf("shortSHA truncation = %q, want 0123456", got) |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +// TestSetBuildInfo_VersionStringShape is an end-to-end smoke over SetBuildInfo |
| 248 | +// (which calls resolveBuildInfo with the real debug.ReadBuildInfo): the |
| 249 | +// resulting rootCmd.Version is the documented "<v> (<c>, <t>)" shape and is |
| 250 | +// never the empty-paren placeholder. |
| 251 | +func TestSetBuildInfo_VersionStringShape(t *testing.T) { |
| 252 | + prev := rootCmd.Version |
| 253 | + t.Cleanup(func() { rootCmd.Version = prev }) |
| 254 | + |
| 255 | + SetBuildInfo("v9.9.9", "deadbee", "2026-06-10T00:00:00Z") |
| 256 | + if rootCmd.Version != "v9.9.9 (deadbee, 2026-06-10T00:00:00Z)" { |
| 257 | + t.Errorf("rootCmd.Version = %q", rootCmd.Version) |
| 258 | + } |
| 259 | + |
| 260 | + // Sentinel ldflags (main.go's un-stamped defaults) → resolveBuildInfo |
| 261 | + // runs against the test binary's real build info. The shape must still |
| 262 | + // be well-formed (never "(, )") and must start with the dev sentinel. |
| 263 | + // (Under `go test` the binary carries no runtime vcs settings, so this |
| 264 | + // lands on dev (unknown, unknown) — still well-formed.) |
| 265 | + SetBuildInfo("dev", "unknown", "unknown") |
| 266 | + if !strings.HasPrefix(rootCmd.Version, "dev (") || !strings.HasSuffix(rootCmd.Version, ")") { |
| 267 | + t.Errorf("sentinel-ldflag Version malformed: %q", rootCmd.Version) |
| 268 | + } |
| 269 | + if strings.Contains(rootCmd.Version, "(, )") { |
| 270 | + t.Errorf("Version must never have empty commit+time fields: %q", rootCmd.Version) |
| 271 | + } |
| 272 | +} |
0 commit comments