package main // release_test.go — the mirror-server REST API, against a recording stand-in. // // The three providers differ in path shape and field names, and those // differences are invisible until a real server rejects the request. A fake // server that records every request lets the differences be asserted directly — // and covers authUser/repoExists/createRepo, which had no test at all. import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // recordedReq is one request the fake provider received. type recordedReq struct { method, path, query string body map[string]any } // fakeProvider stands in for a Gitea/GitHub/GitLab API. routes maps // "METHOD /path" to a status code and JSON body; anything unrouted answers 404, // which is what the "does it exist?" probes expect. type fakeProvider struct { *httptest.Server got []recordedReq routes map[string]struct { code int body string } } func newFakeProvider(t *testing.T) *fakeProvider { t.Helper() f := &fakeProvider{routes: map[string]struct { code int body string }{}} f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec := recordedReq{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.RawQuery} if r.Body != nil { var m map[string]any json.NewDecoder(r.Body).Decode(&m) rec.body = m } f.got = append(f.got, rec) if route, ok := f.routes[r.Method+" "+r.URL.EscapedPath()]; ok { w.WriteHeader(route.code) w.Write([]byte(route.body)) return } w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"message":"Not Found"}`)) })) t.Cleanup(f.Close) return f } func (f *fakeProvider) route(methodPath string, code int, body string) { f.routes[methodPath] = struct { code int body string }{code, body} } // find returns the first recorded request for "METHOD /path", or nil. func (f *fakeProvider) find(methodPath string) *recordedReq { for i, r := range f.got { if r.method+" "+r.path == methodPath { return &f.got[i] } } return nil } func (f *fakeProvider) paths() []string { var out []string for _, r := range f.got { out = append(out, r.method+" "+r.path) } return out } // TestCreateReleasePerProvider pins down the three API dialects: where the // release collection lives, and what the notes field is called. func TestCreateReleasePerProvider(t *testing.T) { rel := release{Tag: "v1.2", Name: "v1.2", Body: "- did a thing", Prerelease: false} cases := []struct { typ string wantPath string bodyField string // provider's name for the notes }{ {"gitea", "/api/v1/repos/mike/mgsh/releases", "body"}, {"github", "/api/v3/repos/mike/mgsh/releases", "body"}, {"gitlab", "/api/v4/projects/mike%2Fmgsh/releases", "description"}, } for _, c := range cases { f := newFakeProvider(t) f.route("POST "+c.wantPath, 201, `{"id":7}`) api := newRemoteAPI(f.URL, "tok", c.typ) if err := api.createRelease("mike", "mgsh", rel); err != nil { t.Fatalf("%s: createRelease: %v (requests: %v)", c.typ, err, f.paths()) } req := f.find("POST " + c.wantPath) if req == nil { t.Fatalf("%s: expected POST %s, got %v", c.typ, c.wantPath, f.paths()) } if req.body["tag_name"] != "v1.2" || req.body["name"] != "v1.2" { t.Errorf("%s: body = %v, want tag_name/name v1.2", c.typ, req.body) } if req.body[c.bodyField] != "- did a thing" { t.Errorf("%s: notes not in %q: %v", c.typ, c.bodyField, req.body) } // GitLab has no pre-release flag; the others must carry it if _, ok := req.body["prerelease"]; ok != (c.typ != "gitlab") { t.Errorf("%s: prerelease presence = %v, want %v", c.typ, ok, c.typ != "gitlab") } } } // TestFindReleaseAndUpdate covers the existing-release path: how each provider // is asked whether a release for a tag exists, and how it is then rewritten. func TestFindReleaseAndUpdate(t *testing.T) { cases := []struct { typ string lookup, wantUpdate string wantMethod string }{ {"gitea", "/api/v1/repos/mike/mgsh/releases/tags/v1.2", "/api/v1/repos/mike/mgsh/releases/7", "PATCH"}, {"github", "/api/v3/repos/mike/mgsh/releases/tags/v1.2", "/api/v3/repos/mike/mgsh/releases/7", "PATCH"}, {"gitlab", "/api/v4/projects/mike%2Fmgsh/releases/v1.2", "/api/v4/projects/mike%2Fmgsh/releases/v1.2", "PUT"}, } for _, c := range cases { f := newFakeProvider(t) f.route("GET "+c.lookup, 200, `{"id":7,"tag_name":"v1.2"}`) f.route(c.wantMethod+" "+c.wantUpdate, 200, `{}`) api := newRemoteAPI(f.URL, "tok", c.typ) id, found, err := api.findRelease("mike", "mgsh", "v1.2") if err != nil || !found { t.Fatalf("%s: findRelease = %q,%v,%v", c.typ, id, found, err) } if err := api.updateRelease("mike", "mgsh", id, release{Tag: "v1.2", Name: "v1.2", Body: "new"}); err != nil { t.Fatalf("%s: updateRelease: %v (requests: %v)", c.typ, err, f.paths()) } req := f.find(c.wantMethod + " " + c.wantUpdate) if req == nil { t.Fatalf("%s: expected %s %s, got %v", c.typ, c.wantMethod, c.wantUpdate, f.paths()) } // an update must not try to move the tag if _, ok := req.body["tag_name"]; ok { t.Errorf("%s: update sent tag_name: %v", c.typ, req.body) } } } // TestFindReleaseMissing: an absent release is "not found", not an error — that // distinction decides between create and update. func TestFindReleaseMissing(t *testing.T) { f := newFakeProvider(t) // everything 404s api := newRemoteAPI(f.URL, "tok", "gitea") id, found, err := api.findRelease("mike", "mgsh", "v9") if err != nil || found || id != "" { t.Fatalf("findRelease on empty server = %q,%v,%v", id, found, err) } } // TestFindReleaseServerError: anything else must surface, so a broken server is // never mistaken for "no release yet" and silently re-created. func TestFindReleaseServerError(t *testing.T) { f := newFakeProvider(t) f.route("GET /api/v1/repos/mike/mgsh/releases/tags/v1", 500, `{"message":"boom"}`) api := newRemoteAPI(f.URL, "tok", "gitea") if _, found, err := api.findRelease("mike", "mgsh", "v1"); err == nil || found { t.Fatalf("a 500 must be an error, got found=%v err=%v", found, err) } } // TestAuthUserAndRepoExists covers the calls pushremote has always made and // that had no test: who the token belongs to, and whether the repo is there. func TestAuthUserAndRepoExists(t *testing.T) { // Gitea/GitHub report "login", GitLab "username" for _, c := range []struct{ typ, root, field string }{ {"gitea", "/api/v1", "login"}, {"gitlab", "/api/v4", "username"}, } { f := newFakeProvider(t) f.route("GET "+c.root+"/user", 200, `{"`+c.field+`":"mike"}`) api := newRemoteAPI(f.URL, "tok", c.typ) owner, err := api.authUser() if err != nil || owner != "mike" { t.Fatalf("%s: authUser = %q, %v", c.typ, owner, err) } } f := newFakeProvider(t) f.route("GET /api/v1/repos/mike/mgsh", 200, `{}`) api := newRemoteAPI(f.URL, "tok", "gitea") if ok, err := api.repoExists("mike", "mgsh"); err != nil || !ok { t.Errorf("repoExists(existing) = %v, %v", ok, err) } if ok, err := api.repoExists("mike", "gone"); err != nil || ok { t.Errorf("repoExists(missing) = %v, %v, want false,nil", ok, err) } // a bad token must be reported, not treated as "no such user" bad := newFakeProvider(t) bad.route("GET /api/v1/user", 401, `{"message":"token required"}`) if _, err := newRemoteAPI(bad.URL, "tok", "gitea").authUser(); err == nil { t.Error("authUser accepted a 401") } } // TestAuthHeaderReachesTheServer: each provider gets its own header form. func TestAuthHeaderReachesTheServer(t *testing.T) { for _, c := range []struct{ typ, header, value string }{ {"gitea", "Authorization", "token s3cret"}, {"github", "Authorization", "Bearer s3cret"}, {"gitlab", "PRIVATE-TOKEN", "s3cret"}, } { var got http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Clone() w.Write([]byte(`{"login":"mike","username":"mike"}`)) })) newRemoteAPI(srv.URL, "s3cret", c.typ).authUser() srv.Close() if got.Get(c.header) != c.value { t.Errorf("%s: %s = %q, want %q", c.typ, c.header, got.Get(c.header), c.value) } } } func TestPreReleaseDetection(t *testing.T) { pre := []string{"v1.0-rc1", "v1.0-RC.2", "v2-beta", "v2-alpha3", "1.0-pre"} final := []string{"v1.0", "v1.0.1", "2026-01-17", "v1.0-final", "release-1"} for _, s := range pre { if !preReleaseRe.MatchString(s) { t.Errorf("%q should be a pre-release", s) } } for _, s := range final { if preReleaseRe.MatchString(s) { t.Errorf("%q should not be a pre-release", s) } } } // TestReleaseNotesFromLog: without an annotation, the notes are the commits // since the previous tag — not the whole history. func TestReleaseNotesFromLog(t *testing.T) { dir := t.TempDir() mustGit(t, dir, "init", "-q") mustGit(t, dir, "config", "user.name", "t") mustGit(t, dir, "config", "user.email", "t@e") commit := func(msg string) { if err := os.WriteFile(filepath.Join(dir, "f"), []byte(msg), 0644); err != nil { t.Fatal(err) } mustGit(t, dir, "add", ".") mustGit(t, dir, "commit", "-q", "-m", msg) } commit("old work") mustGit(t, dir, "tag", "-a", "v1.0", "-m", "v1.0") // annotation == tag name commit("fix the thing") commit("add the other thing") mustGit(t, dir, "tag", "-a", "v1.1", "-m", "v1.1") notes := releaseNotes(dir, "v1.1") if !strings.Contains(notes, "- fix the thing") || !strings.Contains(notes, "- add the other thing") { t.Errorf("notes missing the new commits: %q", notes) } if strings.Contains(notes, "old work") { t.Errorf("notes reach back past the previous tag: %q", notes) } // the default annotation (= the tag name) must not become the body if strings.TrimSpace(notes) == "v1.1" { t.Errorf("notes fell back to the placeholder annotation: %q", notes) } // a real annotation wins over the log mustGit(t, dir, "tag", "-a", "v1.2", "-m", "Handpicked notes") if got := releaseNotes(dir, "v1.2"); got != "Handpicked notes" { t.Errorf("annotated notes = %q, want %q", got, "Handpicked notes") } } // TestEnsureTagRefusesToMoveATag: a published tag that would move is the one // case where releasing must stop. func TestEnsureTagRefusesToMoveATag(t *testing.T) { dir := t.TempDir() mustGit(t, dir, "init", "-q") mustGit(t, dir, "config", "user.name", "t") mustGit(t, dir, "config", "user.email", "t@e") mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "one") mustGit(t, dir, "tag", "-a", "v1", "-m", "v1") if !ensureTag(dir, "v1", "") { t.Error("re-publishing the same commit should be allowed") } if !ensureTag(dir, "v2", "notes here") { t.Fatal("ensureTag did not create a new tag") } if out, _ := gitCapture(dir, "tag", "-l", "--format=%(contents:subject)", "v2"); strings.TrimSpace(out) != "notes here" { t.Errorf("new tag annotation = %q, want %q", strings.TrimSpace(out), "notes here") } mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "two") if ensureTag(dir, "v1", "") { t.Error("ensureTag moved a tag that already points at another commit") } } // TestReleaseTagsAndPushesToOrigin drives the local half of `release` against a // real bare origin: the tag is created with the given notes as its annotation // and lands on the internal server, before any mirror is involved. func TestReleaseTagsAndPushesToOrigin(t *testing.T) { dir := useProject(t, "proj") bare := filepath.Join(t.TempDir(), "origin.git") mustGit(t, "", "init", "--bare", "-q", bare) mustGit(t, dir, "init", "-q") mustGit(t, dir, "config", "user.name", "t") mustGit(t, dir, "config", "user.email", "t@e") mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "work") mustGit(t, dir, "remote", "add", "origin", bare) mustGit(t, dir, "push", "-q", "-u", "origin", "HEAD") cfg.Remotes = nil // no mirror targets: stop after the internal server runCommand("release v1.0 first public build") out, err := gitCapture(bare, "tag", "-l", "--format=%(refname:short) %(contents:subject)") if err != nil { t.Fatal(err) } if got := strings.TrimSpace(out); got != "v1.0 first public build" { t.Errorf("tag on origin = %q, want %q", got, "v1.0 first public build") } }