mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-30 16:56:22 -04:00
* feat(plugins): export ReadPackageManifest and ValidatePackage for off-disk inspection * test(plugins): cover ValidatePackage cross-field validation branch * feat(cmd): add 'plugin list' command * refactor(cmd): drop premature pluginManager interface until first use * feat(cmd): add 'plugin enable' and 'plugin disable' commands * feat(cmd): add 'plugin edit' for config and permission updates * test(cmd): cover plugin edit aborting on config validation failure * feat(cmd): add 'plugin info' and 'plugin validate' (installed id or .ndp file) * test(cmd): unit-test formatManifestInfo nil-guard and json branches * feat(cmd): add 'plugin rescan' command * feat(cmd): enrich plugin info text output and re-validate config on validate * fix(cmd): omit zero-value timestamps in plugin info text output * fix(cmd): give plugin list and info separate format flag vars A shared package-global bound to both commands' --format flag caused the later init() registration (info, default "text") to clobber the earlier one (list, default "table"), so 'plugin list' with no -f failed with 'invalid output format "text"'. Split into pluginListFormat / pluginInfoFormat and add a regression test on the registered defaults. * fix(plugins): address code-review findings on plugin CLI - edit: read-modify-write merge so flipping one permission flag no longer wipes unspecified fields (matches the native API); reject non-JSON --users/--libraries values. - info: return an error on unknown --format (was silently text); include sha256 in off-disk JSON output; align the text columns. - move declaredPermissions onto plugins.Permissions.DeclaredNames() as the single source of truth; drop ValidatePackage's redundant Validate call. - readConfigFile: pass ctx to log.Fatal; copy flag globals before taking their address; trim comments that restated the code. * refactor(plugins): export ReadManifest/ComputeFileSHA256, drop thin CLI wrappers Remove the ReadPackageManifest/ComputeFileSHA256/ValidatePackage wrappers in favor of exporting the underlying readManifest->ReadManifest and computeFileSHA256->ComputeFileSHA256 directly. ValidatePackage was identical to ReadPackageManifest (readManifest already validates via ParseManifest), so the CLI's validate path now calls ReadManifest too. * test(plugins): consolidate ReadManifest specs and move ComputeFileSHA256 tests Merge the two ReadManifest Describe blocks into one, and move the ComputeFileSHA256 tests out of package_test.go into a new manager_sync_test.go (where the function now lives), deduping the overlapping hash specs. * test(plugins): use createTestPackage everywhere, drop writeNDP helper The schema-invalid and cross-field ReadManifest specs now build packages with createTestPackage (typed Manifest) instead of the raw-JSON writeNDP helper, so there is a single package-building helper. Also trims the gratuitous 1MB wasm buffer in the read-only spec to a few bytes. * test(plugins): drop duplicate missing-manifest spec from ReadManifest The openPackage block already covers the missing-manifest.json error path (shared zip-walk code); ReadManifest's identical copy added no distinct coverage. * test(plugins): fix misleading ReadManifest spec name and drop unused wasm bytes The spec was named 'should read only the manifest without loading wasm' but ReadManifest returns only (*Manifest, error) — it cannot assert whether wasm was loaded. Rename to what it actually verifies (manifest parses from a package that also contains a wasm entry) and pass nil wasm bytes, since the contents are never observed. * fix(cmd): address PR review feedback on plugin CLI - edit --users/--libraries now accept both comma-separated and JSON-array input (CSV is converted to the JSON the manager stores); help text documents both. - Permissions.DeclaredNames() derives names by reflecting over the generated struct's json tags instead of a hand-maintained list, so new permission types are picked up automatically. - isPackagePath checks only the .ndp suffix, so a mistyped package path yields a precise 'no such file' error instead of falling back to a misleading 'Plugin not found'. - Restore a ReadManifest test for the missing-manifest.json error path (it has its own branch, separate from openPackage). * docs(cmd): trim verbose comments to the why * fix(cmd): setting an explicit users/libraries list clears the all-* flag Per Codex review: with allUsers/allLibraries true, 'plugin edit --users <list>' preserved the all-* flag so the allow-list was silently ignored, and the mutually-exclusive flags meant it couldn't be corrected in one command. An explicit list now implies all-*=false.
273 lines
7.6 KiB
Go
273 lines
7.6 KiB
Go
package plugins
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("ndpPackage", func() {
|
|
var tmpDir string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "plugin-package-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.RemoveAll(tmpDir)
|
|
})
|
|
|
|
Describe("openPackage", func() {
|
|
It("should load a valid .ndp package", func() {
|
|
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
|
manifest := &Manifest{
|
|
Name: "Test Plugin",
|
|
Author: "Test Author",
|
|
Version: "1.0.0",
|
|
}
|
|
wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header
|
|
|
|
err := createTestPackage(ndpPath, manifest, wasmBytes)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
pkg, err := openPackage(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(pkg.Manifest.Name).To(Equal("Test Plugin"))
|
|
Expect(pkg.Manifest.Author).To(Equal("Test Author"))
|
|
Expect(pkg.Manifest.Version).To(Equal("1.0.0"))
|
|
Expect(pkg.WasmBytes).To(Equal(wasmBytes))
|
|
})
|
|
|
|
It("should return error for missing manifest.json", func() {
|
|
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
|
|
|
// Create a zip with only plugin.wasm
|
|
f, err := os.Create(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer f.Close()
|
|
|
|
zw := newTestZipWriter(f)
|
|
err = zw.addFile("plugin.wasm", []byte{0x00})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = openPackage(ndpPath)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("missing manifest.json"))
|
|
})
|
|
|
|
It("should return error for missing plugin.wasm", func() {
|
|
ndpPath := filepath.Join(tmpDir, "no-wasm.ndp")
|
|
|
|
// Create a zip with only manifest.json
|
|
f, err := os.Create(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer f.Close()
|
|
|
|
zw := newTestZipWriter(f)
|
|
err = zw.addFile("manifest.json", []byte(`{"name":"Test","author":"Test","version":"1.0.0"}`))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = openPackage(ndpPath)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("missing plugin.wasm"))
|
|
})
|
|
|
|
It("should return error for invalid manifest JSON", func() {
|
|
ndpPath := filepath.Join(tmpDir, "invalid-json.ndp")
|
|
|
|
f, err := os.Create(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer f.Close()
|
|
|
|
zw := newTestZipWriter(f)
|
|
err = zw.addFile("manifest.json", []byte(`{invalid json}`))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.addFile("plugin.wasm", []byte{0x00})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = openPackage(ndpPath)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("parsing manifest"))
|
|
})
|
|
|
|
It("should return error for manifest missing required fields", func() {
|
|
ndpPath := filepath.Join(tmpDir, "invalid-manifest.ndp")
|
|
|
|
f, err := os.Create(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer f.Close()
|
|
|
|
zw := newTestZipWriter(f)
|
|
err = zw.addFile("manifest.json", []byte(`{"name":"Test"}`)) // Missing author and version
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.addFile("plugin.wasm", []byte{0x00})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = zw.close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = openPackage(ndpPath)
|
|
Expect(err).To(HaveOccurred())
|
|
// JSON schema validation happens during unmarshaling
|
|
Expect(err.Error()).To(ContainSubstring("parsing manifest"))
|
|
Expect(err.Error()).To(ContainSubstring("author"))
|
|
})
|
|
|
|
It("should return error for non-existent file", func() {
|
|
_, err := openPackage(filepath.Join(tmpDir, "nonexistent.ndp"))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("opening package"))
|
|
})
|
|
})
|
|
|
|
Describe("ReadManifest", func() {
|
|
It("parses the manifest from a package that also contains wasm", func() {
|
|
ndpPath := filepath.Join(tmpDir, "test.ndp")
|
|
manifest := &Manifest{
|
|
Name: "Test Plugin",
|
|
Author: "Test Author",
|
|
Version: "1.0.0",
|
|
Description: new("A test plugin"),
|
|
}
|
|
|
|
err := createTestPackage(ndpPath, manifest, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
m, err := ReadManifest(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(m.Name).To(Equal("Test Plugin"))
|
|
Expect(*m.Description).To(Equal("A test plugin"))
|
|
})
|
|
|
|
It("returns an error for a non-existent file", func() {
|
|
_, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp"))
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns a specific error for a package missing manifest.json", func() {
|
|
ndpPath := filepath.Join(tmpDir, "no-manifest.ndp")
|
|
f, err := os.Create(ndpPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer f.Close()
|
|
zw := newTestZipWriter(f)
|
|
Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed())
|
|
Expect(zw.close()).To(Succeed())
|
|
|
|
_, err = ReadManifest(ndpPath)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("missing manifest.json"))
|
|
})
|
|
|
|
It("fails for a package with a schema-invalid manifest", func() {
|
|
ndp := filepath.Join(tmpDir, "bad.ndp")
|
|
// empty required fields violate the manifest JSON schema
|
|
err := createTestPackage(ndp, &Manifest{}, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
_, err = ReadManifest(ndp)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("enforces cross-field validation", func() {
|
|
ndp := filepath.Join(tmpDir, "crossfield.ndp")
|
|
// subsonicapi permission without users: violates cross-field rule
|
|
manifest := &Manifest{
|
|
Name: "X",
|
|
Author: "me",
|
|
Version: "1.0.0",
|
|
Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}},
|
|
}
|
|
err := createTestPackage(ndp, manifest, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = ReadManifest(ndp)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
|
|
Expect(err.Error()).To(ContainSubstring("users"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// testZipHelper is a helper for creating test zip files with specific contents
|
|
type testZipHelper struct {
|
|
f *os.File
|
|
entries []zipEntry
|
|
}
|
|
|
|
type zipEntry struct {
|
|
name string
|
|
data []byte
|
|
}
|
|
|
|
func newTestZipWriter(f *os.File) *testZipHelper {
|
|
return &testZipHelper{f: f}
|
|
}
|
|
|
|
func (h *testZipHelper) addFile(name string, data []byte) error {
|
|
h.entries = append(h.entries, zipEntry{name: name, data: data})
|
|
return nil
|
|
}
|
|
|
|
func (h *testZipHelper) close() error {
|
|
zw := zip.NewWriter(h.f)
|
|
for _, e := range h.entries {
|
|
w, err := zw.Create(e.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write(e.data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return zw.Close()
|
|
}
|
|
|
|
// createTestPackage creates an .ndp package file from a manifest and wasm bytes.
|
|
// This is primarily used for testing.
|
|
func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error {
|
|
f, err := os.Create(ndpPath)
|
|
if err != nil {
|
|
return fmt.Errorf("creating package file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
zw := zip.NewWriter(f)
|
|
defer zw.Close()
|
|
|
|
// Write manifest.json
|
|
manifestBytes, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling manifest: %w", err)
|
|
}
|
|
|
|
mw, err := zw.Create(manifestFileName)
|
|
if err != nil {
|
|
return fmt.Errorf("creating manifest in zip: %w", err)
|
|
}
|
|
if _, err := mw.Write(manifestBytes); err != nil {
|
|
return fmt.Errorf("writing manifest: %w", err)
|
|
}
|
|
|
|
// Write plugin.wasm
|
|
ww, err := zw.Create(wasmFileName)
|
|
if err != nil {
|
|
return fmt.Errorf("creating wasm in zip: %w", err)
|
|
}
|
|
if _, err := ww.Write(wasmBytes); err != nil {
|
|
return fmt.Errorf("writing wasm: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|