mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-24 16:27:43 +08:00
Merge pull request #3821 from router-for-me/feat/plugin-latest-release-check
Feat/plugin latest release check
This commit is contained in:
@@ -54,6 +54,8 @@ type Handler struct {
|
||||
configReloadHook func(context.Context, *config.Config)
|
||||
pluginStoreRegistryURL string
|
||||
pluginStoreHTTPClient pluginstore.HTTPDoer
|
||||
pluginReleaseCacheMu sync.Mutex
|
||||
pluginReleaseCache map[string]pluginReleaseCacheEntry
|
||||
}
|
||||
|
||||
// NewHandler creates a new management handler instance.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
@@ -17,6 +20,20 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// pluginReleaseCacheTTL bounds how long a resolved latest release version is
|
||||
// reused before the GitHub API is queried again.
|
||||
pluginReleaseCacheTTL = 10 * time.Minute
|
||||
// pluginReleaseFailureCacheTTL throttles retries after a failed lookup so a
|
||||
// rate-limited or unreachable API is not hammered on every listing.
|
||||
pluginReleaseFailureCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
type pluginReleaseCacheEntry struct {
|
||||
version string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type pluginStoreListResponse struct {
|
||||
PluginsEnabled bool `json:"plugins_enabled"`
|
||||
PluginsDir string `json:"plugins_dir"`
|
||||
@@ -77,16 +94,23 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins)
|
||||
|
||||
entries := make([]pluginStoreListEntry, 0, len(registry.Plugins))
|
||||
for _, plugin := range registry.Plugins {
|
||||
for index, plugin := range registry.Plugins {
|
||||
status := statuses[plugin.ID]
|
||||
installedVersion := status.InstalledVersion
|
||||
// Fall back to the registry version when the latest release is unknown.
|
||||
storeVersion := plugin.Version
|
||||
if latestVersions[index] != "" {
|
||||
storeVersion = latestVersions[index]
|
||||
}
|
||||
entries = append(entries, pluginStoreListEntry{
|
||||
ID: htmlsanitize.String(plugin.ID),
|
||||
Name: htmlsanitize.String(plugin.Name),
|
||||
Description: htmlsanitize.String(plugin.Description),
|
||||
Author: htmlsanitize.String(plugin.Author),
|
||||
Version: htmlsanitize.String(plugin.Version),
|
||||
Version: htmlsanitize.String(storeVersion),
|
||||
Repository: htmlsanitize.String(plugin.Repository),
|
||||
Logo: htmlsanitize.String(plugin.Logo),
|
||||
Homepage: htmlsanitize.String(plugin.Homepage),
|
||||
@@ -99,7 +123,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
Registered: status.Registered,
|
||||
Enabled: status.Enabled,
|
||||
EffectiveEnabled: status.EffectiveEnabled,
|
||||
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, plugin.Version),
|
||||
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,6 +300,61 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client {
|
||||
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL}
|
||||
}
|
||||
|
||||
// latestPluginVersions resolves the latest release version of each registry
|
||||
// plugin concurrently, returning results positionally aligned with plugins.
|
||||
// Unresolved entries are left empty so callers can fall back gracefully.
|
||||
func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.Client, plugins []pluginstore.Plugin) []string {
|
||||
versions := make([]string, len(plugins))
|
||||
var wg sync.WaitGroup
|
||||
for index := range plugins {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
versions[index] = h.latestPluginVersion(ctx, client, plugins[index])
|
||||
}(index)
|
||||
}
|
||||
wg.Wait()
|
||||
return versions
|
||||
}
|
||||
|
||||
// latestPluginVersion returns the plugin's latest release version, caching
|
||||
// lookups per repository so repeated listings do not exhaust the GitHub API
|
||||
// rate limit. Failed lookups are cached for a shorter interval and reported
|
||||
// as an empty version.
|
||||
func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string {
|
||||
repository := strings.TrimSpace(plugin.Repository)
|
||||
if repository == "" {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
h.pluginReleaseCacheMu.Lock()
|
||||
entry, found := h.pluginReleaseCache[repository]
|
||||
h.pluginReleaseCacheMu.Unlock()
|
||||
if found && now.Before(entry.expiresAt) {
|
||||
return entry.version
|
||||
}
|
||||
|
||||
version := ""
|
||||
ttl := pluginReleaseFailureCacheTTL
|
||||
release, errRelease := client.FetchLatestRelease(ctx, plugin)
|
||||
if errRelease != nil {
|
||||
log.WithError(errRelease).WithField("plugin_id", plugin.ID).Warn("pluginstore: failed to fetch latest release")
|
||||
} else if latestVersion, errVersion := pluginstore.ReleaseVersion(release); errVersion != nil {
|
||||
log.WithError(errVersion).WithField("plugin_id", plugin.ID).Warn("pluginstore: invalid latest release tag")
|
||||
} else {
|
||||
version = latestVersion
|
||||
ttl = pluginReleaseCacheTTL
|
||||
}
|
||||
|
||||
h.pluginReleaseCacheMu.Lock()
|
||||
if h.pluginReleaseCache == nil {
|
||||
h.pluginReleaseCache = make(map[string]pluginReleaseCacheEntry)
|
||||
}
|
||||
h.pluginReleaseCache[repository] = pluginReleaseCacheEntry{version: version, expiresAt: now.Add(ttl)}
|
||||
h.pluginReleaseCacheMu.Unlock()
|
||||
return version
|
||||
}
|
||||
|
||||
func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) {
|
||||
statuses := map[string]pluginLocalStatus{}
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -146,6 +147,98 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
httpClient := &countingPluginStoreHTTPClient{responses: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
|
||||
"tag_name": "v0.2.0",
|
||||
"assets": []
|
||||
}`),
|
||||
}}
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: httpClient,
|
||||
}
|
||||
|
||||
listOnce := func() pluginStoreListResponse {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
|
||||
h.ListPluginStore(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body pluginStoreListResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
for call := 0; call < 2; call++ {
|
||||
body := listOnce()
|
||||
if len(body.Plugins) != 1 {
|
||||
t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
|
||||
}
|
||||
if body.Plugins[0].Version != "0.2.0" {
|
||||
t.Fatalf("version = %q, want 0.2.0 from latest release tag", body.Plugins[0].Version)
|
||||
}
|
||||
}
|
||||
releaseCalls := httpClient.count("https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest")
|
||||
if releaseCalls != 1 {
|
||||
t.Fatalf("latest release fetched %d times, want 1 (cached)", releaseCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
|
||||
|
||||
h.ListPluginStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body pluginStoreListResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if len(body.Plugins) != 1 {
|
||||
t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
|
||||
}
|
||||
if body.Plugins[0].Version != "0.1.0" {
|
||||
t.Fatalf("version = %q, want registry fallback 0.1.0", body.Plugins[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -168,7 +261,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
|
||||
"tag_name": "v0.1.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
@@ -250,7 +343,7 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{
|
||||
"https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
|
||||
"tag_name": "v0.1.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
@@ -368,6 +461,28 @@ func (c fakePluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
type countingPluginStoreHTTPClient struct {
|
||||
responses fakePluginStoreHTTPClient
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
}
|
||||
|
||||
func (c *countingPluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.mu.Lock()
|
||||
if c.counts == nil {
|
||||
c.counts = make(map[string]int)
|
||||
}
|
||||
c.counts[req.URL.String()]++
|
||||
c.mu.Unlock()
|
||||
return c.responses.Do(req)
|
||||
}
|
||||
|
||||
func (c *countingPluginStoreHTTPClient) count(url string) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.counts[url]
|
||||
}
|
||||
|
||||
func registryJSON(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch"
|
||||
@@ -48,16 +49,17 @@ func (c Client) FetchRegistry(ctx context.Context) (Registry, error) {
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error) {
|
||||
// FetchLatestRelease returns the latest published release of the plugin's
|
||||
// GitHub repository, mirroring the WebUI panel update check.
|
||||
func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) {
|
||||
owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
|
||||
if errRepository != nil {
|
||||
return Release{}, errRepository
|
||||
}
|
||||
releaseURL := fmt.Sprintf(
|
||||
"https://api.github.com/repos/%s/%s/releases/tags/%s",
|
||||
"https://api.github.com/repos/%s/%s/releases/latest",
|
||||
url.PathEscape(owner),
|
||||
url.PathEscape(repo),
|
||||
url.PathEscape("v"+strings.TrimSpace(plugin.Version)),
|
||||
)
|
||||
data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json")
|
||||
if errDownload != nil {
|
||||
@@ -70,6 +72,16 @@ func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// ReleaseVersion derives the plugin version from the release tag, stripping a
|
||||
// leading "v"/"V" and validating the result.
|
||||
func ReleaseVersion(release Release) (string, error) {
|
||||
version := normalizeVersion(release.TagName)
|
||||
if !validPluginVersion(version) {
|
||||
return "", fmt.Errorf("invalid release tag %q", release.TagName)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, error) {
|
||||
if strings.TrimSpace(asset.BrowserDownloadURL) == "" {
|
||||
return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name)
|
||||
@@ -78,10 +90,28 @@ func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte,
|
||||
}
|
||||
|
||||
func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) {
|
||||
return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, map[string]string{
|
||||
headers := map[string]string{
|
||||
"Accept": accept,
|
||||
"User-Agent": c.userAgent(),
|
||||
}, 0)
|
||||
}
|
||||
if token := gitHubAPIToken(requestURL); token != "" {
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
}
|
||||
return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0)
|
||||
}
|
||||
|
||||
// gitHubAPIToken returns the optional GitHub token for GitHub API requests to
|
||||
// raise the unauthenticated rate limit, mirroring the management asset updater.
|
||||
func gitHubAPIToken(requestURL string) string {
|
||||
parsed, errParse := url.Parse(requestURL)
|
||||
if errParse != nil || !strings.EqualFold(parsed.Host, "api.github.com") {
|
||||
return ""
|
||||
}
|
||||
gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL")))
|
||||
if !strings.Contains(gitURL, "github.com") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN"))
|
||||
}
|
||||
|
||||
func (c Client) httpClient() HTTPDoer {
|
||||
|
||||
@@ -64,6 +64,42 @@ func TestSelectReleaseAssetsRejectsMissingAssets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tagName string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "v prefix", tagName: "v1.2.3", want: "1.2.3"},
|
||||
{name: "no prefix", tagName: "0.1.0", want: "0.1.0"},
|
||||
{name: "whitespace", tagName: " v2.0.0 ", want: "2.0.0"},
|
||||
{name: "empty", tagName: "", wantErr: true},
|
||||
{name: "non numeric", tagName: "latest", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
version, errVersion := ReleaseVersion(Release{TagName: tt.tagName})
|
||||
if tt.wantErr {
|
||||
if errVersion == nil {
|
||||
t.Fatalf("ReleaseVersion(%q) error = nil", tt.tagName)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errVersion != nil {
|
||||
t.Fatalf("ReleaseVersion(%q) error = %v", tt.tagName, errVersion)
|
||||
}
|
||||
if version != tt.want {
|
||||
t.Fatalf("ReleaseVersion(%q) = %q, want %q", tt.tagName, version, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumsAndVerifyChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -49,10 +49,15 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio
|
||||
if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil {
|
||||
return InstallResult{}, ErrLoadedPluginLocked
|
||||
}
|
||||
release, errRelease := c.FetchRelease(ctx, plugin)
|
||||
release, errRelease := c.FetchLatestRelease(ctx, plugin)
|
||||
if errRelease != nil {
|
||||
return InstallResult{}, errRelease
|
||||
}
|
||||
latestVersion, errVersion := ReleaseVersion(release)
|
||||
if errVersion != nil {
|
||||
return InstallResult{}, errVersion
|
||||
}
|
||||
plugin.Version = latestVersion
|
||||
archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH)
|
||||
if errAssets != nil {
|
||||
return InstallResult{}, errAssets
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -249,6 +252,64 @@ func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -275,6 +336,26 @@ 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",
|
||||
|
||||
@@ -94,7 +94,6 @@ func ValidatePlugin(plugin Plugin) error {
|
||||
"name": plugin.Name,
|
||||
"description": plugin.Description,
|
||||
"author": plugin.Author,
|
||||
"version": plugin.Version,
|
||||
"repository": plugin.Repository,
|
||||
}
|
||||
for field, value := range required {
|
||||
@@ -105,7 +104,9 @@ func ValidatePlugin(plugin Plugin) error {
|
||||
if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) {
|
||||
return fmt.Errorf("invalid plugin id %q", plugin.ID)
|
||||
}
|
||||
if !validPluginVersion(strings.TrimSpace(plugin.Version)) {
|
||||
// The version is optional since the latest release is the source of truth;
|
||||
// when present it is only used as a display fallback and must be valid.
|
||||
if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) {
|
||||
return fmt.Errorf("invalid plugin version %q", plugin.Version)
|
||||
}
|
||||
if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil {
|
||||
|
||||
@@ -68,6 +68,21 @@ func TestParseRegistryNormalizesPluginFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryAllowsMissingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := Registry{SchemaVersion: 1, Plugins: []Plugin{{
|
||||
ID: "sample-provider",
|
||||
Name: "Sample Provider",
|
||||
Description: "Adds sample provider support.",
|
||||
Author: "author-name",
|
||||
Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin",
|
||||
}}}
|
||||
if errValidate := ValidateRegistry(registry); errValidate != nil {
|
||||
t.Fatalf("ValidateRegistry() error = %v, want nil for missing version", errValidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistryRejectsInvalidEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user