package main // The publisher is tested against httptest, never against an instance: a test // that needs a server somewhere is a test nobody runs, and this is the one tool // in the tree whose mistakes are visible to everybody who downloads a binary. // // Every fixture points CLAUDE_PROJECT_DIR at an empty temp directory — no // `.kettle/` marker anywhere on the way up, which is the state a fresh clone is // in and the whole reason this tool resolves its configuration the way it does // — and KETTLE_CONFIG_HOME at another, so a run can neither read nor overwrite // the developer's own tokens. // // THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a // request: the SDK asks the instance what it is before it hands a client back, // and a fake that did not answer is a fake nothing can be built against. import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "sync" "testing" sdk "code.gitea.io/sdk/gitea" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" ) // modernGitea is what the fake says it is: new enough for every route this // tool asks for. const modernGitea = "1.26.1" // harmless points every fixture away from the machine it runs on. func harmless(t *testing.T) { t.Helper() dir := t.TempDir() t.Setenv("CLAUDE_PROJECT_DIR", dir) t.Setenv(config.EnvHome, filepath.Join(dir, "config")) // An exported KETTLE_URL in the developer's shell would otherwise decide // what a test resolved to, and one of these tests is about resolving // nothing at all. for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} { t.Setenv(key, "") } } func configFor(url string) *config.Resolved { return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"} } // -------------------------------------------------------------------------- // the fake tracker // -------------------------------------------------------------------------- type fake struct { mu sync.Mutex base string version string nextID int64 releases []*sdk.Release assets map[int64][]*sdk.Attachment content map[int64][]byte requests []string // hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft, // which is what an instance does when the tag itself is not in git yet. hideDraftsFromTheTagRoute bool } func newFake(t *testing.T) *fake { t.Helper() f := &fake{ version: modernGitea, assets: map[int64][]*sdk.Attachment{}, content: map[int64][]byte{}, } srv := httptest.NewServer(f) t.Cleanup(srv.Close) f.base = srv.URL return f } func (f *fake) url() string { return f.base } func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.requests = append(f.requests, r.Method+" "+r.URL.Path) if r.URL.Path == "/api/v1/version" { writeJSON(w, map[string]string{"version": f.version}) return } rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases") if !ok { f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path) return } var parts []string if rest = strings.Trim(rest, "/"); rest != "" { parts = strings.Split(rest, "/") } switch { case len(parts) == 0 && r.Method == http.MethodGet: f.list(w, r) case len(parts) == 0 && r.Method == http.MethodPost: f.create(w, r) case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet: f.byTag(w, parts[1]) case len(parts) == 1 && r.Method == http.MethodPatch: f.edit(w, r, parts[0]) case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet: f.listAssets(w, parts[0]) case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost: f.addAsset(w, r, parts[0]) case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete: f.dropAsset(w, parts[0], parts[2]) default: f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path) } } func (f *fake) list(w http.ResponseWriter, r *http.Request) { if page := r.URL.Query().Get("page"); page != "" && page != "1" { writeJSON(w, []*sdk.Release{}) return } writeJSON(w, f.releases) } func (f *fake) create(w http.ResponseWriter, r *http.Request) { var opt sdk.CreateReleaseOption if err := json.NewDecoder(r.Body).Decode(&opt); err != nil { f.refuse(w, http.StatusUnprocessableEntity, err.Error()) return } f.nextID++ rel := &sdk.Release{ ID: f.nextID, TagName: opt.TagName, Target: opt.Target, Title: opt.Title, Note: opt.Note, IsDraft: opt.IsDraft, IsPrerelease: opt.IsPrerelease, HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName, } f.releases = append(f.releases, rel) w.WriteHeader(http.StatusCreated) writeJSON(w, rel) } func (f *fake) byTag(w http.ResponseWriter, tag string) { for _, rel := range f.releases { if rel.TagName != tag { continue } if rel.IsDraft && f.hideDraftsFromTheTagRoute { break } writeJSON(w, rel) return } f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found") } func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) { rel := f.release(id) if rel == nil { f.refuse(w, http.StatusNotFound, "no release "+id) return } var opt sdk.EditReleaseOption if err := json.NewDecoder(r.Body).Decode(&opt); err != nil { f.refuse(w, http.StatusUnprocessableEntity, err.Error()) return } // Gitea's own semantics: an empty string leaves the field alone. if opt.Title != "" { rel.Title = opt.Title } if opt.Note != "" { rel.Note = opt.Note } if opt.IsDraft != nil { rel.IsDraft = *opt.IsDraft } if opt.IsPrerelease != nil { rel.IsPrerelease = *opt.IsPrerelease } writeJSON(w, rel) } func (f *fake) listAssets(w http.ResponseWriter, id string) { rel := f.release(id) if rel == nil { f.refuse(w, http.StatusNotFound, "no release "+id) return } got := f.assets[rel.ID] if got == nil { got = []*sdk.Attachment{} } writeJSON(w, got) } func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) { rel := f.release(id) if rel == nil { f.refuse(w, http.StatusNotFound, "no release "+id) return } file, header, err := r.FormFile("attachment") if err != nil { f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error()) return } defer file.Close() raw, err := io.ReadAll(file) if err != nil { f.refuse(w, http.StatusInternalServerError, err.Error()) return } f.nextID++ a := &sdk.Attachment{ ID: f.nextID, Name: header.Filename, Size: int64(len(raw)), DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename, } f.assets[rel.ID] = append(f.assets[rel.ID], a) f.content[a.ID] = raw w.WriteHeader(http.StatusCreated) writeJSON(w, a) } func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) { rel := f.release(id) if rel == nil { f.refuse(w, http.StatusNotFound, "no release "+id) return } want, _ := strconv.ParseInt(asset, 10, 64) kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID])) for _, a := range f.assets[rel.ID] { if a.ID != want { kept = append(kept, a) } } f.assets[rel.ID] = kept w.WriteHeader(http.StatusNoContent) } func (f *fake) release(id string) *sdk.Release { want, err := strconv.ParseInt(id, 10, 64) if err != nil { return nil } for _, rel := range f.releases { if rel.ID == want { return rel } } return nil } func (f *fake) refuse(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"message": message}) } // assetNamed is what the tracker holds under this name, for the test that says // a replacement leaves exactly one. func (f *fake) assetNamed(name string) []*sdk.Attachment { f.mu.Lock() defer f.mu.Unlock() var out []*sdk.Attachment for _, batch := range f.assets { for _, a := range batch { if a.Name == name { out = append(out, a) } } } return out } func (f *fake) bytesOf(a *sdk.Attachment) string { f.mu.Lock() defer f.mu.Unlock() return string(f.content[a.ID]) } func (f *fake) calls() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string{}, f.requests...) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func writeFile(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } // -------------------------------------------------------------------------- // the tests // -------------------------------------------------------------------------- // The ordinary case: a tag nobody has published yet, and two files that belong // on it. func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) { harmless(t) f := newFake(t) dir := t.TempDir() binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly") sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n") got, err := publish(configFor(f.url()), spec{ Tag: "v1.2.3", Notes: "what changed\n", Files: []string{binary, sums}, }) if err != nil { t.Fatalf("publish: %v", err) } if got.State != "created" { t.Errorf("state is %q, want created", got.State) } if len(f.releases) != 1 { t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases)) } rel := f.releases[0] if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" { t.Errorf("the release is %+v", rel) } // Gitea refuses a release with no title, so the tag stands in for one. if rel.Title != "v1.2.3" { t.Errorf("title is %q, want the tag", rel.Title) } if len(got.Assets) != 2 { t.Fatalf("got %d asset(s), want 2", len(got.Assets)) } for name, want := range map[string]string{ "kettle_v1.2.3_linux_amd64": "a binary, honestly", "SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n", } { held := f.assetNamed(name) if len(held) != 1 { t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name) } if body := f.bytesOf(held[0]); body != want { t.Errorf("%s arrived as %q, want %q", name, body, want) } } // The receipt is the whole user experience of a tool nobody watches run. var out strings.Builder got.print(&out) for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL, "kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} { if !strings.Contains(out.String(), want) { t.Errorf("the receipt does not name %q:\n%s", want, out.String()) } } } // A re-run is not a failure and not a second release. It is also not a no-op // when something changed: a retry that fixed the notes has to leave the fixed // notes behind. func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) { harmless(t) f := newFake(t) dir := t.TempDir() binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one") first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}}) if err != nil { t.Fatalf("the first publish: %v", err) } again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}}) if err != nil { t.Fatalf("the second publish: %v", err) } if len(f.releases) != 1 { t.Fatalf("a re-run left %d releases for one tag", len(f.releases)) } if again.State != "reused" { t.Errorf("state is %q, want reused — nothing had changed", again.State) } if again.Release.ID != first.Release.ID { t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID) } if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 { t.Errorf("the tracker holds %d copies of the one asset", len(held)) } // And the corrected notes actually land. fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}}) if err != nil { t.Fatalf("the third publish: %v", err) } if fixed.State != "updated" { t.Errorf("state is %q, want updated — the notes changed", fixed.State) } if f.releases[0].Note != "second go" { t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note) } if len(f.releases) != 1 { t.Errorf("converging forked the release: %d of them", len(f.releases)) } } // Two attachments with one name is the silent failure: the download URL names // the file, so the second copy is not addressable and nobody notices which one // people got. func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) { harmless(t) f := newFake(t) dir := t.TempDir() path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build") if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil { t.Fatalf("the first publish: %v", err) } // Same name, different bytes — a rebuild after a fix, which is exactly when // somebody re-runs this. writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build") got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}) if err != nil { t.Fatalf("the second publish: %v", err) } held := f.assetNamed("kettle_v3.0.0_linux_arm64") if len(held) != 1 { t.Fatalf("the release carries %d attachments of that name, want 1", len(held)) } if body := f.bytesOf(held[0]); body != "the second build" { t.Errorf("the asset is %q — the replacement did not take", body) } if len(got.Assets) != 1 || !got.Assets[0].Replaced { t.Errorf("the receipt does not report a replacement: %+v", got.Assets) } var out strings.Builder got.print(&out) if !strings.Contains(out.String(), "replaced") { t.Errorf("the receipt does not say it replaced anything:\n%s", out.String()) } } // A draft has no git tag behind it, so the by-tag route can answer 404 for a // release that is plainly there. A publish that believed it would file a second // release every time it was retried. func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) { harmless(t) f := newFake(t) f.hideDraftsFromTheTagRoute = true s := spec{Tag: "v4.0.0", Draft: true} if _, err := publish(configFor(f.url()), s); err != nil { t.Fatalf("the first publish: %v", err) } again, err := publish(configFor(f.url()), s) if err != nil { t.Fatalf("the second publish: %v", err) } if len(f.releases) != 1 { t.Fatalf("a retried draft published %d releases for one tag", len(f.releases)) } if again.State != "reused" { t.Errorf("state is %q, want reused", again.State) } if !f.releases[0].IsDraft { t.Error("the release stopped being a draft") } } // A half-filled configuration is refused before anything is dialled, naming the // variable or the command that supplies what is missing. "401 Unauthorized" // names nothing anybody can act on. func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) { harmless(t) f := newFake(t) for _, tc := range []struct { what string cfg *config.Resolved want string }{ {"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL}, {"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken}, {"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo}, {"nothing at all", &config.Resolved{}, config.EnvURL}, } { _, err := publish(tc.cfg, spec{Tag: "v0.0.1"}) if err == nil { t.Errorf("%s: accepted", tc.what) continue } if !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err) } } if calls := f.calls(); len(calls) != 0 { t.Errorf("a request went out for a configuration that was refused: %v", calls) } } // End to end through main's own argument handling, with the credentials in the // environment and no project anywhere on the way up — which is the state a // clone is in, and the reason this resolves configuration the way it does. func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) { harmless(t) f := newFake(t) dir := t.TempDir() binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me") notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n") t.Setenv(config.EnvURL, f.url()) t.Setenv(config.EnvToken, "s3cret") t.Setenv(config.EnvRepo, "acme/widgets") var stdout, stderr strings.Builder code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary}, &stdout, &stderr) if code != 0 { t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) } if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" { t.Fatalf("the tracker holds %+v", f.releases) } if !strings.Contains(f.releases[0].Note, "It does the thing.") { t.Errorf("the notes file did not arrive: %q", f.releases[0].Note) } for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} { if !strings.Contains(stdout.String(), want) { t.Errorf("the receipt does not name %q:\n%s", want, stdout.String()) } } // A token in a receipt is a token in a terminal scrollback and a pasted // bug report. if strings.Contains(stdout.String()+stderr.String(), "s3cret") { t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String()) } } // Everything a person can get wrong in the arguments is reported before a // release exists to be half-published. func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) { harmless(t) f := newFake(t) dir := t.TempDir() here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x") elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64") if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil { t.Fatal(err) } t.Setenv(config.EnvURL, f.url()) t.Setenv(config.EnvToken, "s3cret") t.Setenv(config.EnvRepo, "acme/widgets") for _, tc := range []struct { what string argv []string want string }{ {"no tag", []string{here}, "--tag is required"}, {"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"}, {"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"}, {"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"}, {"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"}, } { var stdout, stderr strings.Builder if code := run(tc.argv, &stdout, &stderr); code != 2 { t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), tc.want) { t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String()) } } if calls := f.calls(); len(calls) != 0 { t.Errorf("a refused run still talked to the tracker: %v", calls) } if len(f.releases) != 0 { t.Errorf("a refused run created %d release(s)", len(f.releases)) } } // A failure carries the status and what the server said, in the transport's own // error type, because "500" on its own has never helped anybody. func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) { harmless(t) // A token that is not allowed to write releases is the failure somebody // will actually meet: reads are fine, the create is refused. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/v1/version": writeJSON(w, map[string]string{"version": modernGitea}) case r.Method == http.MethodPost: w.WriteHeader(http.StatusForbidden) _, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`) case strings.Contains(r.URL.Path, "/releases/tags/"): w.WriteHeader(http.StatusNotFound) _, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`) default: writeJSON(w, []*sdk.Release{}) } })) t.Cleanup(srv.Close) _, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"}) if err == nil { t.Fatal("a 403 published a release") } for _, want := range []string{"403", "write:repository", "v7.0.0"} { if !strings.Contains(err.Error(), want) { t.Errorf("the failure does not mention %q:\n%v", want, err) } } if strings.Contains(err.Error(), "s3cret") { t.Errorf("the failure quotes the token:\n%v", err) } } // A tool nobody watches run has to be readable when somebody finally does. func TestTheReceiptIsAligned(t *testing.T) { r := &receipt{ Repo: "acme/widgets", State: "created", Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"}, Assets: []asset{ {Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"}, {Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true}, }, } var out strings.Builder r.print(&out) // One line for the release, one per asset, the URL, and the summary. lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") if len(lines) != 5 { t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String()) } // The URLs line up, which is what makes a column of them scannable. first := strings.Index(lines[1], "https://") if second := strings.Index(lines[2], "https://"); first != second { t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String()) } if !strings.HasPrefix(lines[2], "replaced") { t.Errorf("a replaced asset is not called one:\n%s", out.String()) } // The URL a person opens is on its own line, not buried in a summary. if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") { t.Errorf("the release URL is not on its own line:\n%s", out.String()) } if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) { t.Errorf("the summary does not read %q:\n%s", want, out.String()) } }