| index 95fe4eaa8..88d5c7cb0 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| + "flag" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| @@ -18,6 +19,7 @@ import ( |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| @@ -159,6 +161,44 @@ func TestResolveModeExpiredCredentialOnly(t *testing.T) { |
| } |
| } |
| |
| +// TestResolveModeConsoleFlagRefusedWithStoreEnv pins the design ruling from |
| +// the review round (2026-08-03): an explicit -console alongside a store env |
| +// is refused outright, rather than silently winning as direct mode with |
| +// -console quietly ignored — the spec's "the two modes never mix in one |
| +// invocation" intent (§1) would otherwise be violated invisibly. Both store |
| +// env vars are checked; a BARE env with no -console still wins silently, |
| +// unchanged (already pinned by TestResolveModeDirectWins/TestResolveModeDirWins, |
| +// both of which call resolveMode("")). |
| +func TestResolveModeConsoleFlagRefusedWithStoreEnv(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + envVar string |
| + }{ |
| + {"bucket", "CARLOS_DEPLOYMENT_BUCKET"}, |
| + {"dir", "CARLOS_DEPLOYMENT_DIR"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv(tc.envVar, "something") |
| + |
| + sess, err := resolveMode("https://a.example") |
| + if sess != nil { |
| + t.Fatalf("resolveMode with -console AND %s set = %+v, want nil session", tc.envVar, sess) |
| + } |
| + if err == nil { |
| + t.Fatalf("resolveMode with -console AND %s set must error", tc.envVar) |
| + } |
| + if !strings.Contains(err.Error(), "-console") { |
| + t.Fatalf("error %q does not mention -console", err) |
| + } |
| + if !strings.Contains(err.Error(), tc.envVar) { |
| + t.Fatalf("error %q does not mention %s", err, tc.envVar) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func newTestSession(base string) *apiSession { |
| return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"} |
| } |
| @@ -578,6 +618,59 @@ func loginTo(t *testing.T, origin, address string) { |
| } |
| } |
| |
| +// apiTestAccount is the -account value every API-mode verb test below uses. |
| +// Deliberately NOT sqid-shaped and not "ops": every prior round of these |
| +// tests happened to pass an -account value ("acct") that resolveAccount |
| +// would have resolved WITHOUT error had it wrongly been called (release. |
| +// ValidAccount("acct") happens to be true, so a stray resolveAccount call |
| +// would have silently "worked" and gone unnoticed). "vickys-team" fails |
| +// that same check, so a regression that starts calling acctOf()/ |
| +// resolveAccount in API mode again is loud (log.Fatal, or at minimum the |
| +// registry.db file requireNoRegistryTouch below watches for) rather than |
| +// silently passing. |
| +const apiTestAccount = "vickys-team" |
| + |
| +// requireNoRegistryTouch points CARLOS_DATA_DIR at a fresh, empty temp |
| +// directory and returns a func the caller runs AFTER the verb under test: |
| +// it fails the test if registry.db appeared there. accountFlags' resolver |
| +// (resolveAccount) is the only thing in this file that ever opens the |
| +// registry, and registry.Open creates the file on first open even if |
| +// resolution goes on to fail — so its presence is direct, mechanical proof |
| +// that API mode reached into the local registry, which it must never do |
| +// (there is no registry on a developer laptop to reach into). |
| +func requireNoRegistryTouch(t *testing.T) func() { |
| + t.Helper() |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DATA_DIR", dir) |
| + return func() { |
| + t.Helper() |
| + path := filepath.Join(dir, "registry.db") |
| + if _, err := os.Stat(path); err == nil { |
| + t.Fatalf("%s was created — API mode must never open the local registry", path) |
| + } else if !os.IsNotExist(err) { |
| + t.Fatalf("stat %s: %v", path, err) |
| + } |
| + } |
| +} |
| + |
| +// newShipFlagSet builds the -account/-registry flags runShipAPI's callers |
| +// register in production (accountFlags), parses account plus the given |
| +// positional file/dir args exactly as fs.Parse(os.Args) would, and returns |
| +// the FlagSet and the raw -account pointer runShipAPI expects — letting a |
| +// test drive runShipAPI directly (bypassing runShip's own flag set and, |
| +// crucially, its log.Fatal on error) so a refusal can be asserted as a |
| +// returned error instead of a process exit. |
| +func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string) { |
| + t.Helper() |
| + fs := flag.NewFlagSet("ship", flag.ContinueOnError) |
| + acctRaw, _ := accountFlags(fs) |
| + full := append([]string{"-account", account}, args...) |
| + if err := fs.Parse(full); err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + return fs, acctRaw |
| +} |
| + |
| // TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live |
| // credential and neither store env set, it must ship through the console |
| // (not openStore/release.Ship) and print the first-use "using <origin> as |
| @@ -587,10 +680,11 @@ func loginTo(t *testing.T, origin, address string) { |
| func TestShipVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| var reqCount int32 |
| mux := http.NewServeMux() |
| - mux.HandleFunc("POST /api/cli/apps/acct/titogo/releases", func(w http.ResponseWriter, r *http.Request) { |
| + mux.HandleFunc("POST /api/cli/apps/"+apiTestAccount+"/titogo/releases", func(w http.ResponseWriter, r *http.Request) { |
| atomic.AddInt32(&reqCount, 1) |
| mr, err := r.MultipartReader() |
| if err != nil { |
| @@ -626,7 +720,7 @@ func TestShipVerbAPIMode(t *testing.T) { |
| } |
| |
| buf := captureStdout(t) |
| - runShip([]string{"-app", "titogo", "-account", "acct", "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) |
| |
| out := buf.String() |
| if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| @@ -638,6 +732,7 @@ func TestShipVerbAPIMode(t *testing.T) { |
| if got := atomic.LoadInt32(&reqCount); got != 1 { |
| t.Fatalf("server got %d requests, want 1", got) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a |
| @@ -647,6 +742,7 @@ func TestShipVerbAPIMode(t *testing.T) { |
| func TestShipVerbAPIModeNoOp(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| mr, err := r.MultipartReader() |
| @@ -676,7 +772,7 @@ func TestShipVerbAPIModeNoOp(t *testing.T) { |
| } |
| |
| buf := captureStdout(t) |
| - runShip([]string{"-app", "titogo", "-account", "acct", "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) |
| |
| out := buf.String() |
| if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) { |
| @@ -685,6 +781,7 @@ func TestShipVerbAPIModeNoOp(t *testing.T) { |
| if strings.Contains(out, "shipped titogo abc1234\n") { |
| t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote |
| @@ -694,11 +791,12 @@ func TestShipVerbAPIModeNoOp(t *testing.T) { |
| func TestPromoteVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| var reqCount int32 |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| atomic.AddInt32(&reqCount, 1) |
| - if r.URL.Path != "/api/cli/apps/acct/titogo/promote" { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/promote" { |
| t.Errorf("path = %s", r.URL.Path) |
| } |
| var req struct{ Version, Channel string } |
| @@ -713,7 +811,7 @@ func TestPromoteVerbAPIMode(t *testing.T) { |
| loginTo(t, ts.URL, "vicky@keymail.dev") |
| |
| buf := captureStdout(t) |
| - runPromote([]string{"-app", "titogo", "-account", "acct", "abc1234", "canary/x"}) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) |
| |
| out := buf.String() |
| if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| @@ -725,6 +823,7 @@ func TestPromoteVerbAPIMode(t *testing.T) { |
| if got := atomic.LoadInt32(&reqCount); got != 1 { |
| t.Fatalf("server got %d requests, want 1", got) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode |
| @@ -734,6 +833,7 @@ func TestPromoteVerbAPIMode(t *testing.T) { |
| func TestPromoteVerbAPIModeNoOp(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| json.NewEncoder(w).Encode(map[string]any{"result": "noop"}) |
| @@ -743,12 +843,13 @@ func TestPromoteVerbAPIModeNoOp(t *testing.T) { |
| loginTo(t, ts.URL, "vicky@keymail.dev") |
| |
| buf := captureStdout(t) |
| - runPromote([]string{"-app", "titogo", "-account", "acct", "abc1234", "canary/x"}) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) |
| |
| out := buf.String() |
| if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) { |
| t.Fatalf("stdout = %q, want %q", out, want) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the |
| @@ -758,9 +859,10 @@ func TestPromoteVerbAPIModeNoOp(t *testing.T) { |
| func TestChannelsVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - if r.URL.Path != "/api/cli/apps/acct/titogo/channels" { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/channels" { |
| t.Errorf("path = %s", r.URL.Path) |
| } |
| json.NewEncoder(w).Encode(map[string]any{ |
| @@ -775,7 +877,7 @@ func TestChannelsVerbAPIMode(t *testing.T) { |
| loginTo(t, ts.URL, "vicky@keymail.dev") |
| |
| buf := captureStdout(t) |
| - runChannels([]string{"-app", "titogo", "-account", "acct"}) |
| + runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) |
| |
| out := buf.String() |
| if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| @@ -790,17 +892,19 @@ func TestChannelsVerbAPIMode(t *testing.T) { |
| if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") { |
| t.Fatalf("stdout = %q, want the broken row's ERROR text", out) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestRollbackVerbAPIMode pins runRollback's API-mode wiring. |
| func TestRollbackVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| |
| var reqCount int32 |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| atomic.AddInt32(&reqCount, 1) |
| - if r.URL.Path != "/api/cli/apps/acct/titogo/rollback" { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/rollback" { |
| t.Errorf("path = %s", r.URL.Path) |
| } |
| var req struct{ Channel string } |
| @@ -815,7 +919,7 @@ func TestRollbackVerbAPIMode(t *testing.T) { |
| loginTo(t, ts.URL, "vicky@keymail.dev") |
| |
| buf := captureStdout(t) |
| - runRollback([]string{"-app", "titogo", "-account", "acct", "canary/x"}) |
| + runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "canary/x"}) |
| |
| out := buf.String() |
| if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| @@ -827,6 +931,7 @@ func TestRollbackVerbAPIMode(t *testing.T) { |
| if got := atomic.LoadInt32(&reqCount); got != 1 { |
| t.Fatalf("server got %d requests, want 1", got) |
| } |
| + checkNoRegistry() |
| } |
| |
| // TestPromoteVerbDirectUnchanged pins the other half of resolveMode's |
| @@ -869,6 +974,13 @@ func TestPromoteVerbDirectUnchanged(t *testing.T) { |
| runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"}) |
| |
| out := buf.String() |
| + // Pins ship's direct-mode print format exactly (review round, MINOR 8): |
| + // unlike API mode's deliberately different "shipped <app> <version>" |
| + // (no count), direct mode still names the artifact count — this must |
| + // stay byte-identical to what it printed before Task 5 touched this verb. |
| + if want := "shipped demoapp abc1234 (1 artifacts)"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want the direct-mode shipped line", out) |
| + } |
| if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) { |
| t.Fatalf("stdout = %q, want the direct-mode promoted line", out) |
| } |
| @@ -876,3 +988,256 @@ func TestPromoteVerbDirectUnchanged(t *testing.T) { |
| t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got) |
| } |
| } |
| + |
| +// ---- Task 5 review round: runShipAPI's static/multi-file/pre-flight paths - |
| + |
| +// TestShipAPIStaticNamesSiteTarGz pins runShipAPI's static branch end to end |
| +// (review round, IMPORTANT 2 — previously zero coverage): the packed tarball |
| +// must reach the server in a part named EXACTLY "site.tar.gz", carrying the |
| +// same bytes edge.PackDir itself produces for the same directory — proof the |
| +// scratch-temp-file plumbing (PackDir -> temp file -> multipart part) does |
| +// not mangle anything in between. |
| +func TestShipAPIStaticNamesSiteTarGz(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("<h1>hi</h1>"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantBytes, err := edge.PackDir(siteDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var gotName string |
| + var gotBytes []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("not multipart: %v", err) |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatalf("part: %v", err) |
| + } |
| + b, err := io.ReadAll(part) |
| + if err != nil { |
| + t.Fatalf("read part: %v", err) |
| + } |
| + if part.FormName() != "meta" { |
| + gotName = part.FormName() |
| + gotBytes = b |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err != nil { |
| + t.Fatalf("runShipAPI: %v", err) |
| + } |
| + |
| + if gotName != "site.tar.gz" { |
| + t.Fatalf("server received artifact part named %q, want %q", gotName, "site.tar.gz") |
| + } |
| + if !bytes.Equal(gotBytes, wantBytes) { |
| + t.Fatalf("server received %d bytes, want the %d bytes edge.PackDir produced for the same directory", len(gotBytes), len(wantBytes)) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIStaticCleansUpTempFileOnServerError pins the temp-file-leak fix |
| +// (review round, MINOR 4): runShipAPI used to call log.Fatal on a failing |
| +// sess.ship, which calls os.Exit and skips every deferred function — |
| +// including the static kind's scratch temp file cleanup, up to 256MiB per |
| +// invocation. Returning an error instead (the caller log.Fatals) lets that |
| +// defer actually run. |
| +func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hi"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + io.Copy(io.Discard, r.Body) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + tmpParent := os.TempDir() |
| + before, err := os.ReadDir(tmpParent) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeNames := map[string]bool{} |
| + for _, e := range before { |
| + beforeNames[e.Name()] = true |
| + } |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err == nil { |
| + t.Fatal("runShipAPI against a failing server must error") |
| + } |
| + |
| + after, err := os.ReadDir(tmpParent) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range after { |
| + if beforeNames[e.Name()] { |
| + continue |
| + } |
| + if strings.HasPrefix(e.Name(), "carlos-ship-") { |
| + t.Fatalf("temp file %s leaked after a failed ship", e.Name()) |
| + } |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMultiFileGoodBasenames pins the multi-file binary ship path |
| +// (review round, IMPORTANT 2 + MINOR 7's good case): every basename shaped |
| +// like a GOOS-GOARCH target ships as its own part, named exactly its |
| +// basename. |
| +func TestShipAPIMultiFileGoodBasenames(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + dir := t.TempDir() |
| + fileArm := filepath.Join(dir, "linux-arm64") |
| + fileAmd := filepath.Join(dir, "linux-amd64") |
| + if err := os.WriteFile(fileArm, []byte("arm bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileAmd, []byte("amd bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var reqCount int32 |
| + gotParts := map[string]bool{} |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("not multipart: %v", err) |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatalf("part: %v", err) |
| + } |
| + io.Copy(io.Discard, part) |
| + if part.FormName() != "meta" { |
| + gotParts[part.FormName()] = true |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) |
| + if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil { |
| + t.Fatalf("runShipAPI: %v", err) |
| + } |
| + |
| + if !gotParts["linux-arm64"] || !gotParts["linux-amd64"] || len(gotParts) != 2 { |
| + t.Fatalf("server received parts %v, want exactly linux-arm64 and linux-amd64", gotParts) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMultiFileBadBasenameRefused pins the client-side name check |
| +// (review round, MINOR 7's bad case): a multi-file binary ship whose |
| +// basename doesn't fit the server's own GOOS-GOARCH shape (targetRe, |
| +// internal/console/apicli.go) must be refused before ever dialing the |
| +// console, naming the offending file. |
| +func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + dir := t.TempDir() |
| + fileGood := filepath.Join(dir, "linux-arm64") |
| + fileBad := filepath.Join(dir, "output.bin") |
| + if err := os.WriteFile(fileGood, []byte("arm bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileBad, []byte("bad bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) |
| + err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + if err == nil { |
| + t.Fatal("runShipAPI with a bad basename must error") |
| + } |
| + if !strings.Contains(err.Error(), "output.bin") { |
| + t.Fatalf("error %q does not name the offending file", err) |
| + } |
| + if !strings.Contains(err.Error(), "invalid artifact name") { |
| + t.Fatalf("error %q does not explain the refusal", err) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 0 { |
| + t.Fatalf("server got %d requests, want 0 — a bad basename must be refused before dialing the console", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMissingArtifactRefusedBeforeDialing pins the pre-flight stat |
| +// (review round, MINOR 6): a typo'd artifact path must fail clean before the |
| +// console is ever dialed, not after. |
| +func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + missing := filepath.Join(t.TempDir(), "linux-arm64") // never created |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, missing) |
| + err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + if err == nil { |
| + t.Fatal("runShipAPI with a missing artifact path must error") |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 0 { |
| + t.Fatalf("server got %d requests, want 0 — a missing path must be refused before dialing the console", got) |
| + } |
| + checkNoRegistry() |
| +} |