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.
394 lines
12 KiB
Go
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")
|
|
}
|
|
}
|