package handler import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "context" "errors" "fmt" "io" "strings" "sync" "testing" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/registries/fetch" ) func TestHostedTargetForFileInPath(t *testing.T) { got, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz") if err != nil || got != (HostedTarget{Name: "tools", Version: "1.2.0", Filename: "tool.tar.gz"}) { t.Errorf("generic target = %+v, %v", got, err) } for _, tt := range []struct{ ecosystem, name, version, file string }{ {"generic", "tools", "1.2.0", ""}, {"generic", "tools", "1.2.0", ".."}, {"generic", "acme/tools", "1.2.0", "tool.tar.gz"}, {"npm", "request", "2.88.3", "request-2.88.3.tgz"}, } { if _, err := HostedTargetFor(tt.ecosystem, tt.name, tt.version, tt.file); !errors.Is(err, ErrHostedInvalidName) { t.Errorf("HostedTargetFor(%q, %q, %q, %q) error = %v, want ErrHostedInvalidName", tt.ecosystem, tt.name, tt.version, tt.file, err) } } if !HostedFileInPath("generic") || HostedFileInPath("npm") || HostedFileInPath("cargo") { t.Error("HostedFileInPath should hold for generic only") } } func TestHostedTargetFor(t *testing.T) { tests := []struct { name string ecosystem, pkg, version string want HostedTarget wantErr error }{ {name: "npm", ecosystem: "npm", pkg: "request", version: "2.88.3", want: HostedTarget{Name: "request", Version: "2.88.3", Filename: "request-2.88.3.tgz"}}, {name: "npm scoped", ecosystem: "npm", pkg: "@types/node", version: "20.1.0", want: HostedTarget{Name: "@types/node", Version: "20.1.0", Filename: "node-20.1.0.tgz"}}, {name: "composer", ecosystem: "composer", pkg: "monolog/monolog", version: "3.5.0", want: HostedTarget{Name: "monolog/monolog", Version: "3.5.0", Filename: "monolog-3.5.0.zip"}}, {name: "composer without vendor", ecosystem: "composer", pkg: "monolog", version: "3.5.0", wantErr: ErrHostedInvalidName}, {name: "composer with extra segment", ecosystem: "composer", pkg: "a/b/c", version: "1.0.0", wantErr: ErrHostedInvalidName}, {name: "unsupported ecosystem", ecosystem: "cargo", pkg: "serde", version: "1.0.200", wantErr: ErrHostedUnsupported}, {name: "unknown ecosystem", ecosystem: "maven", pkg: "junit", version: "4.13", wantErr: ErrHostedUnsupported}, {name: "npm scope without @", ecosystem: "npm", pkg: "types/node", version: "1.0.0", wantErr: ErrHostedInvalidName}, {name: "dot-dot name segment", ecosystem: "npm", pkg: "@scope/..", version: "1.0.0", wantErr: ErrHostedInvalidName}, {name: "dot-dot version", ecosystem: "npm", pkg: "request", version: "..", wantErr: ErrHostedInvalidName}, {name: "control character", ecosystem: "npm", pkg: "request", version: "1.0.0\n", wantErr: ErrHostedInvalidName}, {name: "empty version", ecosystem: "npm", pkg: "request", version: "", wantErr: ErrHostedInvalidName}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := HostedTargetFor(tt.ecosystem, tt.pkg, tt.version, "") if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Fatalf("HostedTargetFor() error = %v, want %v", err, tt.wantErr) } return } if err != nil { t.Fatalf("HostedTargetFor() error = %v", err) } if got != tt.want { t.Errorf("HostedTargetFor() = %+v, want %+v", got, tt.want) } }) } } // npmTarball builds an npm package tarball holding manifest as // package/package.json. Building an archive in memory cannot fail, so errors // panic. func npmTarball(manifest string) []byte { var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) for _, f := range [][2]string{{"package/package.json", manifest}, {"package/index.js", "module.exports = 1\n"}} { must(tw.WriteHeader(&tar.Header{Name: f[0], Mode: 0o644, Size: int64(len(f[1]))})) _, err := io.WriteString(tw, f[1]) must(err) } must(tw.Close()) must(gz.Close()) return buf.Bytes() } // composerZip builds a Composer dist archive holding manifest as composer.json // under dir, or at the root when dir is empty. func composerZip(dir, manifest string) []byte { var buf bytes.Buffer zw := zip.NewWriter(&buf) prefix := "" if dir != "" { prefix = dir + "/" } for _, f := range [][2]string{{prefix + "composer.json", manifest}, {prefix + "src/Logger.php", "=8.1"}}`)}, {name: "composer manifest in a top directory", ecosystem: "composer", pkg: "monolog/monolog", version: "3.5.0", archive: composerZip("Seldaek-monolog-abc123", `{"name":"Monolog/Monolog","version":"3.5.0"}`)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proxy, _, store, _ := setupTestProxy(t) target, err := HostedTargetFor(tt.ecosystem, tt.pkg, tt.version, "") if err != nil { t.Fatalf("HostedTargetFor: %v", err) } _, err = proxy.PushLocalArtifact(context.Background(), tt.ecosystem, target, bytes.NewReader(tt.archive), "", "") if tt.wantErr == nil { if err != nil { t.Fatalf("PushLocalArtifact: %v", err) } return } if !errors.Is(err, tt.wantErr) { t.Fatalf("PushLocalArtifact error = %v, want %v", err, tt.wantErr) } if listed, _ := proxy.LocalArtifacts(tt.ecosystem, tt.pkg); len(listed) != 0 { t.Errorf("rejected archive was recorded: %+v", listed) } if len(store.files) != 0 { t.Errorf("rejected archive left %d objects in storage", len(store.files)) } }) } } // removeStored deletes the stored object of an artifact behind the cache's back. func removeStored(t *testing.T, proxy *Proxy, versionPURL, filename string) { t.Helper() record, err := proxy.DB.GetArtifact(versionPURL, filename) if err != nil || record == nil { t.Fatalf("GetArtifact: %+v, %v", record, err) } if err := proxy.Storage.Delete(context.Background(), record.StoragePath.String); err != nil { t.Fatalf("deleting stored object: %v", err) } } func TestPushedArtifactIsNeverReplacedFromUpstream(t *testing.T) { const versionPURL, filename = "pkg:npm/request@2.88.3", "request-2.88.3.tgz" tests := []struct { name string damage func(t *testing.T, proxy *Proxy) wantErr error }{ {name: "stored object missing", wantErr: ErrHostedArtifactUnavailable, damage: func(t *testing.T, proxy *Proxy) { removeStored(t, proxy, versionPURL, filename) }}, {name: "record cleared after an integrity failure", wantErr: ErrHostedArtifactMissing, damage: func(t *testing.T, proxy *Proxy) { record, err := proxy.DB.GetArtifact(versionPURL, filename) if err != nil || record == nil { t.Fatalf("GetArtifact: %+v, %v", record, err) } if err := proxy.DB.DiscardArtifact(versionPURL, filename, record.StoragePath.String); err != nil { t.Fatalf("DiscardArtifact: %v", err) } }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proxy, db, _, fetcher := setupTestProxy(t) fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("upstream tarball")), ContentType: "application/gzip"} pushRequest(t, proxy) tt.damage(t, proxy) _, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "npm", "request", "2.88.3", filename, "https://registry.npmjs.org/request/-/request-2.88.3.tgz") if !errors.Is(err, tt.wantErr) { t.Errorf("GetOrFetchArtifactFromURL error = %v, want %v", err, tt.wantErr) } if fetcher.fetchCalled { t.Error("a miss on a pushed artifact fetched the upstream copy") } local, err := db.GetLocalArtifacts("pkg:npm/request") if err != nil || len(local) != 1 || !local[0].Manifest.Valid { t.Errorf("pushed records after the miss = %+v, %v, want the pushed one with its manifest", local, err) } }) } } // zipWith builds a zip holding files in the given order. func zipWith(files ...[2]string) []byte { var buf bytes.Buffer zw := zip.NewWriter(&buf) for _, f := range files { w, err := zw.Create(f[0]) must(err) _, err = io.WriteString(w, f[1]) must(err) } must(zw.Close()) return buf.Bytes() } func TestComposerManifestLookup(t *testing.T) { const monolog = `{"name":"monolog/monolog"}` const fixture = `{"name":"acme/test-fixture"}` tests := []struct { name string archive []byte wantErr error }{ {name: "root manifest after a nested one", archive: zipWith([2]string{"tests/composer.json", fixture}, [2]string{"composer.json", monolog})}, {name: "top directory manifest next to a deeper one", archive: zipWith([2]string{"monolog-abc/tests/composer.json", fixture}, [2]string{"monolog-abc/composer.json", monolog})}, {name: "two top directories with manifests", archive: zipWith([2]string{"tests/composer.json", fixture}, [2]string{"monolog-abc/composer.json", monolog}), wantErr: ErrHostedInvalidArchive}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target, err := HostedTargetFor("composer", "monolog/monolog", "3.5.0", "") if err != nil { t.Fatalf("HostedTargetFor: %v", err) } _, err = readHostedManifest("composer", bytes.NewReader(tt.archive), target) if tt.wantErr == nil && err != nil { t.Errorf("readHostedManifest error = %v, want the root or single top directory manifest", err) } if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { t.Errorf("readHostedManifest error = %v, want %v", err, tt.wantErr) } }) } } func TestConcurrentPushesOfTheSameFileHaveOneWinner(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) proxy.HostedEnabled = true target, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz") if err != nil { t.Fatalf("HostedTargetFor: %v", err) } const pushes = 8 start := make(chan struct{}) results := make([]error, pushes) pushed := make([]HostedArtifact, pushes) var wg sync.WaitGroup for i := range pushes { wg.Add(1) go func() { defer wg.Done() <-start body := strings.NewReader(fmt.Sprintf("build %d", i)) pushed[i], results[i] = proxy.PushLocalArtifact(context.Background(), "generic", target, body, "", "") }() } close(start) wg.Wait() winner := -1 for i, err := range results { switch { case err == nil && winner >= 0: t.Fatalf("pushes %d and %d both succeeded", winner, i) case err == nil: winner = i case !errors.Is(err, ErrHostedExists): t.Fatalf("push %d error = %v, want ErrHostedExists", i, err) } } if winner < 0 { t.Fatal("no push succeeded") } result, err := proxy.GetLocalArtifact(context.Background(), "generic", "tools", "1.2.0", "tool.tar.gz") if err != nil || result == nil { t.Fatalf("GetLocalArtifact = %v, %v", result, err) } served, _ := io.ReadAll(result.Reader) _ = result.Reader.Close() if string(served) != fmt.Sprintf("build %d", winner) { t.Errorf("served %q, want the winning push %q", served, fmt.Sprintf("build %d", winner)) } } // A fetch that started before a push and records after it must not replace // the pushed artifact or its version's integrity. func TestCacheFillAfterPushKeepsThePush(t *testing.T) { proxy, _, store, _ := setupTestProxy(t) proxy.HostedEnabled = true pushRequest(t, proxy) const fetchedPath = "npm/request/2.88.3/fetched/request-2.88.3.tgz" if _, _, err := store.Store(context.Background(), fetchedPath, strings.NewReader("upstream bytes")); err != nil { t.Fatalf("storing fetched copy: %v", err) } fetched := testArtifact("upstream bytes", "pkg:npm/request@2.88.3", "request-2.88.3.tgz", "application/gzip") err := proxy.updateCacheDB("npm", "request", "pkg:npm/request", "https://registry.npmjs.org/request/-/request-2.88.3.tgz", fetchedPath, fetched, "sha512-upstream") if !errors.Is(err, database.ErrLocalArtifactExists) { t.Fatalf("updateCacheDB over a push error = %v, want ErrLocalArtifactExists", err) } result, err := proxy.GetLocalArtifact(context.Background(), "npm", "request", "2.88.3", "request-2.88.3.tgz") if err != nil || result == nil { t.Fatalf("GetLocalArtifact = %v, %v, want the pushed copy", result, err) } served, err := io.ReadAll(result.Reader) _ = result.Reader.Close() if err != nil || string(served) != pushedTarball { t.Errorf("served %d bytes, %v, want the pushed tarball, integrity intact", len(served), err) } } func TestGenericPushDoesNotReadTheStoredFile(t *testing.T) { proxy, _, store, _ := setupTestProxy(t) proxy.HostedEnabled = true target, err := HostedTargetFor("generic", "tools", "1.2.0", "tool.tar.gz") if err != nil { t.Fatalf("HostedTargetFor: %v", err) } store.openErr = errors.New("storage opened") if _, err := proxy.PushLocalArtifact(context.Background(), "generic", target, strings.NewReader("bytes"), "", ""); err != nil { t.Fatalf("PushLocalArtifact: %v", err) } }