package handler import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "context" "errors" "io" "strings" "testing" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/registries/fetch" ) 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) } }) } }