mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-16 01:43:24 +09:00
enhance(packages/npm): expand version metadata and support npm deprecate (#37890)
Fixes #21624 Adds the npm package version metadata fields that Gitea's npm registry was previously dropping on publish, and implements the `npm deprecate` command, which Gitea did not accept before. ### New / pass-through metadata fields The following are now parsed from the publish payload, persisted in the stored `npm.Metadata`, and re-emitted on the abbreviated version manifest returned to npm clients: - `hasInstallScript` — auto-detected from `scripts.preinstall` / `scripts.install` / `scripts.postinstall` (also honors a client-supplied value). Without this flag, `npm install` skips lifecycle scripts. - `_hasShrinkwrap` — authoritatively derived by inspecting the uploaded tarball for a top-level `*/npm-shrinkwrap.json` entry. Client-supplied values are ignored. Decompression failures fall back to `false` and do not block publish (integrity has already been validated). - `engines` (`map[string]string`) - `cpu`, `os` (`[]string`) - `directories` (`map[string]string`) - `funding` (`any`; preserves the spec's string / object / array shape) - `acceptDependencies` (`map[string]string`) - `deprecated` (`string`) `peerDependenciesMeta` was already in the stored struct but is now exercised by tests. ### `npm deprecate` support `npm deprecate <pkg-spec> <message>` PUTs the package document to the same URL as publish but with no `_attachments`. The router now detects that shape and routes to a new handler that updates each affected version's stored `Metadata.Deprecated` via `packages_model.UpdateVersion`. An empty message clears the flag (undeprecate). Unknown versions are silently skipped, matching npm's behavior. No new routes were added. Supported invocations include: - `npm deprecate my-thing@"< 0.2.3" "critical bug fixed in v0.2.3"` - `npm deprecate my-thing@1.x "1.x is no longer supported"` - `npm deprecate my-thing@1.0.0 ""` (undeprecate) --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
co-authored by
wxiaoguang
bircni
parent
c2ebe3724f
commit
6487d14855
@@ -175,14 +175,14 @@ func TestParsePackageInfo(t *testing.T) {
|
|||||||
// metadata amplification from a package with a huge number of (tiny) file entries.
|
// metadata amplification from a package with a huge number of (tiny) file entries.
|
||||||
func TestParsePackageTooManyFiles(t *testing.T) {
|
func TestParsePackageTooManyFiles(t *testing.T) {
|
||||||
defer test.MockVariableValue(&maxFileEntries, 3)()
|
defer test.MockVariableValue(&maxFileEntries, 3)()
|
||||||
buf := test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
buf := test.WriteTarCompression(gzip.NewWriter, map[string]string{
|
||||||
"file1": "content1",
|
"file1": "content1",
|
||||||
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
||||||
})
|
})
|
||||||
_, err := ParsePackage(buf)
|
_, err := ParsePackage(buf)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
buf = test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
buf = test.WriteTarCompression(gzip.NewWriter, map[string]string{
|
||||||
"file1": "content1",
|
"file1": "content1",
|
||||||
"file2": "content2",
|
"file2": "content2",
|
||||||
"file3": "content3",
|
"file3": "content3",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package npm
|
package npm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -108,6 +110,15 @@ type PackageMetadataVersion struct {
|
|||||||
Readme string `json:"readme,omitempty"`
|
Readme string `json:"readme,omitempty"`
|
||||||
Dist PackageDistribution `json:"dist"`
|
Dist PackageDistribution `json:"dist"`
|
||||||
Maintainers []User `json:"maintainers,omitempty"`
|
Maintainers []User `json:"maintainers,omitempty"`
|
||||||
|
HasInstallScript bool `json:"hasInstallScript,omitempty"`
|
||||||
|
HasShrinkwrap bool `json:"_hasShrinkwrap,omitempty"`
|
||||||
|
Engines map[string]string `json:"engines,omitempty"`
|
||||||
|
CPU []string `json:"cpu,omitempty"`
|
||||||
|
OS []string `json:"os,omitempty"`
|
||||||
|
Directories map[string]string `json:"directories,omitempty"`
|
||||||
|
Funding any `json:"funding,omitempty"`
|
||||||
|
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
|
||||||
|
Deprecated string `json:"deprecated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
|
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
|
||||||
@@ -243,13 +254,38 @@ type packageUpload struct {
|
|||||||
Attachments map[string]*PackageAttachment `json:"_attachments"`
|
Attachments map[string]*PackageAttachment `json:"_attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsePackage parses the content into a npm package
|
// ParseUpload decodes a npm PUT body. Exactly one of the returned pointers
|
||||||
|
// is non-nil on success; a body without `_attachments` is a deprecate request,
|
||||||
|
// otherwise it is a "publish".
|
||||||
|
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var upload packageUpload
|
||||||
|
if err := json.Unmarshal(body, &upload); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if len(upload.Attachments) == 0 {
|
||||||
|
dep, err := parseUploadDeprecation(&upload, body)
|
||||||
|
return nil, dep, err
|
||||||
|
}
|
||||||
|
p, err := parseUploadPackage(&upload)
|
||||||
|
return p, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
|
||||||
|
// surface as ErrInvalidAttachment once name/version validation has passed.
|
||||||
func ParsePackage(r io.Reader) (*Package, error) {
|
func ParsePackage(r io.Reader) (*Package, error) {
|
||||||
var upload packageUpload
|
var upload packageUpload
|
||||||
if err := json.NewDecoder(r).Decode(&upload); err != nil {
|
if err := json.NewDecoder(r).Decode(&upload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return parseUploadPackage(&upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUploadPackage builds a Package from a decoded publish body.
|
||||||
|
func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||||
for _, meta := range upload.Versions {
|
for _, meta := range upload.Versions {
|
||||||
if !validateName(meta.Name) {
|
if !validateName(meta.Name) {
|
||||||
return nil, ErrInvalidPackageName
|
return nil, ErrInvalidPackageName
|
||||||
@@ -298,6 +334,13 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
|||||||
Bin: meta.Bin,
|
Bin: meta.Bin,
|
||||||
Readme: meta.Readme,
|
Readme: meta.Readme,
|
||||||
Repository: meta.Repository,
|
Repository: meta.Repository,
|
||||||
|
Engines: meta.Engines,
|
||||||
|
CPU: meta.CPU,
|
||||||
|
OS: meta.OS,
|
||||||
|
Directories: meta.Directories,
|
||||||
|
Funding: meta.Funding,
|
||||||
|
AcceptDependencies: meta.AcceptDependencies,
|
||||||
|
Deprecated: meta.Deprecated,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,12 +387,75 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
|||||||
return nil, ErrInvalidIntegrity
|
return nil, ErrInvalidIntegrity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
|
||||||
|
// packument can lie about either.
|
||||||
|
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrInvalidPackage
|
return nil, ErrInvalidPackage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxNpmTarballScanBytes caps the decompressed tarball bytes inspectTarball
|
||||||
|
// will read; defends against gzip bombs.
|
||||||
|
const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
|
||||||
|
|
||||||
|
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
|
||||||
|
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
|
||||||
|
|
||||||
|
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
|
||||||
|
// and hasInstallScript (package/package.json declares any of preinstall,
|
||||||
|
// install, postinstall). Both must be derived server-side because the client
|
||||||
|
// can lie in the packument. Any read/decode error yields (false, false) so a
|
||||||
|
// malformed archive does not block publishing.
|
||||||
|
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||||
|
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err != nil {
|
||||||
|
return hasShrinkwrap, hasInstallScript
|
||||||
|
}
|
||||||
|
// npm pack puts files under a single root directory (usually "package/").
|
||||||
|
name := strings.TrimPrefix(hdr.Name, "./")
|
||||||
|
if strings.Count(name, "/") != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
|
||||||
|
hasShrinkwrap = true
|
||||||
|
case strings.HasSuffix(name, "/package.json"):
|
||||||
|
hasInstallScript = tarballDeclaresInstallScript(tr)
|
||||||
|
}
|
||||||
|
if hasShrinkwrap && hasInstallScript {
|
||||||
|
return hasShrinkwrap, hasInstallScript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarballDeclaresInstallScript reports whether a package.json declares any
|
||||||
|
// of preinstall, install, postinstall.
|
||||||
|
func tarballDeclaresInstallScript(r io.Reader) bool {
|
||||||
|
var pkg struct {
|
||||||
|
Scripts map[string]string `json:"scripts"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, name := range []string{"preinstall", "install", "postinstall"} {
|
||||||
|
if strings.TrimSpace(pkg.Scripts[name]) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func validateName(name string) bool {
|
func validateName(name string) bool {
|
||||||
if strings.TrimSpace(name) != name {
|
if strings.TrimSpace(name) != name {
|
||||||
return false
|
return false
|
||||||
@@ -359,3 +465,41 @@ func validateName(name string) bool {
|
|||||||
}
|
}
|
||||||
return nameMatch.MatchString(name)
|
return nameMatch.MatchString(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PackageDeprecation is the result of parsing an npm deprecate request body.
|
||||||
|
// Versions maps a version string to its deprecation message; an empty message
|
||||||
|
// means "undeprecate".
|
||||||
|
type PackageDeprecation struct {
|
||||||
|
PackageName string
|
||||||
|
Versions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUploadDeprecation builds a PackageDeprecation from a body with no
|
||||||
|
// `_attachments`. Only versions whose object explicitly contained a
|
||||||
|
// `deprecated` key are emitted, so a subset PUT cannot silently undeprecate
|
||||||
|
// versions it omitted. An empty string still means "undeprecate".
|
||||||
|
func parseUploadDeprecation(upload *packageUpload, body []byte) (*PackageDeprecation, error) {
|
||||||
|
if !validateName(upload.Name) {
|
||||||
|
return nil, ErrInvalidPackageName
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
Versions map[string]map[string]any `json:"versions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d := &PackageDeprecation{
|
||||||
|
PackageName: upload.Name,
|
||||||
|
Versions: make(map[string]string, len(raw.Versions)),
|
||||||
|
}
|
||||||
|
for v, meta := range upload.Versions {
|
||||||
|
if meta == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := raw.Versions[v]["deprecated"]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.Versions[v] = meta.Deprecated
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ package npm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha512"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -25,14 +28,18 @@ func TestParsePackage(t *testing.T) {
|
|||||||
packageAuthor := "KN4CK3R"
|
packageAuthor := "KN4CK3R"
|
||||||
packageBin := "gitea"
|
packageBin := "gitea"
|
||||||
packageDescription := "Test Description"
|
packageDescription := "Test Description"
|
||||||
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
|
|
||||||
integrity := "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg=="
|
|
||||||
repository := Repository{
|
repository := Repository{
|
||||||
Type: "gitea",
|
Type: "gitea",
|
||||||
URL: "http://localhost:3000/gitea/test.git",
|
URL: "http://localhost:3000/gitea/test.git",
|
||||||
Directory: "packages/test-package",
|
Directory: "packages/test-package",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataBytes := buildTarball(map[string]string{
|
||||||
|
"package/package.json": `{"name": "@scope/test-package","version": "1.0.1-pre","description": "Test Description","author": "KN4CK3R"}`,
|
||||||
|
})
|
||||||
|
data := base64.StdEncoding.EncodeToString(dataBytes)
|
||||||
|
integrity := "sha512-" + base64Sha512(dataBytes)
|
||||||
|
|
||||||
t.Run("InvalidUpload", func(t *testing.T) {
|
t.Run("InvalidUpload", func(t *testing.T) {
|
||||||
p, err := ParsePackage(bytes.NewReader([]byte{0}))
|
p, err := ParsePackage(bytes.NewReader([]byte{0}))
|
||||||
assert.Nil(t, p)
|
assert.Nil(t, p)
|
||||||
@@ -354,3 +361,143 @@ func TestParsePackage(t *testing.T) {
|
|||||||
require.Equal(t, "./cli.js", p.Metadata.Bin["dev-null"])
|
require.Equal(t, "./cli.js", p.Metadata.Bin["dev-null"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTarball assembles a gzipped tar with the given entries.
|
||||||
|
func buildTarball(files map[string]string) []byte {
|
||||||
|
return test.WriteTarCompression(gzip.NewWriter, files).Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectTarball(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
wantShrinkwrap, wantInstaller bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
files: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shrinkwrap only",
|
||||||
|
files: map[string]string{"package/npm-shrinkwrap.json": "{}"},
|
||||||
|
wantShrinkwrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postinstall only",
|
||||||
|
files: map[string]string{"package/package.json": `{"scripts":{"postinstall":"echo hi"}}`},
|
||||||
|
wantInstaller: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preinstall",
|
||||||
|
files: map[string]string{"package/package.json": `{"scripts":{"preinstall":"noop"}}`},
|
||||||
|
wantInstaller: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "install",
|
||||||
|
files: map[string]string{"package/package.json": `{"scripts":{"install":"noop"}}`},
|
||||||
|
wantInstaller: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace-only script does not count",
|
||||||
|
files: map[string]string{"package/package.json": `{"scripts":{"postinstall":" "}}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated lifecycle script ignored",
|
||||||
|
files: map[string]string{"package/package.json": `{"scripts":{"test":"jest"}}`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both",
|
||||||
|
files: map[string]string{
|
||||||
|
"package/npm-shrinkwrap.json": "{}",
|
||||||
|
"package/package.json": `{"scripts":{"install":"go"}}`,
|
||||||
|
},
|
||||||
|
wantShrinkwrap: true,
|
||||||
|
wantInstaller: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested shrinkwrap ignored",
|
||||||
|
files: map[string]string{
|
||||||
|
"package/subdir/npm-shrinkwrap.json": "{}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading ./ prefix stripped",
|
||||||
|
files: map[string]string{"./package/npm-shrinkwrap.json": "{}"},
|
||||||
|
// npm pack sometimes emits "./package/..." entries.
|
||||||
|
wantShrinkwrap: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
data := buildTarball(c.files)
|
||||||
|
gotShrink, gotInstaller := inspectTarball(data)
|
||||||
|
assert.Equal(t, c.wantShrinkwrap, gotShrink, "shrinkwrap")
|
||||||
|
assert.Equal(t, c.wantInstaller, gotInstaller, "installScript")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("malformed gzip returns false", func(t *testing.T) {
|
||||||
|
hasShrinkwrap, hasInstaller := inspectTarball([]byte("not a gzip"))
|
||||||
|
assert.False(t, hasShrinkwrap)
|
||||||
|
assert.False(t, hasInstaller)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed package.json falls through", func(t *testing.T) {
|
||||||
|
data := buildTarball(map[string]string{"package/package.json": "{ this is not json"})
|
||||||
|
_, hasInstaller := inspectTarball(data)
|
||||||
|
assert.False(t, hasInstaller)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUpload(t *testing.T) {
|
||||||
|
pkg := "@scope/test-package"
|
||||||
|
|
||||||
|
t.Run("dispatches deprecate on missing _attachments", func(t *testing.T) {
|
||||||
|
body := fmt.Sprintf(`{"name":%q,"versions":{"1.0.0":{"deprecated":"gone"},"1.0.1":{"deprecated":""},"1.0.2":{},"1.0.3":null}}`, pkg)
|
||||||
|
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, p)
|
||||||
|
require.NotNil(t, dep)
|
||||||
|
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
|
||||||
|
// Reuse a minimal tarball with a package.json.
|
||||||
|
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||||
|
integrity := "sha512-" + base64Sha512(data)
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||||
|
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||||
|
)
|
||||||
|
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, dep)
|
||||||
|
require.NotNil(t, p)
|
||||||
|
assert.Equal(t, pkg, p.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
|
||||||
|
// The old fast-path used a substring check for "deprecated"; make sure
|
||||||
|
// the new dispatch keys off _attachments only.
|
||||||
|
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||||
|
integrity := "sha512-" + base64Sha512(data)
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||||
|
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||||
|
)
|
||||||
|
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, dep)
|
||||||
|
require.NotNil(t, p)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid json errors out", func(t *testing.T) {
|
||||||
|
_, _, err := ParseUpload(strings.NewReader("not json"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64Sha512(data []byte) string {
|
||||||
|
h := sha512.Sum512(data)
|
||||||
|
return base64.StdEncoding.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,4 +24,13 @@ type Metadata struct {
|
|||||||
Bin map[string]string `json:"bin,omitempty"`
|
Bin map[string]string `json:"bin,omitempty"`
|
||||||
Readme string `json:"readme,omitempty"`
|
Readme string `json:"readme,omitempty"`
|
||||||
Repository Repository `json:"repository"`
|
Repository Repository `json:"repository"`
|
||||||
|
HasInstallScript bool `json:"has_install_script,omitempty"`
|
||||||
|
HasShrinkwrap bool `json:"has_shrinkwrap,omitempty"`
|
||||||
|
Engines map[string]string `json:"engines,omitempty"`
|
||||||
|
CPU []string `json:"cpu,omitempty"`
|
||||||
|
OS []string `json:"os,omitempty"`
|
||||||
|
Directories map[string]string `json:"directories,omitempty"`
|
||||||
|
Funding any `json:"funding,omitempty"`
|
||||||
|
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
|
||||||
|
Deprecated string `json:"deprecated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-14
@@ -102,10 +102,6 @@ func ReadAllTarGzContent(r io.Reader) (map[string]string, error) {
|
|||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteTarArchive(files map[string]string) *bytes.Buffer {
|
|
||||||
return WriteTarCompression(func(w io.Writer) io.WriteCloser { return util.NopCloser{Writer: w} }, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteZipArchive(files map[string]string) *bytes.Buffer {
|
func WriteZipArchive(files map[string]string) *bytes.Buffer {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
zw := zip.NewWriter(buf)
|
zw := zip.NewWriter(buf)
|
||||||
@@ -117,17 +113,15 @@ func WriteZipArchive(files map[string]string) *bytes.Buffer {
|
|||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteTarCompression[F func(io.Writer) io.WriteCloser | func(io.Writer) (io.WriteCloser, error)](compression F, files map[string]string) *bytes.Buffer {
|
func WriteTarArchive(files map[string]string) *bytes.Buffer {
|
||||||
buf := &bytes.Buffer{}
|
return WriteTarCompression(func(w io.Writer) io.WriteCloser { return util.NopCloser{Writer: w} }, files)
|
||||||
var cw io.WriteCloser
|
}
|
||||||
switch compressFunc := any(compression).(type) {
|
|
||||||
case func(io.Writer) io.WriteCloser:
|
|
||||||
cw = compressFunc(buf)
|
|
||||||
case func(io.Writer) (io.WriteCloser, error):
|
|
||||||
cw, _ = compressFunc(buf)
|
|
||||||
}
|
|
||||||
tw := tar.NewWriter(cw)
|
|
||||||
|
|
||||||
|
func WriteTarCompression[WC io.WriteCloser, F func(io.Writer) WC](compression F, files map[string]string) *bytes.Buffer {
|
||||||
|
// TODO: to support xz.NewWriter which returns (*Writer, error), it needs a separate function due to the limit of Golang generic
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
cw := compression(buf)
|
||||||
|
tw := tar.NewWriter(cw)
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
hdr := &tar.Header{
|
hdr := &tar.Header{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
|||||||
OptionalDependencies: metadata.OptionalDependencies,
|
OptionalDependencies: metadata.OptionalDependencies,
|
||||||
Readme: metadata.Readme,
|
Readme: metadata.Readme,
|
||||||
Bin: metadata.Bin,
|
Bin: metadata.Bin,
|
||||||
|
HasInstallScript: metadata.HasInstallScript,
|
||||||
|
HasShrinkwrap: metadata.HasShrinkwrap,
|
||||||
|
Engines: metadata.Engines,
|
||||||
|
CPU: metadata.CPU,
|
||||||
|
OS: metadata.OS,
|
||||||
|
Directories: metadata.Directories,
|
||||||
|
Funding: metadata.Funding,
|
||||||
|
AcceptDependencies: metadata.AcceptDependencies,
|
||||||
|
Deprecated: metadata.Deprecated,
|
||||||
Dist: npm_module.PackageDistribution{
|
Dist: npm_module.PackageDistribution{
|
||||||
Shasum: pd.Files[0].Blob.HashSHA1,
|
Shasum: pd.Files[0].Blob.HashSHA1,
|
||||||
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
packages_module "gitea.dev/modules/packages"
|
packages_module "gitea.dev/modules/packages"
|
||||||
npm_module "gitea.dev/modules/packages/npm"
|
npm_module "gitea.dev/modules/packages/npm"
|
||||||
@@ -154,7 +155,7 @@ func DownloadPackageFileByName(ctx *context.Context) {
|
|||||||
|
|
||||||
// UploadPackage creates a new package
|
// UploadPackage creates a new package
|
||||||
func UploadPackage(ctx *context.Context) {
|
func UploadPackage(ctx *context.Context) {
|
||||||
npmPackage, err := npm_module.ParsePackage(ctx.Req.Body)
|
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrInvalidArgument) {
|
if errors.Is(err, util.ErrInvalidArgument) {
|
||||||
apiError(ctx, http.StatusBadRequest, err)
|
apiError(ctx, http.StatusBadRequest, err)
|
||||||
@@ -164,6 +165,12 @@ func UploadPackage(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `npm deprecate` reuses the publish endpoint with no `_attachments`.
|
||||||
|
if deprecation != nil {
|
||||||
|
deprecatePackage(ctx, deprecation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
|
repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
canWrite := repo.OwnerID == ctx.Doer.ID
|
canWrite := repo.OwnerID == ctx.Doer.ID
|
||||||
@@ -252,6 +259,57 @@ func DeletePreview(ctx *context.Context) {
|
|||||||
ctx.Status(http.StatusOK)
|
ctx.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deprecatePackage handles an `npm deprecate` request, which is a PUT to the
|
||||||
|
// package URL with no attachments and a `deprecated` string set on each
|
||||||
|
// affected version (empty string means undeprecate).
|
||||||
|
func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation) {
|
||||||
|
if len(dep.Versions) == 0 {
|
||||||
|
apiError(ctx, http.StatusBadRequest, "npm deprecate request contains no versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run per-version updates in one transaction so a partial failure does
|
||||||
|
// not leave the package in a half-applied state.
|
||||||
|
err := db.WithTx(ctx, func(txCtx std_ctx.Context) error {
|
||||||
|
for version, message := range dep.Versions {
|
||||||
|
pv, err := packages_model.GetVersionByNameAndVersion(txCtx, ctx.Package.Owner.ID, packages_model.TypeNpm, dep.PackageName, version)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &npm_module.Metadata{}
|
||||||
|
if err := json.Unmarshal([]byte(pv.MetadataJSON), metadata); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Deprecated == message {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metadata.Deprecated = message
|
||||||
|
|
||||||
|
raw, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pv.MetadataJSON = string(raw)
|
||||||
|
|
||||||
|
if err := packages_model.UpdateVersion(txCtx, pv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
// DeletePackageVersion deletes the package version
|
// DeletePackageVersion deletes the package version
|
||||||
func DeletePackageVersion(ctx *context.Context) {
|
func DeletePackageVersion(ctx *context.Context) {
|
||||||
packageName := packageNameFromParams(ctx)
|
packageName := packageNameFromParams(ctx)
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha512"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -18,6 +22,7 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/packages/npm"
|
"gitea.dev/modules/packages/npm"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -43,7 +48,14 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
repoURL := "http://localhost:3000/gitea/test.git"
|
repoURL := "http://localhost:3000/gitea/test.git"
|
||||||
repoDirectory := "package-subdir"
|
repoDirectory := "package-subdir"
|
||||||
|
|
||||||
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
|
attachmentBytes := test.WriteTarCompression(gzip.NewWriter, map[string]string{
|
||||||
|
"package/package.json": `{"name":"` + packageName + `","version":"` + packageVersion + `","scripts":{"postinstall":"echo hi"}}`,
|
||||||
|
}).Bytes()
|
||||||
|
attachmentData := base64.StdEncoding.EncodeToString(attachmentBytes)
|
||||||
|
sha1Sum := sha1.Sum(attachmentBytes)
|
||||||
|
sha1SumHex := hex.EncodeToString(sha1Sum[:])
|
||||||
|
sha512Sum := sha512.Sum512(attachmentBytes)
|
||||||
|
integrity := "sha512-" + base64.StdEncoding.EncodeToString(sha512Sum[:])
|
||||||
|
|
||||||
buildUpload := func(version string) string {
|
buildUpload := func(version string) string {
|
||||||
return `{
|
return `{
|
||||||
@@ -65,8 +77,8 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
"` + packageBinName + `": "` + packageBinPath + `"
|
"` + packageBinName + `": "` + packageBinPath + `"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"integrity": "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==",
|
"integrity": "` + integrity + `",
|
||||||
"shasum": "aaa7eaf852a948b0aa05afeda35b1badca155d90"
|
"shasum": "` + sha1SumHex + `"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "` + repoType + `",
|
"type": "` + repoType + `",
|
||||||
@@ -82,12 +94,29 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
"soy-milk": {
|
"soy-milk": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "echo hi"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.7.0",
|
||||||
|
"npm": ">=10.8.2"
|
||||||
|
},
|
||||||
|
"cpu": ["x64", "arm64"],
|
||||||
|
"os": ["linux", "darwin"],
|
||||||
|
"directories": {
|
||||||
|
"doc": "./doc",
|
||||||
|
"man": "./man"
|
||||||
|
},
|
||||||
|
"funding": "https://example.com/fund",
|
||||||
|
"acceptDependencies": {
|
||||||
|
"left-pad": "1.x"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"_attachments": {
|
"_attachments": {
|
||||||
"` + packageName + `-` + version + `.tgz": {
|
"` + packageName + `-` + version + `.tgz": {
|
||||||
"data": "` + data + `"
|
"data": "` + attachmentData + `"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
@@ -126,7 +155,7 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
|
|
||||||
pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID)
|
pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, int64(192), pb.Size)
|
assert.EqualValues(t, len(attachmentBytes), pb.Size)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("UploadExists", func(t *testing.T) {
|
t.Run("UploadExists", func(t *testing.T) {
|
||||||
@@ -144,7 +173,7 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
AddTokenAuth(token)
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
b, _ := base64.StdEncoding.DecodeString(data)
|
b, _ := base64.StdEncoding.DecodeString(attachmentData)
|
||||||
assert.Equal(t, b, resp.Body.Bytes())
|
assert.Equal(t, b, resp.Body.Bytes())
|
||||||
|
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)).
|
req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)).
|
||||||
@@ -185,13 +214,22 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
assert.Equal(t, packageDescription, pmv.Description)
|
assert.Equal(t, packageDescription, pmv.Description)
|
||||||
assert.Equal(t, packageAuthor, pmv.Author.Name)
|
assert.Equal(t, packageAuthor, pmv.Author.Name)
|
||||||
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
|
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
|
||||||
assert.Equal(t, "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", pmv.Dist.Integrity)
|
assert.Equal(t, integrity, pmv.Dist.Integrity)
|
||||||
assert.Equal(t, "aaa7eaf852a948b0aa05afeda35b1badca155d90", pmv.Dist.Shasum)
|
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
|
||||||
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
|
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
|
||||||
assert.Equal(t, repoType, result.Repository.Type)
|
assert.Equal(t, repoType, result.Repository.Type)
|
||||||
assert.Equal(t, repoURL, result.Repository.URL)
|
assert.Equal(t, repoURL, result.Repository.URL)
|
||||||
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
|
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
|
||||||
assert.Equal(t, map[string]any{"soy-milk": map[string]any{"optional": true}}, pmv.PeerDependenciesMeta)
|
assert.Equal(t, map[string]any{"soy-milk": map[string]any{"optional": true}}, pmv.PeerDependenciesMeta)
|
||||||
|
assert.True(t, pmv.HasInstallScript)
|
||||||
|
assert.False(t, pmv.HasShrinkwrap)
|
||||||
|
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
|
||||||
|
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
|
||||||
|
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
|
||||||
|
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
|
||||||
|
assert.Equal(t, "https://example.com/fund", pmv.Funding)
|
||||||
|
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
|
||||||
|
assert.Empty(t, pmv.Deprecated)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("AddTag", func(t *testing.T) {
|
t.Run("AddTag", func(t *testing.T) {
|
||||||
@@ -315,6 +353,132 @@ func TestPackageNpm(t *testing.T) {
|
|||||||
`, rendered)
|
`, rendered)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Deprecate", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
deprecationMessage := "critical bug fixed in v0.2.3"
|
||||||
|
buildDeprecate := func(message string) string {
|
||||||
|
return `{
|
||||||
|
"_id": "` + packageName + `",
|
||||||
|
"name": "` + packageName + `",
|
||||||
|
"versions": {
|
||||||
|
"` + packageVersion + `": {
|
||||||
|
"name": "` + packageName + `",
|
||||||
|
"version": "` + packageVersion + `",
|
||||||
|
"deprecated": "` + message + `"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", root).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||||
|
|
||||||
|
assert.Contains(t, result.Versions, packageVersion)
|
||||||
|
assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated)
|
||||||
|
|
||||||
|
// Empty deprecation message clears the flag (undeprecate).
|
||||||
|
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", root).AddTokenAuth(token)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
result = DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||||
|
assert.Empty(t, result.Versions[packageVersion].Deprecated)
|
||||||
|
|
||||||
|
// Unknown versions are silently skipped (idempotent); the known
|
||||||
|
// version is still updated.
|
||||||
|
mixedBody := `{
|
||||||
|
"_id": "` + packageName + `",
|
||||||
|
"name": "` + packageName + `",
|
||||||
|
"versions": {
|
||||||
|
"` + packageVersion + `": {
|
||||||
|
"name": "` + packageName + `",
|
||||||
|
"version": "` + packageVersion + `",
|
||||||
|
"deprecated": "` + deprecationMessage + `"
|
||||||
|
},
|
||||||
|
"99.99.99": {
|
||||||
|
"name": "` + packageName + `",
|
||||||
|
"version": "99.99.99",
|
||||||
|
"deprecated": "ghost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(mixedBody)).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", root).AddTokenAuth(token)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
result = DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||||
|
assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated)
|
||||||
|
assert.NotContains(t, result.Versions, "99.99.99")
|
||||||
|
|
||||||
|
// Restore the cleared state for subsequent subtests.
|
||||||
|
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// A user who has no write access to the URL owner's packages must
|
||||||
|
// be rejected by reqPackageAccess before deprecatePackage runs.
|
||||||
|
otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
otherToken := "Bearer " + getTokenForLoggedInUser(t, loginUser(t, otherUser.Name), auth_model.AccessTokenScopeWritePackage)
|
||||||
|
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))).
|
||||||
|
AddTokenAuth(otherToken)
|
||||||
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UploadHasShrinkwrapClaim", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
// The tarball in `data` does not contain npm-shrinkwrap.json.
|
||||||
|
// Even when the client claims _hasShrinkwrap: true, the
|
||||||
|
// authoritative tarball scan must overrule the claim.
|
||||||
|
claimPackageName := "@scope/test-shrinkwrap-claim"
|
||||||
|
claimVersion := "1.0.0"
|
||||||
|
claimRoot := fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, url.QueryEscape(claimPackageName))
|
||||||
|
body := `{
|
||||||
|
"_id": "` + claimPackageName + `",
|
||||||
|
"name": "` + claimPackageName + `",
|
||||||
|
"versions": {
|
||||||
|
"` + claimVersion + `": {
|
||||||
|
"name": "` + claimPackageName + `",
|
||||||
|
"version": "` + claimVersion + `",
|
||||||
|
"_hasShrinkwrap": true,
|
||||||
|
"dist": {
|
||||||
|
"integrity": "` + integrity + `",
|
||||||
|
"shasum": "` + sha1SumHex + `"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_attachments": {
|
||||||
|
"` + claimPackageName + `-` + claimVersion + `.tgz": {
|
||||||
|
"data": "` + attachmentData + `"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
req := NewRequestWithBody(t, "PUT", claimRoot, strings.NewReader(body)).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", claimRoot).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||||
|
require.Contains(t, result.Versions, claimVersion)
|
||||||
|
assert.False(t, result.Versions[claimVersion].HasShrinkwrap, "client-claimed _hasShrinkwrap must be overridden by tarball scan")
|
||||||
|
|
||||||
|
// Clean up so the subsequent Delete subtest's version counts match.
|
||||||
|
req = NewRequest(t, "DELETE", claimRoot+"/-rev/dummy").AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Delete", func(t *testing.T) {
|
t.Run("Delete", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user