package pluginstore import ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "io" "net/http" "os" "path/filepath" "runtime" "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost") func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) { t.Parallel() tests := []struct { name string goos string loaded bool wantBlocked bool }{ {name: "windows loaded", goos: "windows", loaded: true, wantBlocked: true}, {name: "windows not loaded", goos: "windows", loaded: false, wantBlocked: false}, {name: "linux loaded", goos: "linux", loaded: true, wantBlocked: false}, {name: "darwin loaded", goos: "darwin", loaded: true, wantBlocked: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() _, errInstall := Client{HTTPClient: failingHTTPDoer{}}.Install(context.Background(), testPlugin(), InstallOptions{ PluginsDir: t.TempDir(), GOOS: tt.goos, GOARCH: "amd64", PluginLoaded: func() bool { return tt.loaded }, }) if errInstall == nil { t.Fatal("Install() error = nil") } if gotBlocked := errors.Is(errInstall, ErrLoadedPluginLocked); gotBlocked != tt.wantBlocked { t.Fatalf("Install() error = %v, blocked = %v, want %v", errInstall, gotBlocked, tt.wantBlocked) } }) }} func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) { t.Parallel() _, errInstall := InstallArchive(makeZip(t, map[string]string{ "sample-provider.dll": "library-data", }), testPlugin(), InstallOptions{ PluginsDir: t.TempDir(), GOOS: "windows", GOARCH: "amd64", PluginLoaded: func() bool { return true }, }) if !errors.Is(errInstall, ErrLoadedPluginLocked) { t.Fatalf("InstallArchive() error = %v, want ErrLoadedPluginLocked", errInstall) }} func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) { t.Parallel() root := t.TempDir() targetDir := filepath.Join(root, "windows", "amd64") if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } targetPath := filepath.Join(targetDir, "sample-provider.dll") if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } loaded := true prepared := false result, errInstall := InstallArchive(makeZip(t, map[string]string{ "sample-provider.dll": "new", }), testPlugin(), InstallOptions{ PluginsDir: root, GOOS: "windows", GOARCH: "amd64", PluginLoaded: func() bool { return loaded }, BeforeWrite: func() error { prepared = true loaded = false return nil }, }) if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) } if !prepared { t.Fatal("BeforeWrite was not called") } if !result.Overwritten { t.Fatal("Overwritten = false, want true") } data, errRead := os.ReadFile(targetPath) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } if string(data) != "new" { t.Fatalf("installed data = %q, want new", data) }} func TestInstallArchiveWritesPlatformPlugin(t *testing.T) { t.Parallel() root := t.TempDir() result, errInstall := InstallArchive(makeZip(t, map[string]string{ "README.md": "ignored", "sample-provider.dylib": "library-data", }), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"}) if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) } wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider.dylib") if result.Path != wantPath { t.Fatalf("Path = %q, want %q", result.Path, wantPath) } data, errRead := os.ReadFile(wantPath) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } if string(data) != "library-data" { t.Fatalf("installed data = %q", data) }} func TestInstallArchiveReportsOverwrite(t *testing.T) { t.Parallel() root := t.TempDir() targetDir := filepath.Join(root, "darwin", "arm64") if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { t.Fatalf("MkdirAll() error = %v", errMkdir) } if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider.dylib"), []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } result, errInstall := InstallArchive(makeZip(t, map[string]string{ "sample-provider.dylib": "new", }), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"}) if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) } if !result.Overwritten { t.Fatal("Overwritten = false, want true") }} func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) { t.Parallel() root := t.TempDir() existingPath := filepath.Join(root, "sample-provider"+pluginhost.PluginExtension(runtime.GOOS)) if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } result, errInstall := InstallArchive(makeZip(t, map[string]string{ "sample-provider" + pluginhost.PluginExtension(runtime.GOOS): "new", }), testPlugin(), InstallOptions{PluginsDir: root, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}) if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) } if result.Path != existingPath { t.Fatalf("Path = %q, want selected runtime plugin %q", result.Path, existingPath) } if !result.Overwritten { t.Fatal("Overwritten = false, want true") } data, errRead := os.ReadFile(existingPath) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } if string(data) != "new" { t.Fatalf("installed data = %q, want new", data) }} func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) { t.Parallel() tests := []struct { name string files map[string]string wantErr string }{ { name: "zip slip", files: map[string]string{"../sample-provider.dylib": "library"}, wantErr: "escapes archive root", }, { name: "absolute path", files: map[string]string{"/sample-provider.dylib": "library"}, wantErr: "is absolute", }, { name: "nested target", files: map[string]string{"nested/sample-provider.dylib": "library"}, wantErr: "zip root", }, { name: "extension mismatch", files: map[string]string{"sample-provider.so": "library"}, wantErr: "sample-provider.dylib", }, { name: "filename mismatch", files: map[string]string{"other.dylib": "library"}, wantErr: "sample-provider.dylib", }, { name: "missing target", files: map[string]string{"README.md": "library"}, wantErr: "does not contain", }, { name: "multiple targets", files: map[string]string{ "sample-provider.dylib": "library", "copy.dylib": "library", }, wantErr: "sample-provider.dylib", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() _, errInstall := InstallArchive(makeZip(t, tt.files), testPlugin(), InstallOptions{PluginsDir: t.TempDir(), GOOS: "darwin", GOARCH: "arm64"}) if errInstall == nil { t.Fatal("InstallArchive() error = nil") } if !strings.Contains(errInstall.Error(), tt.wantErr) { t.Fatalf("InstallArchive() error = %v, want substring %q", errInstall, tt.wantErr) } }) }} func TestInstallUsesLatestReleaseVersion(t *testing.T) { t.Parallel() root := t.TempDir() archiveData := makeZip(t, map[string]string{"sample-provider.dylib": "library-data"}) archiveName := "sample-provider_0.2.0_darwin_arm64.zip" checksum := sha256.Sum256(archiveData) client := Client{HTTPClient: mapHTTPDoer{ "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ "tag_name": "v0.2.0", "assets": [ {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, {"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"} ] }`), "https://downloads.example/" + archiveName: archiveData, "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), }} result, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ PluginsDir: root, GOOS: "darwin", GOARCH: "arm64", }) if errInstall != nil { t.Fatalf("Install() error = %v", errInstall) } if result.Version != "0.2.0" { t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version) } data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")) if errRead != nil { t.Fatalf("ReadFile() error = %v", errRead) } if string(data) != "library-data" { t.Fatalf("installed data = %q", data) }} func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) { t.Parallel() client := Client{HTTPClient: mapHTTPDoer{ "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{"tag_name": "latest", "assets": []}`), }} _, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ PluginsDir: t.TempDir(), GOOS: "darwin", GOARCH: "arm64", }) if errInstall == nil { t.Fatal("Install() error = nil") } if !strings.Contains(errInstall.Error(), "invalid release tag") { t.Fatalf("Install() error = %v, want invalid release tag", errInstall) }} func makeZip(t *testing.T, files map[string]string) []byte { t.Helper() var buffer bytes.Buffer writer := zip.NewWriter(&buffer) for name, content := range files { file, errCreate := writer.Create(name) if errCreate != nil { t.Fatalf("Create(%s) error = %v", name, errCreate) } if _, errWrite := file.Write([]byte(content)); errWrite != nil { t.Fatalf("Write(%s) error = %v", name, errWrite) } } if errClose := writer.Close(); errClose != nil { t.Fatalf("Close() error = %v", errClose) } return buffer.Bytes()} type failingHTTPDoer struct{} func (failingHTTPDoer) Do(*http.Request) (*http.Response, error) { return nil, errors.New("network unavailable")} type mapHTTPDoer map[string][]byte func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) { body, ok := c[req.URL.String()] if !ok { return &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("not found")), Header: make(http.Header), Request: req, }, nil } return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body)), Header: make(http.Header), Request: req, }, nil} func testPlugin() Plugin { return Plugin{ ID: "sample-provider", Name: "Sample Provider", Description: "Adds sample provider support.", Author: "author-name", Version: "0.1.0", Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin", }}
package pluginstore
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
)
func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
goos string
loaded bool
wantBlocked bool
}{
{name: "windows loaded", goos: "windows", loaded: true, wantBlocked: true},
{name: "windows not loaded", goos: "windows", loaded: false, wantBlocked: false},
{name: "linux loaded", goos: "linux", loaded: true, wantBlocked: false},
{name: "darwin loaded", goos: "darwin", loaded: true, wantBlocked: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, errInstall := Client{HTTPClient: failingHTTPDoer{}}.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: t.TempDir(),
GOOS: tt.goos,
GOARCH: "amd64",
PluginLoaded: func() bool { return tt.loaded },
})
if errInstall == nil {
t.Fatal("Install() error = nil")
}
if gotBlocked := errors.Is(errInstall, ErrLoadedPluginLocked); gotBlocked != tt.wantBlocked {
t.Fatalf("Install() error = %v, blocked = %v, want %v", errInstall, gotBlocked, tt.wantBlocked)
}
})
}
}
func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) {
t.Parallel()
_, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "library-data",
}), testPlugin(), InstallOptions{
PluginsDir: t.TempDir(),
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return true },
})
if !errors.Is(errInstall, ErrLoadedPluginLocked) {
t.Fatalf("InstallArchive() error = %v, want ErrLoadedPluginLocked", errInstall)
}
}
func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, "windows", "amd64")
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
targetPath := filepath.Join(targetDir, "sample-provider.dll")
if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
loaded := true
prepared := false
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dll": "new",
}), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "windows",
GOARCH: "amd64",
PluginLoaded: func() bool { return loaded },
BeforeWrite: func() error {
prepared = true
loaded = false
return nil
},
})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if !prepared {
t.Fatal("BeforeWrite was not called")
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
data, errRead := os.ReadFile(targetPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "new" {
t.Fatalf("installed data = %q, want new", data)
}
}
func TestInstallArchiveWritesPlatformPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"README.md": "ignored",
"sample-provider.dylib": "library-data",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
wantPath := filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")
if result.Path != wantPath {
t.Fatalf("Path = %q, want %q", result.Path, wantPath)
}
data, errRead := os.ReadFile(wantPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "library-data" {
t.Fatalf("installed data = %q", data)
}
}
func TestInstallArchiveReportsOverwrite(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, "darwin", "arm64")
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
t.Fatalf("MkdirAll() error = %v", errMkdir)
}
if errWrite := os.WriteFile(filepath.Join(targetDir, "sample-provider.dylib"), []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider.dylib": "new",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: "darwin", GOARCH: "arm64"})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
}
func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) {
t.Parallel()
root := t.TempDir()
existingPath := filepath.Join(root, "sample-provider"+pluginhost.PluginExtension(runtime.GOOS))
if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
result, errInstall := InstallArchive(makeZip(t, map[string]string{
"sample-provider" + pluginhost.PluginExtension(runtime.GOOS): "new",
}), testPlugin(), InstallOptions{PluginsDir: root, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH})
if errInstall != nil {
t.Fatalf("InstallArchive() error = %v", errInstall)
}
if result.Path != existingPath {
t.Fatalf("Path = %q, want selected runtime plugin %q", result.Path, existingPath)
}
if !result.Overwritten {
t.Fatal("Overwritten = false, want true")
}
data, errRead := os.ReadFile(existingPath)
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "new" {
t.Fatalf("installed data = %q, want new", data)
}
}
func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantErr string
}{
{
name: "zip slip",
files: map[string]string{"../sample-provider.dylib": "library"},
wantErr: "escapes archive root",
},
{
name: "absolute path",
files: map[string]string{"/sample-provider.dylib": "library"},
wantErr: "is absolute",
},
{
name: "nested target",
files: map[string]string{"nested/sample-provider.dylib": "library"},
wantErr: "zip root",
},
{
name: "extension mismatch",
files: map[string]string{"sample-provider.so": "library"},
wantErr: "sample-provider.dylib",
},
{
name: "filename mismatch",
files: map[string]string{"other.dylib": "library"},
wantErr: "sample-provider.dylib",
},
{
name: "missing target",
files: map[string]string{"README.md": "library"},
wantErr: "does not contain",
},
{
name: "multiple targets",
files: map[string]string{
"sample-provider.dylib": "library",
"copy.dylib": "library",
},
wantErr: "sample-provider.dylib",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, errInstall := InstallArchive(makeZip(t, tt.files), testPlugin(), InstallOptions{PluginsDir: t.TempDir(), GOOS: "darwin", GOARCH: "arm64"})
if errInstall == nil {
t.Fatal("InstallArchive() error = nil")
}
if !strings.Contains(errInstall.Error(), tt.wantErr) {
t.Fatalf("InstallArchive() error = %v, want substring %q", errInstall, tt.wantErr)
}
})
}
}
func TestInstallUsesLatestReleaseVersion(t *testing.T) {
t.Parallel()
root := t.TempDir()
archiveData := makeZip(t, map[string]string{"sample-provider.dylib": "library-data"})
archiveName := "sample-provider_0.2.0_darwin_arm64.zip"
checksum := sha256.Sum256(archiveData)
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
"tag_name": "v0.2.0",
"assets": [
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
]
}`),
"https://downloads.example/" + archiveName: archiveData,
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
}}
result, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: root,
GOOS: "darwin",
GOARCH: "arm64",
})
if errInstall != nil {
t.Fatalf("Install() error = %v", errInstall)
}
if result.Version != "0.2.0" {
t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version)
}
data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib"))
if errRead != nil {
t.Fatalf("ReadFile() error = %v", errRead)
}
if string(data) != "library-data" {
t.Fatalf("installed data = %q", data)
}
}
func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) {
t.Parallel()
client := Client{HTTPClient: mapHTTPDoer{
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{"tag_name": "latest", "assets": []}`),
}}
_, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{
PluginsDir: t.TempDir(),
GOOS: "darwin",
GOARCH: "arm64",
})
if errInstall == nil {
t.Fatal("Install() error = nil")
}
if !strings.Contains(errInstall.Error(), "invalid release tag") {
t.Fatalf("Install() error = %v, want invalid release tag", errInstall)
}
}
func makeZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, errCreate := writer.Create(name)
if errCreate != nil {
t.Fatalf("Create(%s) error = %v", name, errCreate)
}
if _, errWrite := file.Write([]byte(content)); errWrite != nil {
t.Fatalf("Write(%s) error = %v", name, errWrite)
}
}
if errClose := writer.Close(); errClose != nil {
t.Fatalf("Close() error = %v", errClose)
}
return buffer.Bytes()
}
type failingHTTPDoer struct{}
func (failingHTTPDoer) Do(*http.Request) (*http.Response, error) {
return nil, errors.New("network unavailable")
}
type mapHTTPDoer map[string][]byte
func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) {
body, ok := c[req.URL.String()]
if !ok {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("not found")),
Header: make(http.Header),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
Request: req,
}, nil
}
func testPlugin() Plugin {
return Plugin{
ID: "sample-provider",
Name: "Sample Provider",
Description: "Adds sample provider support.",
Author: "author-name",
Version: "0.1.0",
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
}
}