mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-02 13:56:55 -05:00
* feat(plugins): mount library directories as read-only by default Add an AllowWriteAccess boolean to the plugin model, defaulting to false. When off, library directories are mounted with the extism "ro:" prefix (read-only). Admins can explicitly grant write access via a new toggle in the Library Permission card. * test: add tests to buildAllowedPaths Signed-off-by: Deluan <deluan@navidrome.org> * chore: improve allowed paths logging for library access Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
125 lines
4.2 KiB
Go
125 lines
4.2 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("parsePluginConfig", func() {
|
|
It("returns nil for empty string", func() {
|
|
result, err := parsePluginConfig("")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(BeNil())
|
|
})
|
|
|
|
It("serializes object values as JSON strings", func() {
|
|
result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(1))
|
|
Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`))
|
|
})
|
|
|
|
It("handles mixed value types", func() {
|
|
result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(5))
|
|
Expect(result["api_key"]).To(Equal("secret"))
|
|
Expect(result["timeout"]).To(Equal("30"))
|
|
Expect(result["rate"]).To(Equal("1.5"))
|
|
Expect(result["enabled"]).To(Equal("true"))
|
|
Expect(result["tags"]).To(Equal(`["a","b"]`))
|
|
})
|
|
|
|
It("returns error for invalid JSON", func() {
|
|
_, err := parsePluginConfig(`{invalid json}`)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
|
|
})
|
|
|
|
It("returns error for non-object JSON", func() {
|
|
_, err := parsePluginConfig(`["array", "not", "object"]`)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
|
|
})
|
|
|
|
It("handles null values", func() {
|
|
result, err := parsePluginConfig(`{"key": null}`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(1))
|
|
Expect(result["key"]).To(Equal("null"))
|
|
})
|
|
|
|
It("handles empty object", func() {
|
|
result, err := parsePluginConfig(`{}`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(HaveLen(0))
|
|
Expect(result).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("buildAllowedPaths", func() {
|
|
var libraries model.Libraries
|
|
|
|
BeforeEach(func() {
|
|
libraries = model.Libraries{
|
|
{ID: 1, Path: "/music/library1"},
|
|
{ID: 2, Path: "/music/library2"},
|
|
{ID: 3, Path: "/music/library3"},
|
|
}
|
|
})
|
|
|
|
Context("read-only (default)", func() {
|
|
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
|
|
result := buildAllowedPaths(nil, libraries, nil, true, false)
|
|
Expect(result).To(HaveLen(3))
|
|
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
|
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
|
|
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
|
})
|
|
|
|
It("mounts only selected libraries with ro: prefix", func() {
|
|
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
|
|
Expect(result).To(HaveLen(2))
|
|
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
|
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
|
Expect(result).ToNot(HaveKey("ro:/music/library2"))
|
|
})
|
|
})
|
|
|
|
Context("read-write (allowWriteAccess=true)", func() {
|
|
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
|
|
result := buildAllowedPaths(nil, libraries, nil, true, true)
|
|
Expect(result).To(HaveLen(3))
|
|
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
|
|
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
|
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
|
|
})
|
|
|
|
It("mounts only selected libraries without ro: prefix", func() {
|
|
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
|
|
Expect(result).To(HaveLen(1))
|
|
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
|
})
|
|
})
|
|
|
|
Context("edge cases", func() {
|
|
It("returns empty map when no libraries match", func() {
|
|
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
|
|
Expect(result).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty map when libraries list is empty", func() {
|
|
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
|
|
Expect(result).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty map when allLibraries is false and no IDs provided", func() {
|
|
result := buildAllowedPaths(nil, libraries, nil, false, false)
|
|
Expect(result).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|