pulumi/sdk/nodejs/npm/manager_test.go

369 lines
11 KiB
Go

// Copyright 2016-2020, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package npm
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha1" //nolint:gosec // this is what NPM wants
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
ptesting "github.com/pulumi/pulumi/sdk/v3/go/common/testing"
"github.com/pulumi/pulumi/sdk/v3/go/common/testing/iotest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// chdir temporarily changes the current directory of the program.
// It restores it to the original directory when the test is done.
func chdir(t *testing.T, dir string) {
cwd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir)) // Set directory
t.Cleanup(func() {
require.NoError(t, os.Chdir(cwd)) // Restore directory
restoredDir, err := os.Getwd()
if assert.NoError(t, err) {
assert.Equal(t, cwd, restoredDir)
}
})
}
//nolint:paralleltest // changes working directory
func TestNPMInstall(t *testing.T) {
t.Run("development", func(t *testing.T) {
testInstall(t, "npm", false /*production*/)
})
t.Run("production", func(t *testing.T) {
testInstall(t, "npm", true /*production*/)
})
}
//nolint:paralleltest // changes working directory
func TestYarnInstall(t *testing.T) {
t.Run("development", func(t *testing.T) {
testInstall(t, "yarn", false /*production*/)
})
t.Run("production", func(t *testing.T) {
testInstall(t, "yarn", true /*production*/)
})
}
//nolint:paralleltest // changes working directory
func TestPnpmInstall(t *testing.T) {
t.Run("development", func(t *testing.T) {
testInstall(t, "pnpm", false /*production*/)
})
t.Run("production", func(t *testing.T) {
testInstall(t, "pnpm", true /*production*/)
})
}
func TestResolvePackageManager(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
pm PackageManagerType
lockFiles []string
expected string
}{
{"defaults to npm", AutoPackageManager, []string{}, "npm"},
{"picks npm", NpmPackageManager, []string{}, "npm"},
{"picks yarn", YarnPackageManager, []string{}, "yarn"},
{"picks pnpm", PnpmPackageManager, []string{}, "pnpm"},
{"picks npm based on lockfile", AutoPackageManager, []string{"npm"}, "npm"},
{"picks yarn based on lockfile", AutoPackageManager, []string{"yarn"}, "yarn"},
{"picks pnpm based on lockfile", AutoPackageManager, []string{"pnpm"}, "pnpm"},
{"yarn > pnpm > npm", AutoPackageManager, []string{"yarn", "pnpm", "npm"}, "yarn"},
{"pnpm > npm", AutoPackageManager, []string{"pnpm", "npm"}, "pnpm"},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, lockFile := range tt.lockFiles {
writeLockFile(t, dir, lockFile)
}
pm, err := ResolvePackageManager(tt.pm, dir)
require.NoError(t, err)
require.Equal(t, tt.expected, pm.Name())
})
}
}
func TestPack(t *testing.T) {
t.Parallel()
packageJSON := []byte(`{
"name": "test-package",
"version": "1.0"
}`)
for _, pm := range []string{"npm", "yarn", "pnpm"} {
pm := pm
t.Run(pm, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeLockFile(t, dir, pm)
packageJSONFilename := filepath.Join(dir, "package.json")
require.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))
stderr := new(bytes.Buffer)
artifact, err := Pack(context.Background(), AutoPackageManager, dir, stderr)
require.NoError(t, err)
// check that the artifact contains a package.json
b, err := gzip.NewReader(bytes.NewReader((artifact)))
require.NoError(t, err)
tr := tar.NewReader(b)
for {
h, err := tr.Next()
if err == io.EOF {
require.Fail(t, "package.json not found")
break
}
require.NoError(t, err)
if h.Name == "package/package.json" {
break
}
}
})
}
}
func TestPackInvalidPackageJSON(t *testing.T) {
t.Parallel()
// Missing a version field
packageJSON := []byte(`{
"name": "test-package"
}`)
for _, tt := range []struct{ packageManager, expectedErrorMessage string }{
{"npm", "Invalid package, must have name and version"},
{"yarn", "Package doesn't have a version"},
{"pnpm", "Package version is not defined in the package.json"},
} {
tt := tt
t.Run(tt.packageManager, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeLockFile(t, dir, tt.packageManager)
packageJSONFilename := filepath.Join(dir, "package.json")
require.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))
stderr := new(bytes.Buffer)
_, err := Pack(context.Background(), AutoPackageManager, dir, stderr)
exitErr := new(exec.ExitError)
require.ErrorAs(t, err, &exitErr)
assert.NotZero(t, exitErr.ExitCode())
require.Contains(t, stderr.String(), tt.expectedErrorMessage)
})
}
}
// writeLockFile writes a mock lockfile for the selected package manager
func writeLockFile(t *testing.T, dir string, packageManager string) {
t.Helper()
switch packageManager {
case "npm":
writeFile(t, filepath.Join(dir, "package-lock.json"), "{\"lockfileVersion\": 2}")
case "yarn":
writeFile(t, filepath.Join(dir, "yarn.lock"), "# yarn lockfile v1")
case "pnpm":
writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "lockfileVersion: '6.0'")
}
}
func testInstall(t *testing.T, packageManager string, production bool) {
// To test this functionality without actually hitting NPM,
// we'll spin up a local HTTP server that implements a subset
// of the NPM registry API.
//
// We'll tell NPM to use this server with a ~/.npmrc file
// containing the line:
//
// registry = <srv.URL>
//
// Pnpm reads the same .npmrc file.
//
// Similarly, we'll tell Yarn to use this server with a
// ~/.yarnrc file containing the line:
//
// registry "<srv.URL>"
home := t.TempDir()
t.Setenv("HOME", home)
registryURL := fakeNPMRegistry(t)
writeFile(t, filepath.Join(home, ".npmrc"),
"registry="+registryURL)
writeFile(t, filepath.Join(home, ".yarnrc"),
"registry "+strconv.Quote(registryURL))
// Create a new empty test directory and change the current working directory to it.
tempdir := t.TempDir()
chdir(t, tempdir)
// Create a package directory to install dependencies into.
pkgdir := filepath.Join(tempdir, "package")
assert.NoError(t, os.Mkdir(pkgdir, 0o700))
// Write out a minimal package.json file that has at least one dependency.
packageJSONFilename := filepath.Join(pkgdir, "package.json")
packageJSON := []byte(`{
"name": "test-package",
"license": "MIT",
"dependencies": {
"@pulumi/pulumi": "latest"
}
}`)
assert.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))
writeLockFile(t, pkgdir, packageManager)
// Install dependencies, passing nil for stdout and stderr, which connects
// them to the file descriptor for the null device (os.DevNull).
ptesting.YarnInstallMutex.Lock()
defer ptesting.YarnInstallMutex.Unlock()
out := iotest.LogWriter(t)
bin, err := Install(context.Background(), AutoPackageManager, pkgdir, production, out, out)
assert.NoError(t, err)
assert.Equal(t, packageManager, bin)
}
// fakeNPMRegistry starts up an HTTP server that implements a subset of the NPM registry API
// that is sufficient for the tests in this file.
// The server will shut down when the test is complete.
//
// The server responds with fake information about a single package:
// @pulumi/pulumi.
//
// See https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md for
// details on the protocol.
func fakeNPMRegistry(t testing.TB) string {
t.Helper()
// The server needs the tarball's SHA-1 hash so we'll build it in
// advance.
tarball, tarballSHA1 := tarballOf(t,
// The bare minimum files needed by NPM.
"package/package.json", `{
"name": "@pulumi/pulumi",
"license": "MIT"
}`)
var srv *httptest.Server
// Separate assignment so we can access srv.URL in the handler.
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("[fakeNPMRegistry] %v %v", r.Method, r.URL.Path)
if r.Method != http.MethodGet {
// We only expect GET requests.
http.NotFound(w, r)
return
}
switch r.URL.Path {
case "/@pulumi/pulumi":
tarballURL := srv.URL + "/@pulumi/pulumi/-/pulumi-3.0.0.tgz"
fmt.Fprintf(w, `{
"name": "@pulumi/pulumi",
"dist-tags": {"latest": "3.0.0"},
"versions": {
"3.0.0": {
"name": "@pulumi/pulumi",
"version": "3.0.0",
"dist": {
"tarball": %q,
"shasum": %q
}
}
}
}`, tarballURL, tarballSHA1)
case "/@pulumi/pulumi/-/pulumi-3.0.0.tgz":
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(tarball)))
_, err := w.Write(tarball)
if !assert.NoError(t, err) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv.URL
}
// tarballOf constructs a .tar.gz archive containing the given files
// and returns the raw bytes and the SHA-1 hash of the archive.
//
// The files are specified as a list of pairs of paths and contents.
// The paths are relative to the root of the archive.
func tarballOf(t testing.TB, pairs ...string) (data []byte, sha string) {
t.Helper()
require.True(t, len(pairs)%2 == 0, "pairs must be a list of path/contents pairs")
var buff bytes.Buffer // raw .tar.gz bytes
hash := sha1.New() //nolint:gosec // this is what NPM wants
// Order of which writer wraps which is important here.
// .tar.gz means we need .gz to be the innermost writer.
gzipw := gzip.NewWriter(io.MultiWriter(&buff, hash))
tarw := tar.NewWriter(gzipw)
for i := 0; i < len(pairs); i += 2 {
path, contents := pairs[i], pairs[i+1]
require.NoError(t, tarw.WriteHeader(&tar.Header{
Name: path,
Mode: 0o600,
Size: int64(len(contents)),
}), "WriteHeader(%q)", path)
_, err := tarw.Write([]byte(contents))
require.NoError(t, err, "WriteContents(%q)", path)
}
// Closing the writers will flush them and write the final tarball bytes.
require.NoError(t, tarw.Close())
require.NoError(t, gzipw.Close())
return buff.Bytes(), hex.EncodeToString(hash.Sum(nil))
}
// writeFile creates a file at the given path with the given contents.
func writeFile(t testing.TB, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
}