Files
mesh-catalog/modules/gitea/cmd/npm-registry/retention_test.go
T
jochen a378abc758 gitea: say what keeps each npm version and delete only what nothing names (hq ADR 0251)
Every publish added a version and nothing removed one. A Go bundle beside the
TypeScript one keeps what a lockfile or a range on the forge names, what a
dist-tag names and the newest five; a dry run unless asked with a why, and
nothing deleted while any repository is unread.
2026-10-08 12:00:48 +02:00

394 lines
12 KiB
Go

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
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] != "" {
writeJSON(w, []map[string]any{{"name": "a.tgz", "size": 1 << 20}, {"name": "b", "size": 1024}})
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")
}
}