diff --git a/internal/virtual/merger_test.go b/internal/virtual/merger_test.go new file mode 100644 index 0000000..5b610fc --- /dev/null +++ b/internal/virtual/merger_test.go @@ -0,0 +1,138 @@ +package virtual + +import ( + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestRegisterGetMerger(t *testing.T) { + if _, err := GetMerger(models.PackageHelm); err != nil { + t.Errorf("helm merger should be registered: %v", err) + } + if _, err := GetMerger(models.PackagePyPI); err != nil { + t.Errorf("pypi merger should be registered: %v", err) + } + if _, err := GetMerger(models.PackageType("nope")); err == nil { + t.Error("expected error for unknown merger") + } +} + +func TestPyPIMerge(t *testing.T) { + m := &PyPIMerger{} + members := []MemberIndex{ + {RemoteName: "a", RepoType: models.RepoTypeRemote, Body: []byte(`foo-1.0.whl`)}, + {RemoteName: "b", RepoType: models.RepoTypeLocal, Body: []byte(`bar-2.0.whl`)}, + } + out, err := m.MergeIndexes(members, "http://proxy") + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, "foo-1.0.whl") || !strings.Contains(s, "bar-2.0.whl") { + t.Errorf("merged index missing entries: %s", s) + } + if !strings.Contains(s, "http://proxy/api/v1/remote/a/pkg/foo-1.0.whl") { + t.Errorf("remote href not rewritten: %s", s) + } + if !strings.Contains(s, "http://proxy/api/v1/local/b/bar-2.0.whl") { + t.Errorf("local href not rewritten: %s", s) + } + + // Sorted output: foo before... entries sorted by link text. + if strings.Index(s, "bar-2.0.whl") > strings.Index(s, "foo-1.0.whl") { + t.Error("entries should be sorted by text") + } + + // Duplicate link texts across members are de-duplicated. + dup := []MemberIndex{ + {RemoteName: "a", Body: []byte(`dup`)}, + {RemoteName: "b", Body: []byte(`dup`)}, + } + out, _ = m.MergeIndexes(dup, "") + if strings.Count(string(out), ">dup") != 1 { + t.Errorf("duplicate not de-duplicated: %s", out) + } +} + +func TestHelmMerge(t *testing.T) { + m := &HelmMerger{} + memberA := `apiVersion: v1 +entries: + alpha: + - name: alpha + version: 1.0.0 + urls: + - charts/alpha-1.0.0.tgz +` + memberB := `apiVersion: v1 +entries: + beta: + - name: beta + version: 2.0.0 + urls: + - https://charts.example.com/beta-2.0.0.tgz + gamma: + - name: gamma + version: 3.0.0 + urls: + - https://other-host.example.net/gamma-3.0.0.tgz +` + members := []MemberIndex{ + {RemoteName: "a", RepoType: models.RepoTypeLocal, BaseURL: "https://charts.example.com", Body: []byte(memberA)}, + {RemoteName: "b", RepoType: models.RepoTypeRemote, BaseURL: "https://charts.example.com", Body: []byte(memberB)}, + } + out, err := m.MergeIndexes(members, "http://proxy") + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, chart := range []string{"alpha", "beta", "gamma"} { + if !strings.Contains(s, chart) { + t.Errorf("merged index missing chart %q: %s", chart, s) + } + } + // Relative URL from a local member is rewritten under /local/. + if !strings.Contains(s, "http://proxy/api/v1/local/a/charts/alpha-1.0.0.tgz") { + t.Errorf("relative local url not rewritten: %s", s) + } + // Same-host absolute URL from a remote member is rewritten under /remote/. + if !strings.Contains(s, "http://proxy/api/v1/remote/b/beta-2.0.0.tgz") { + t.Errorf("same-host absolute url not rewritten: %s", s) + } + // Cross-host absolute URL is left untouched. + if !strings.Contains(s, "https://other-host.example.net/gamma-3.0.0.tgz") { + t.Errorf("cross-host url should be preserved: %s", s) + } +} + +func TestHelmMergeDedup(t *testing.T) { + m := &HelmMerger{} + body := `apiVersion: v1 +entries: + alpha: + - name: alpha + version: 1.0.0 + urls: [charts/alpha-1.0.0.tgz] +` + members := []MemberIndex{ + {RemoteName: "a", BaseURL: "https://x", Body: []byte(body)}, + {RemoteName: "b", BaseURL: "https://x", Body: []byte(body)}, + } + out, _ := m.MergeIndexes(members, "") + if strings.Count(string(out), "version: 1.0.0") != 1 { + t.Errorf("duplicate chart version not de-duplicated: %s", out) + } +} + +func TestHelmMergeInvalidYAML(t *testing.T) { + m := &HelmMerger{} + out, err := m.MergeIndexes([]MemberIndex{{RemoteName: "a", Body: []byte("::: not yaml :::")}}, "") + if err != nil { + t.Fatalf("invalid member yaml should be skipped, not error: %v", err) + } + if !strings.Contains(string(out), "apiVersion") { + t.Errorf("expected a valid empty merged index: %s", out) + } +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..62f9fd5 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,145 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func testServer(t *testing.T, h http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return New(srv.URL) +} + +func TestRemotesRoundTrip(t *testing.T) { + var gotMethod, gotPath string + c := testServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/remotes": + w.Write([]byte(`[{"name":"a"},{"name":"b"}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/remotes/a": + w.Write([]byte(`{"name":"a"}`)) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"name":"a"}`)) + } + }) + ctx := context.Background() + + remotes, err := c.ListRemotes(ctx) + if err != nil || len(remotes) != 2 { + t.Fatalf("ListRemotes: %v %v", remotes, err) + } + if r, err := c.GetRemote(ctx, "a"); err != nil || r.Name != "a" { + t.Fatalf("GetRemote: %v %v", r, err) + } + if err := c.CreateRemote(ctx, &models.Remote{Name: "a", PackageType: models.PackageGeneric}); err != nil { + t.Fatalf("CreateRemote: %v", err) + } + if err := c.UpdateRemote(ctx, &models.Remote{Name: "a"}); err != nil { + t.Fatalf("UpdateRemote: %v", err) + } + if err := c.DeleteRemote(ctx, "a"); err != nil { + t.Fatalf("DeleteRemote: %v", err) + } + if gotMethod != http.MethodDelete || gotPath != "/api/v2/remotes/a" { + t.Errorf("last call = %s %s", gotMethod, gotPath) + } +} + +func TestVirtualsRoundTrip(t *testing.T) { + c := testServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/virtuals"): + w.Write([]byte(`[{"name":"v"}]`)) + case r.Method == http.MethodGet: + w.Write([]byte(`{"name":"v"}`)) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"name":"v"}`)) + } + }) + ctx := context.Background() + if vs, err := c.ListVirtuals(ctx); err != nil || len(vs) != 1 { + t.Fatalf("ListVirtuals: %v %v", vs, err) + } + if v, err := c.GetVirtual(ctx, "v"); err != nil || v.Name != "v" { + t.Fatalf("GetVirtual: %v %v", v, err) + } + if err := c.CreateVirtual(ctx, &models.Virtual{Name: "v"}); err != nil { + t.Fatalf("CreateVirtual: %v", err) + } + if err := c.UpdateVirtual(ctx, &models.Virtual{Name: "v"}); err != nil { + t.Fatalf("UpdateVirtual: %v", err) + } + if err := c.DeleteVirtual(ctx, "v"); err != nil { + t.Fatalf("DeleteVirtual: %v", err) + } +} + +func TestStatsHealthObjects(t *testing.T) { + c := testServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stats"): + w.Write([]byte(`{"total_remotes":3}`)) + case strings.HasSuffix(r.URL.Path, "/health"): + w.Write([]byte(`{"status":"ok"}`)) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.Write([]byte(`[{"path":"p"}]`)) + } + }) + ctx := context.Background() + if _, err := c.Stats(ctx); err != nil { + t.Fatalf("Stats: %v", err) + } + if _, err := c.Health(ctx); err != nil { + t.Fatalf("Health: %v", err) + } + if objs, err := c.ListObjects(ctx, "r", 1, 50); err != nil || len(objs) != 1 { + t.Fatalf("ListObjects: %v %v", objs, err) + } + if err := c.EvictObject(ctx, "r", "some/path"); err != nil { + t.Fatalf("EvictObject: %v", err) + } +} + +func TestErrorResponses(t *testing.T) { + c := testServer(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + ctx := context.Background() + _, err := c.GetRemote(ctx, "x") + if err == nil || !strings.Contains(err.Error(), "api error 500") { + t.Errorf("expected api error, got %v", err) + } +} + +func TestDecodeError(t *testing.T) { + c := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`not json`)) + }) + if _, err := c.ListRemotes(context.Background()); err == nil || !strings.Contains(err.Error(), "decode") { + t.Errorf("expected decode error, got %v", err) + } +} + +func TestRequestError(t *testing.T) { + // Invalid base URL triggers request construction failure. + c := New("http://[::1]:namedport") + if err := c.DeleteRemote(context.Background(), "x"); err == nil { + t.Error("expected request error for invalid URL") + } +}