package main // assets_test.go — attaching files to a release. // // The three providers upload assets in three unrelated ways, and the difference // is invisible until a real server rejects the request. The recording stand-in // lets each one be pinned down: where the bytes go, how they are wrapped, and // what else has to happen around them. import ( "os" "path/filepath" "strings" "testing" ) // assetTree builds a project with ./bin and ./assets and returns its path. func assetTree(t *testing.T, files map[string]string) string { t.Helper() dir := t.TempDir() for rel, content := range files { p := filepath.Join(dir, rel) if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0755); err != nil { t.Fatal(err) } } return dir } func TestCollectAssets(t *testing.T) { dir := assetTree(t, map[string]string{ "bin/mgsh-linux-amd64": "ELF...", "bin/mgsh-darwin-arm64": "MACH...", "assets/logo.png": "PNG", "assets/notes.txt": "text", "bin/sub/nested": "not descended into", "src/main.go": "not an asset directory", }) // bin/mgsh is a symlink to one of its siblings, as build.sh leaves it if err := os.Symlink("mgsh-linux-amd64", filepath.Join(dir, "bin", "mgsh")); err != nil { t.Fatal(err) } assets, skipped := collectAssets(dir) var names []string for _, a := range assets { names = append(names, a.name) } want := "logo.png,mgsh-darwin-arm64,mgsh-linux-amd64,notes.txt" if strings.Join(names, ",") != want { t.Errorf("collected %v, want %s", names, want) } if len(skipped) != 0 { t.Errorf("skipped = %v, want none", skipped) } for _, a := range assets { if a.size == 0 { t.Errorf("%s has no size", a.name) } if _, err := os.Stat(a.path); err != nil { t.Errorf("%s: %v", a.name, err) } } // a project with neither directory contributes nothing at all if got, _ := collectAssets(t.TempDir()); len(got) != 0 { t.Errorf("a project without bin/ or assets/ produced %v", got) } } // TestCollectAssetsNameCollision: one asset name can only mean one file, so the // second directory's copy is reported rather than silently overwriting. func TestCollectAssetsNameCollision(t *testing.T) { dir := assetTree(t, map[string]string{ "bin/tool": "the binary", "assets/tool": "something else with the same name", }) assets, skipped := collectAssets(dir) if len(assets) != 1 || assets[0].name != "tool" || !strings.HasSuffix(assets[0].path, "bin/tool") { t.Errorf("assets = %+v, want only bin/tool", assets) } if len(skipped) != 1 || !strings.Contains(skipped[0], "tool") { t.Errorf("skipped = %v, want the assets/ copy", skipped) } } // TestUploadAssetGitea: a multipart POST under the field name Gitea expects, // with the asset name in the query. func TestUploadAssetGitea(t *testing.T) { dir := assetTree(t, map[string]string{"bin/mgsh-linux-amd64": "binary-bytes-here"}) assets, _ := collectAssets(dir) f := newFakeProvider(t) f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`) f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`) api := newRemoteAPI(f.URL, "tok", "gitea") ref := releaseRef{tag: "v1.0", id: "7"} if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil { t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths()) } req := f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets") if req == nil { t.Fatalf("no upload, requests: %v", f.paths()) } if !strings.Contains(req.query, "name=mgsh-linux-amd64") { t.Errorf("query = %q, want the asset name", req.query) } if !strings.HasPrefix(req.ctype, "multipart/form-data") { t.Errorf("content type = %q, want multipart", req.ctype) } body := string(req.raw) if !strings.Contains(body, `name="attachment"`) { t.Errorf("form field is not 'attachment': %q", body) } if !strings.Contains(body, "binary-bytes-here") { t.Errorf("the file contents did not make it: %q", body) } } // TestUploadAssetGitHub: the bytes go raw to the host the release object named, // not to the API host. func TestUploadAssetGitHub(t *testing.T) { dir := assetTree(t, map[string]string{"bin/tool": "raw-bytes"}) assets, _ := collectAssets(dir) f := newFakeProvider(t) f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`) f.route("POST /uploads/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`) api := newRemoteAPI(f.URL, "tok", "github") // the template suffix from the release object has to be stripped ref := api.parseReleaseRef("v1.0", []byte(`{"id":7,"upload_url":"`+ f.URL+`/uploads/repos/mike/mgsh/releases/7/assets{?name,label}"}`)) if ref.id != "7" || strings.Contains(ref.uploadURL, "{") { t.Fatalf("release ref = %+v, want id 7 and a bare upload url", ref) } if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil { t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths()) } req := f.find("POST /uploads/repos/mike/mgsh/releases/7/assets") if req == nil { t.Fatalf("no upload to the upload host, requests: %v", f.paths()) } if string(req.raw) != "raw-bytes" { t.Errorf("body = %q, want the file verbatim", req.raw) } if !strings.Contains(req.query, "name=tool") { t.Errorf("query = %q, want the asset name", req.query) } } // TestUploadAssetGitHubWithoutUploadURL: without one there is nowhere to put // the bytes, and that has to be said rather than guessed at. func TestUploadAssetGitHubWithoutUploadURL(t *testing.T) { dir := assetTree(t, map[string]string{"bin/tool": "x"}) assets, _ := collectAssets(dir) f := newFakeProvider(t) f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`) api := newRemoteAPI(f.URL, "tok", "github") err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0]) if err == nil || !strings.Contains(err.Error(), "upload_url") { t.Errorf("error = %v, want it to name the missing upload_url", err) } } // TestUploadAssetGitLab: a GitLab release stores links, not files, so the file // goes into the generic package registry first and the release then points at // it. func TestUploadAssetGitLab(t *testing.T) { dir := assetTree(t, map[string]string{"bin/tool": "package-bytes"}) assets, _ := collectAssets(dir) const pkg = "/api/v4/projects/mike%2Fmgsh/packages/generic/mgsh/v1.0/tool" const links = "/api/v4/projects/mike%2Fmgsh/releases/v1.0/assets/links" f := newFakeProvider(t) f.route("GET "+links, 200, `[]`) f.route("PUT "+pkg, 201, `{"message":"201 Created"}`) f.route("POST "+links, 201, `{"id":1}`) api := newRemoteAPI(f.URL, "tok", "gitlab") if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1.0", id: "v1.0"}, assets[0]); err != nil { t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths()) } up := f.find("PUT " + pkg) if up == nil { t.Fatalf("no package upload, requests: %v", f.paths()) } if string(up.raw) != "package-bytes" { t.Errorf("package body = %q, want the file verbatim", up.raw) } link := f.find("POST " + links) if link == nil { t.Fatalf("the release was not linked to the package, requests: %v", f.paths()) } if link.body["name"] != "tool" || link.body["link_type"] != "package" { t.Errorf("link body = %v", link.body) } if u, _ := link.body["url"].(string); !strings.HasSuffix(u, pkg) { t.Errorf("link url = %q, want it to point at the package", u) } } // TestUploadAssetReplacesPrevious: re-releasing after a rebuild has to replace // the old file, not fail on it or leave two of each. func TestUploadAssetReplacesPrevious(t *testing.T) { dir := assetTree(t, map[string]string{"bin/tool": "v2"}) assets, _ := collectAssets(dir) f := newFakeProvider(t) f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[{"id":41,"name":"other"},{"id":42,"name":"tool"}]`) f.route("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42", 204, ``) f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":43}`) api := newRemoteAPI(f.URL, "tok", "gitea") if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0]); err != nil { t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths()) } if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42") == nil { t.Errorf("the previous asset was not removed: %v", f.paths()) } // only the one with the matching name if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/41") != nil { t.Errorf("an unrelated asset was deleted: %v", f.paths()) } if f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets") == nil { t.Errorf("the replacement was not uploaded: %v", f.paths()) } } // TestUploadAssetsReportsFailures: one bad file must not stop the others, and // the caller has to hear about it. func TestUploadAssetsReportsFailures(t *testing.T) { dir := assetTree(t, map[string]string{"bin/good": "ok", "bin/bad": "boom"}) assets, _ := collectAssets(dir) f := newFakeProvider(t) f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`) // the fake answers 404 for anything unrouted, so the POST fails api := newRemoteAPI(f.URL, "tok", "gitea") out := captureStdout(t, func() { if err := api.uploadAssets("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets); err == nil { t.Error("failed uploads were not reported") } }) // both were attempted, and each was announced before it started for _, name := range []string{"good", "bad"} { if !strings.Contains(out, name) { t.Errorf("%s was not attempted or not announced:\n%s", name, out) } } } func TestAssetSummaryAndDirList(t *testing.T) { dir := assetTree(t, map[string]string{ "bin/a": strings.Repeat("x", 1024), "assets/b": strings.Repeat("y", 512), "src/main.go": "not counted", }) assets, _ := collectAssets(dir) if got := assetSummary(assets); got != "2 assets, 1.5K" { t.Errorf("assetSummary = %q, want \"2 assets, 1.5K\"", got) } if got := strings.Join(assetDirList(dir), ","); got != "./bin,./assets" { t.Errorf("assetDirList = %q", got) } // singular reads properly, and a project without the directories lists none if got := assetSummary(assets[:1]); !strings.HasPrefix(got, "1 asset,") { t.Errorf("assetSummary(one) = %q", got) } if got := assetDirList(t.TempDir()); len(got) != 0 { t.Errorf("assetDirList of a bare project = %v", got) } }