package main import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "sort" "strings" "sync" "testing" "time" ) // fakeForge answers the forge's interface from maps, and records every delete. type fakeForge struct { t *testing.T versions map[string][]string // package -> versions tags map[string]map[string]string // package -> dist-tags repos map[string]map[string]string // repository -> path -> content broken map[string]bool // repositories whose tree answers 500 files map[string][]int64 // package@version -> its files' sizes (default 1 MiB + 1 KiB) noSize map[string]bool // package@version whose files answer 500 mu sync.Mutex deleted []string password string } func (f *fakeForge) serve() *Forge { srv := httptest.NewServer(http.HandlerFunc(f.handle)) f.t.Cleanup(srv.Close) file := filepath.Join(f.t.TempDir(), "admin.secret") if err := os.WriteFile(file, []byte(f.password+"\n"), 0o600); err != nil { f.t.Fatal(err) } return &Forge{URL: srv.URL, User: "admin", PasswordFile: file, Owner: "acme"} } var ( versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/npm/([^/]+)/([^/]+)(/files)?$`) treePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/git/trees/`) rawPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/raw/(.+)$`) ) func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok || user != "admin" || pass != f.password { http.Error(w, "unauthorised", http.StatusUnauthorized) return } p := r.URL.EscapedPath() q := r.URL.Query() page := 1 fmt.Sscan(q.Get("page"), &page) switch { case p == "/api/v1/packages/acme" && r.Method == http.MethodGet: var all []map[string]any names := keys(f.versions) for _, n := range names { for i, v := range f.versions[n] { all = append(all, map[string]any{"name": n, "version": v, "type": "npm", "created_at": time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)}) } } writeJSON(w, pageOf(all, page, pageSize)) case strings.HasPrefix(p, "/api/packages/acme/npm/"): name, _ := url.PathUnescape(strings.TrimPrefix(p, "/api/packages/acme/npm/")) writeJSON(w, map[string]any{"name": name, "dist-tags": f.tags[name]}) case versionFiles.MatchString(p): m := versionFiles.FindStringSubmatch(p) name, _ := url.PathUnescape(m[1]) version, _ := url.PathUnescape(m[2]) if m[3] != "" { if f.noSize[name+"@"+version] { http.Error(w, "storage unavailable", http.StatusInternalServerError) return } sizes, has := f.files[name+"@"+version] if !has { sizes = []int64{1 << 20, 1024} } var files []map[string]any for i, n := range sizes { files = append(files, map[string]any{"id": i + 1, "size": n, "name": fmt.Sprintf("f%d", i), "md5": "x"}) } writeJSON(w, files) return } if r.Method != http.MethodDelete { http.Error(w, "no", http.StatusMethodNotAllowed) return } f.mu.Lock() f.deleted = append(f.deleted, name+"@"+version) f.mu.Unlock() w.WriteHeader(http.StatusNoContent) case p == "/api/v1/repos/search": var all []map[string]any for _, n := range keys(f.repos) { all = append(all, map[string]any{"full_name": n, "default_branch": "main", "empty": len(f.repos[n]) == 0}) } writeJSON(w, map[string]any{"ok": true, "data": pageOf(all, page, pageSize)}) case treePath.MatchString(p): m := treePath.FindStringSubmatch(p) full := m[1] + "/" + m[2] if f.broken[full] { http.Error(w, "boom", http.StatusInternalServerError) return } var tree []map[string]any for _, path := range keys(f.repos[full]) { tree = append(tree, map[string]any{"path": path, "type": "blob"}) } writeJSON(w, map[string]any{"tree": tree, "truncated": false, "total_count": len(tree)}) case rawPath.MatchString(p): m := rawPath.FindStringSubmatch(p) file, _ := url.PathUnescape(m[3]) content, has := f.repos[m[1]+"/"+m[2]][file] if !has { http.NotFound(w, r) return } w.Write([]byte(content)) default: http.NotFound(w, r) } } func keys[T any](m map[string]T) []string { var out []string for k := range m { out = append(out, k) } sort.Strings(out) return out } func pageOf[T any](all []T, page, size int) []T { lo := (page - 1) * size if lo >= len(all) { return []T{} } return all[lo:min(lo+size, len(all))] } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } // aRegistry: the SDK with nine versions over two lines, a UI package with three, and repositories that // pin an old SDK in a lockfile, range onto the old line, and tag a pre-release. func aRegistry(t *testing.T) *fakeForge { return &fakeForge{ t: t, password: "s3cret-never-shown", versions: map[string][]string{ "@acme/sdk": {"0.1.0", "0.1.1", "0.1.2", "0.1.3", "0.2.0", "0.2.1", "0.2.2", "0.3.0-beta.1", "0.3.0"}, "@acme/ui": {"1.0.0", "1.1.0", "2.0.0"}, }, tags: map[string]map[string]string{ "@acme/sdk": {"latest": "0.3.0", "next": "0.3.0-beta.1"}, "@acme/ui": {"latest": "2.0.0"}, }, repos: map[string]map[string]string{ "acme/app": { "package.json": `{"dependencies":{"@acme/sdk":"^0.1.0","left-pad":"1.0.0"}}`, "package-lock.json": `{"lockfileVersion":3,"packages":{"node_modules/@acme/sdk":{"version":"0.1.0"}}}`, }, "acme/site": {"web/package.json": `{"devDependencies":{"@acme/ui":"~1.0.0"}}`, "README.md": "x"}, "acme/empty": {}, }, } } func decided(s *Survey) map[string]Item { out := map[string]Item{} for _, p := range s.Packages { for _, v := range p.Versions { out[p.Name+"@"+v.Version] = v } } return out } func TestWhatKeepsAVersionIsWhatNamesIt(t *testing.T) { f := aRegistry(t) s, err := survey(context.Background(), f.serve(), "", 3, func(Item) bool { return true }) if err != nil { t.Fatal(err) } if s.Repositories != 3 || len(s.Unread) != 0 { t.Fatalf("read %d, unread %v", s.Repositories, s.Unread) } d := decided(s) wantKept := map[string]string{ "@acme/sdk@0.1.0": "a lockfile names it: acme/app:package-lock.json", // old, but pinned "@acme/sdk@0.1.3": "the highest version satisfying ^0.1.0 in acme/app:package.json", "@acme/sdk@0.3.0-beta.1": "dist-tag next", "@acme/sdk@0.3.0": "dist-tag latest", "@acme/sdk@0.2.2": "among the newest 3 (pre-releases count)", "@acme/ui@1.0.0": "the highest version satisfying ~1.0.0 in acme/site:web/package.json", "@acme/ui@1.1.0": "among the newest 3 (pre-releases count)", } for v, why := range wantKept { item := d[v] if !item.Kept || !contains(item.Why, why) { t.Errorf("%s: kept %v, why %v; want %q", v, item.Kept, item.Why, why) } } for _, v := range []string{"@acme/sdk@0.1.1", "@acme/sdk@0.1.2", "@acme/sdk@0.2.0", "@acme/sdk@0.2.1"} { if d[v].Kept { t.Errorf("%s kept for %v", v, d[v].Why) } } if d["@acme/sdk@0.1.1"].Bytes != 1<<20+1024 { t.Errorf("size %d", d["@acme/sdk@0.1.1"].Bytes) } // Newest first. if s.Packages[0].Versions[0].Version != "0.3.0" || s.Packages[0].Versions[1].Version != "0.3.0-beta.1" { t.Errorf("order %v", s.Packages[0].Versions[:2]) } } func contains(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } func TestADryRunDeletesNothingAndSaysWhatWouldGo(t *testing.T) { f := aRegistry(t) p, err := retention(context.Background(), f.serve(), "", 3, true, "") if err != nil { t.Fatal(err) } if len(f.deleted) != 0 { t.Fatalf("a dry run deleted %v", f.deleted) } var would []string for _, c := range p.Candidates { would = append(would, c.Package+"@"+c.Version) } sort.Strings(would) if strings.Join(would, " ") != "@acme/sdk@0.1.1 @acme/sdk@0.1.2 @acme/sdk@0.2.0 @acme/sdk@0.2.1" { t.Fatalf("would delete %v", would) } if p.Bytes != 4*(1<<20+1024) { t.Errorf("bytes %d", p.Bytes) } raw, _ := json.Marshal(p) if strings.Contains(string(raw), f.password) { t.Fatal("the answer carries the forge's password") } } func TestARealRunWithoutWhyIsRefusedBeforeAnythingIsRead(t *testing.T) { f := aRegistry(t) if _, err := retention(context.Background(), f.serve(), "", 3, false, " "); err == nil || !strings.Contains(err.Error(), "needs why") { t.Fatalf("got %v", err) } if len(f.deleted) != 0 { t.Fatal("deleted without a why") } } func TestARealRunDeletesOnlyWhatNothingKeeps(t *testing.T) { f := aRegistry(t) p, err := retention(context.Background(), f.serve(), "", 3, false, "the registry keeps what is named") if err != nil { t.Fatal(err) } sort.Strings(f.deleted) if strings.Join(f.deleted, " ") != "@acme/sdk@0.1.1 @acme/sdk@0.1.2 @acme/sdk@0.2.0 @acme/sdk@0.2.1" { t.Fatalf("deleted %v", f.deleted) } if len(p.Deleted) != 4 || len(p.Refused) != 0 { t.Fatalf("answered %v / %v", p.Deleted, p.Refused) } } func TestAnUnreadRepositoryStopsARealRunBeforeAnythingIsDeleted(t *testing.T) { f := aRegistry(t) f.broken = map[string]bool{"acme/site": true} p, err := retention(context.Background(), f.serve(), "", 3, false, "tidy") if err == nil || !strings.Contains(err.Error(), "nothing was deleted") { t.Fatalf("got %v", err) } if len(f.deleted) != 0 { t.Fatalf("deleted %v while a repository was unread", f.deleted) } if p == nil || len(p.Unread) != 1 || !strings.HasPrefix(p.Unread[0], "acme/site") { t.Fatalf("unread %v", p) } // And the dry run says so, without failing. dry, err := retention(context.Background(), f.serve(), "", 3, true, "") if err != nil || len(dry.Unread) != 1 { t.Fatalf("dry run: %v %v", err, dry) } } func TestARangeThatCannotBeReadKeepsEveryVersionOfItsPackage(t *testing.T) { f := aRegistry(t) f.repos["acme/odd"] = map[string]string{"package.json": `{"dependencies":{"@acme/sdk":"^not.a.range"}}`} p, err := retention(context.Background(), f.serve(), "@acme/sdk", 1, true, "") if err != nil { t.Fatal(err) } if len(p.Candidates) != 0 || !strings.Contains(p.Packages[0].KeepsAll, "^not.a.range") { t.Fatalf("candidates %v, keeps all %q", p.Candidates, p.Packages[0].KeepsAll) } } func TestADistTagNamedAsARangeKeepsItsVersion(t *testing.T) { f := aRegistry(t) f.repos["acme/tagged"] = map[string]string{"package.json": `{"dependencies":{"@acme/sdk":"next"}}`} s, err := survey(context.Background(), f.serve(), "@acme/sdk", 1, nil) if err != nil { t.Fatal(err) } if s.Packages[0].KeepsAll != "" || !decided(s)["@acme/sdk@0.3.0-beta.1"].Kept { t.Fatalf("%+v", s.Packages[0]) } } func TestAWrongPasswordIsAFailureThatDoesNotSayThePassword(t *testing.T) { f := aRegistry(t) forge := f.serve() f.password = "rotated" _, err := survey(context.Background(), forge, "", 3, nil) if err == nil || !strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "s3cret") { t.Fatalf("got %v", err) } } func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { tools := Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }) var served []string for _, tl := range tools { served = append(served, tl.Name) suffix := "(r)" if tl.Name == "npm_retention" { suffix = "(a)" } if !strings.HasSuffix(tl.Description, suffix) { t.Errorf("%s does not end with %s", tl.Name, suffix) } } if strings.Join(served, ",") != strings.Join(ToolNames, ",") { t.Fatalf("served %v, named %v", served, ToolNames) } readme, err := os.ReadFile("../../README.md") if err != nil { t.Fatal(err) } for _, n := range ToolNames { if !strings.Contains(string(readme), "`"+n+"`") { t.Errorf("the README does not name %s", n) } } raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } var m struct { Tools []string `json:"tools"` Build struct { Artifacts []struct { From string `json:"from"` Loads []string `json:"loads"` Env map[string]string `json:"env"` } `json:"artifacts"` } `json:"build"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } if len(m.Tools) != 0 { t.Error("the manifest lists tools: its claims would offer them as both seats' verbs") } found := false for _, a := range m.Build.Artifacts { if a.From == "cmd/npm-registry" { found = true for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_NPM_OWNER"} { if a.Env[k] == "" { t.Errorf("the bundle is not given %s", k) } } } } if !found { t.Error("the manifest builds no cmd/npm-registry bundle") } // Refused cleanly when the environment is missing, not a crash. if _, err := tools[0].Run(map[string]any{}); err == nil { t.Error("ran without a forge") } } func TestEveryVersionIsMeasuredAndASizeNotReadIsSaid(t *testing.T) { f := aRegistry(t) f.files = map[string][]int64{"@acme/sdk@0.1.1": {1000, 234}, "@acme/sdk@0.3.0": {5000}} f.noSize = map[string]bool{"@acme/sdk@0.2.0": true} p, err := retention(context.Background(), f.serve(), "@acme/sdk", 3, true, "") if err != nil { t.Fatal(err) } d := decided(p.Survey) // A kept version is measured too: no 0 that nobody asked for. if got := d["@acme/sdk@0.3.0"]; !got.Kept || got.Bytes != 5000 || got.SizeError != "" { t.Errorf("kept 0.3.0: %+v", got) } if got := d["@acme/sdk@0.1.1"]; got.Bytes != 1234 { t.Errorf("0.1.1: %+v", got) } broken := d["@acme/sdk@0.2.0"] if broken.Bytes != 0 || !strings.Contains(broken.SizeError, "500") { t.Errorf("0.2.0: %+v", broken) } var candidate *Candidate for i := range p.Candidates { if p.Candidates[i].Version == "0.2.0" { candidate = &p.Candidates[i] } } if candidate == nil || candidate.SizeError == "" { t.Errorf("the candidate does not carry its size error: %+v", p.Candidates) } // Would delete 0.1.1 (1234), 0.1.2 and 0.2.1 (1 MiB + 1 KiB each) and 0.2.0 (unknown). if want := int64(1234 + 2*(1<<20+1024)); p.Bytes != want { t.Errorf("bytes %d, want %d", p.Bytes, want) } if p.RegistryBytes <= p.Bytes { t.Errorf("registry bytes %d not above %d", p.RegistryBytes, p.Bytes) } said := strings.Join(p.Said, "\n") if !strings.Contains(said, "the size of 1 versions could not be read") || !strings.Contains(said, "@acme/sdk@0.2.0") { t.Errorf("said %q", said) } } func TestALockfileOfEveryShapeKeepsWhatItPins(t *testing.T) { f := aRegistry(t) f.repos["acme/v2"] = map[string]string{"package-lock.json": `{"lockfileVersion":2,"packages":{ "":{"name":"v2","dependencies":{"@acme/sdk":"^0.2.0"}}, "node_modules/@acme/sdk":{"version":"0.2.0","dependencies":{"left-pad":"^1.0.0"}}}, "dependencies":{"@acme/sdk":{"version":"0.2.0","requires":{"left-pad":"^1.0.0"}}}}`} s, err := survey(context.Background(), f.serve(), "@acme/sdk", 1, nil) if err != nil { t.Fatal(err) } if len(s.NotRead) != 0 { t.Fatalf("not read %v", s.NotRead) } if got := decided(s)["@acme/sdk@0.2.0"]; !contains(got.Why, "a lockfile names it: acme/v2:package-lock.json") { t.Fatalf("0.2.0: %+v", got) } } func TestAnUnreadableLockfileStopsARealRunBeforeAnythingIsDeleted(t *testing.T) { f := aRegistry(t) f.repos["acme/odd"] = map[string]string{"package-lock.json": `{"lockfileVersion":3,"packages":{`} p, err := retention(context.Background(), f.serve(), "", 3, false, "tidy") if err == nil || !strings.Contains(err.Error(), "nothing was deleted") || !strings.Contains(err.Error(), "acme/odd:package-lock.json") { t.Fatalf("got %v", err) } if len(f.deleted) != 0 { t.Fatalf("deleted %v while a lockfile was not read", f.deleted) } if p == nil || len(p.NotRead) != 1 || len(p.Deleted) != 0 { t.Fatalf("plan %+v", p) } // And the dry run says so, without failing, and still says what would go. dry, err := retention(context.Background(), f.serve(), "", 3, true, "") if err != nil || len(dry.NotRead) != 1 || len(dry.Candidates) == 0 { t.Fatalf("dry run: %v %+v", err, dry) } if !strings.Contains(strings.Join(dry.Said, "\n"), "1 files were not read whole") { t.Errorf("said %v", dry.Said) } } // An answer larger than the cap is refused, never parsed as if the cut body were whole; one exactly at // the cap is read. func TestAnAnswerTheCapCutIsRefused(t *testing.T) { size := maxBody + 1 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(make([]byte, size)) })) t.Cleanup(srv.Close) file := filepath.Join(t.TempDir(), "admin.secret") if err := os.WriteFile(file, []byte("x\n"), 0o600); err != nil { t.Fatal(err) } f := &Forge{URL: srv.URL, User: "admin", PasswordFile: file, Owner: "acme"} if _, err := f.do(context.Background(), http.MethodGet, "/big"); err == nil || !strings.Contains(err.Error(), "not read whole") { t.Fatalf("a cut answer: %v", err) } size = maxBody if body, err := f.do(context.Background(), http.MethodGet, "/big"); err != nil || len(body) != maxBody { t.Fatalf("an answer at the cap: %d bytes, %v", len(body), err) } }