Merge pull request #4185 from router-for-me/codex/fix-p2-oauth-plugin-source

Codex/fix p2 oauth plugin source
This commit is contained in:
Supra4E8C
2026-07-10 17:41:04 +08:00
committed by GitHub
6 changed files with 542 additions and 78 deletions
@@ -2627,8 +2627,12 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
return
}
provider, status, isPlugin, metadata, ok := GetOAuthSessionDetails(state)
provider, status, isPlugin, metadata, completed, ok := GetOAuthSessionDetails(state)
if !ok {
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"})
return
}
if completed {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
@@ -86,11 +86,15 @@ func (h *Handler) handleOAuthCallback(c *gin.Context, req oauthCallbackRequest)
return
}
sessionProvider, sessionStatus, isPlugin, _, ok := GetOAuthSessionDetails(state)
sessionProvider, sessionStatus, isPlugin, _, completed, ok := GetOAuthSessionDetails(state)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
return
}
if completed {
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is already completed"})
return
}
provider := strings.TrimSpace(req.Provider)
if provider == "" {
provider = sessionProvider
@@ -12,8 +12,9 @@ import (
)
const (
oauthSessionTTL = 10 * time.Minute
maxOAuthStateLength = 128
oauthSessionTTL = 10 * time.Minute
oauthCompletedSessionTTL = time.Minute
maxOAuthStateLength = 128
)
const (
@@ -33,23 +34,30 @@ type oauthSession struct {
Status string
Source string
Metadata map[string]any
Completed bool
CreatedAt time.Time
ExpiresAt time.Time
}
type oauthSessionStore struct {
mu sync.RWMutex
ttl time.Duration
sessions map[string]oauthSession
mu sync.RWMutex
ttl time.Duration
completedTTL time.Duration
sessions map[string]oauthSession
}
func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
if ttl <= 0 {
ttl = oauthSessionTTL
}
completedTTL := oauthCompletedSessionTTL
if ttl < completedTTL {
completedTTL = ttl
}
return &oauthSessionStore{
ttl: ttl,
sessions: make(map[string]oauthSession),
ttl: ttl,
completedTTL: completedTTL,
sessions: make(map[string]oauthSession),
}
}
@@ -127,7 +135,7 @@ func (s *oauthSessionStore) SetError(state, message string) {
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
if !ok {
if !ok || session.Completed {
return
}
session.Status = message
@@ -146,7 +154,15 @@ func (s *oauthSessionStore) Complete(state string) {
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
delete(s.sessions, state)
session, ok := s.sessions[state]
if !ok || session.Completed {
return
}
session.Status = ""
session.Metadata = nil
session.Completed = true
session.ExpiresAt = now.Add(s.completedTTL)
s.sessions[state] = session
}
func (s *oauthSessionStore) CompleteProvider(provider string, source string) int {
@@ -163,8 +179,12 @@ func (s *oauthSessionStore) CompleteProvider(provider string, source string) int
s.purgeExpiredLocked(now)
removed := 0
for state, session := range s.sessions {
if strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
delete(s.sessions, state)
if !session.Completed && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
session.Status = ""
session.Metadata = nil
session.Completed = true
session.ExpiresAt = now.Add(s.completedTTL)
s.sessions[state] = session
removed++
}
}
@@ -197,7 +217,7 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool {
if !ok {
return false
}
if session.Status != "" {
if session.Completed || session.Status != "" {
return false
}
if provider == "" {
@@ -239,18 +259,18 @@ func CompletePluginOAuthSessionsByProvider(provider string) int {
func GetOAuthSession(state string) (provider string, status string, ok bool) {
session, ok := oauthSessions.Get(state)
if !ok {
if !ok || session.Completed {
return "", "", false
}
return session.Provider, session.Status, true
}
func GetOAuthSessionDetails(state string) (provider string, status string, isPlugin bool, metadata map[string]any, ok bool) {
func GetOAuthSessionDetails(state string) (provider string, status string, isPlugin bool, metadata map[string]any, completed bool, ok bool) {
session, ok := oauthSessions.Get(state)
if !ok {
return "", "", false, nil, false
return "", "", false, nil, false, false
}
return session.Provider, session.Status, session.Source == oauthSessionSourcePlugin, cloneOAuthSessionMetadata(session.Metadata), true
return session.Provider, session.Status, session.Source == oauthSessionSourcePlugin, cloneOAuthSessionMetadata(session.Metadata), session.Completed, true
}
func IsOAuthSessionPending(state, provider string) bool {
@@ -0,0 +1,166 @@
package management
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestOAuthSessionStoreCompleteKeepsShortLivedSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("completed-state", "codex")
store.Complete("completed-state")
if _, ok := store.Get("completed-state"); !ok {
t.Fatal("completed OAuth session was deleted instead of retained as a tombstone")
}
if store.IsPending("completed-state", "codex") {
t.Fatal("completed OAuth session remained pending")
}
}
func TestOAuthSessionStoreCompleteDoesNotExtendCompletedSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("completed-state", "codex")
store.Complete("completed-state")
before, ok := store.Get("completed-state")
if !ok {
t.Fatal("completed OAuth session tombstone is missing")
}
store.completedTTL = 2 * time.Minute
store.Complete("completed-state")
after, ok := store.Get("completed-state")
if !ok {
t.Fatal("completed OAuth session tombstone is missing after repeated completion")
}
if !after.ExpiresAt.Equal(before.ExpiresAt) {
t.Fatalf("repeated completion extended expiry from %s to %s", before.ExpiresAt, after.ExpiresAt)
}
}
func TestOAuthSessionStoreCompleteProviderSkipsCompletedSessions(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
store.Register("completed-state", "codex")
store.Register("pending-state", "codex")
store.Complete("completed-state")
completedBefore, ok := store.Get("completed-state")
if !ok {
t.Fatal("completed OAuth session tombstone is missing")
}
store.completedTTL = 2 * time.Minute
if got := store.CompleteProvider("codex", oauthSessionSourceBuiltin); got != 1 {
t.Fatalf("CompleteProvider() = %d, want 1 newly completed session", got)
}
completedAfter, ok := store.Get("completed-state")
if !ok {
t.Fatal("completed OAuth session tombstone is missing after provider completion")
}
if !completedAfter.ExpiresAt.Equal(completedBefore.ExpiresAt) {
t.Fatalf("provider completion extended existing tombstone from %s to %s", completedBefore.ExpiresAt, completedAfter.ExpiresAt)
}
pendingAfter, ok := store.Get("pending-state")
if !ok || !pendingAfter.Completed {
t.Fatalf("pending session completed/ok = %t/%t, want true/true", pendingAfter.Completed, ok)
}
}
func TestGetOAuthSessionHidesCompletedSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
store.Register("completed-state", "codex")
store.Complete("completed-state")
provider, status, ok := GetOAuthSession("completed-state")
if ok {
t.Fatalf("GetOAuthSession() = (%q, %q, true), want completed session hidden", provider, status)
}
_, _, _, _, completed, detailsOK := GetOAuthSessionDetails("completed-state")
if !detailsOK || !completed {
t.Fatalf("GetOAuthSessionDetails() completed/ok = %t/%t, want true/true", completed, detailsOK)
}
}
func TestGetAuthStatusRejectsUnknownStateAndAcceptsCompletedState(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
handler := &Handler{}
router := gin.New()
router.GET("/status", handler.GetAuthStatus)
unknown := performOAuthStatusRequest(t, router, "unknown-state")
if unknown.Status != "error" || unknown.Error != "unknown or expired state" {
t.Fatalf("unknown state response = %#v, want unknown/expired error", unknown)
}
store.Register("completed-state", "codex")
store.Complete("completed-state")
completed := performOAuthStatusRequest(t, router, "completed-state")
if completed.Status != "ok" || completed.Error != "" {
t.Fatalf("completed state response = %#v, want success", completed)
}
}
func TestOAuthCallbackRejectsCompletedSession(t *testing.T) {
store := newOAuthSessionStore(time.Minute)
replaceOAuthSessionStoreForTest(t, store)
store.Register("completed-state", "codex")
store.Complete("completed-state")
handler := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
router := gin.New()
router.POST("/oauth-callback", handler.PostOAuthCallback)
req := httptest.NewRequest(
http.MethodPost,
"/oauth-callback",
strings.NewReader(`{"provider":"codex","state":"completed-state","code":"test-code"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("completed callback status = %d, want %d; body=%s", w.Code, http.StatusConflict, w.Body.String())
}
}
type oauthStatusResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
func performOAuthStatusRequest(t *testing.T, router http.Handler, state string) oauthStatusResponse {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/status?state="+state, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status request returned %d, want %d; body=%s", w.Code, http.StatusOK, w.Body.String())
}
var response oauthStatusResponse
if errDecode := json.Unmarshal(w.Body.Bytes(), &response); errDecode != nil {
t.Fatalf("decode status response: %v", errDecode)
}
return response
}
func replaceOAuthSessionStoreForTest(t *testing.T, store *oauthSessionStore) {
t.Helper()
original := oauthSessions
oauthSessions = store
t.Cleanup(func() {
oauthSessions = original
})
}
+170 -60
View File
@@ -59,32 +59,34 @@ type pluginStoreSourceErr struct {
}
type pluginStoreListEntry struct {
StoreID string `json:"store_id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
SourceURL string `json:"source_url"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Author string `json:"author"`
Version string `json:"version"`
Repository string `json:"repository"`
InstallType string `json:"install_type"`
AuthRequired bool `json:"auth_required"`
AuthConfigured bool `json:"auth_configured"`
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
Logo string `json:"logo,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
Tags []string `json:"tags,omitempty"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
UpdateAvailable bool `json:"update_available"`
StoreID string `json:"store_id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
SourceURL string `json:"source_url"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Author string `json:"author"`
Version string `json:"version"`
Repository string `json:"repository"`
InstallType string `json:"install_type"`
AuthRequired bool `json:"auth_required"`
AuthConfigured bool `json:"auth_configured"`
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
Logo string `json:"logo,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
Tags []string `json:"tags,omitempty"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version"`
InstalledSourceID string `json:"installed_source_id,omitempty"`
InstallSourceStatus string `json:"install_source_status,omitempty"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
UpdateAvailable bool `json:"update_available"`
}
type pluginStorePlatform struct {
@@ -110,13 +112,16 @@ type pluginInstallRequest struct {
}
type pluginLocalStatus struct {
Installed bool
InstalledVersion string
Path string
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
Installed bool
InstalledVersion string
StoreManaged bool
InstalledSourceID string
InstalledSourceURL string
Path string
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
}
type sourcedPlugin struct {
@@ -148,11 +153,21 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
}
client := h.newPluginStoreClient(proxyURL, "", storeAuth)
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
pluginSourceCounts := make(map[string]int, len(plugins))
for _, item := range plugins {
pluginSourceCounts[item.plugin.ID]++
}
entries := make([]pluginStoreListEntry, 0, len(plugins))
for index, item := range plugins {
plugin := item.plugin
status := statuses[plugin.ID]
installedSourceID, installSourceStatus, sourceAllowsUpdate := pluginStoreInstallSourceStatus(
status,
sources,
item.source.ID,
pluginSourceCounts[plugin.ID],
)
installedVersion := status.InstalledVersion
// Fall back to the registry version when the latest release is unknown.
storeVersion := plugin.Version
@@ -160,32 +175,34 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
storeVersion = latestVersions[index]
}
entries = append(entries, pluginStoreListEntry{
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
SourceID: htmlsanitize.String(item.source.ID),
SourceName: htmlsanitize.String(item.source.Name),
SourceURL: htmlsanitize.String(item.source.URL),
ID: htmlsanitize.String(plugin.ID),
Name: htmlsanitize.String(plugin.Name),
Description: htmlsanitize.String(plugin.Description),
Author: htmlsanitize.String(plugin.Author),
Version: htmlsanitize.String(storeVersion),
Repository: htmlsanitize.String(plugin.Repository),
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
AuthRequired: plugin.AuthRequired,
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
Logo: htmlsanitize.String(plugin.Logo),
Homepage: htmlsanitize.String(plugin.Homepage),
License: htmlsanitize.String(plugin.License),
Tags: htmlsanitize.Strings(plugin.Tags),
Installed: status.Installed,
InstalledVersion: htmlsanitize.String(installedVersion),
Path: htmlsanitize.String(status.Path),
Configured: status.Configured,
Registered: status.Registered,
Enabled: status.Enabled,
EffectiveEnabled: status.EffectiveEnabled,
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion),
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
SourceID: htmlsanitize.String(item.source.ID),
SourceName: htmlsanitize.String(item.source.Name),
SourceURL: htmlsanitize.String(item.source.URL),
ID: htmlsanitize.String(plugin.ID),
Name: htmlsanitize.String(plugin.Name),
Description: htmlsanitize.String(plugin.Description),
Author: htmlsanitize.String(plugin.Author),
Version: htmlsanitize.String(storeVersion),
Repository: htmlsanitize.String(plugin.Repository),
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
AuthRequired: plugin.AuthRequired,
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
Logo: htmlsanitize.String(plugin.Logo),
Homepage: htmlsanitize.String(plugin.Homepage),
License: htmlsanitize.String(plugin.License),
Tags: htmlsanitize.Strings(plugin.Tags),
Installed: status.Installed,
InstalledVersion: htmlsanitize.String(installedVersion),
InstalledSourceID: htmlsanitize.String(installedSourceID),
InstallSourceStatus: htmlsanitize.String(installSourceStatus),
Path: htmlsanitize.String(status.Path),
Configured: status.Configured,
Registered: status.Registered,
Enabled: status.Enabled,
EffectiveEnabled: status.EffectiveEnabled,
UpdateAvailable: sourceAllowsUpdate && pluginstore.UpdateAvailable(installedVersion, storeVersion),
})
}
@@ -213,7 +230,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
return
}
installCtx := c.Request.Context()
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, _, host := h.pluginStoreSnapshot()
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot()
sources, errSources := h.pluginStoreSources(sourceConfigs)
if errSources != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
@@ -223,6 +240,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
if !okPlugin {
return
}
if !validatePluginStoreInstallSource(c, configs, sources, id, source.ID) {
return
}
pluginIsBusy := func() bool { return pluginBusy(host, id) }
installOptions := pluginstore.InstallOptions{
PluginsDir: pluginsDir,
@@ -717,6 +737,7 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
status := statuses[id]
status.Configured = true
status.Enabled = pluginInstanceEnabled(item)
status.InstalledSourceID, status.InstalledSourceURL, status.StoreManaged = pluginStoreConfiguredSource(item)
statuses[id] = status
}
if host != nil {
@@ -738,6 +759,95 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
return statuses, nil
}
func pluginStoreConfiguredSource(item config.PluginInstanceConfig) (sourceID string, sourceURL string, managed bool) {
storeNode := pluginStoreConfigNode(item)
if storeNode == nil {
return "", "", false
}
var manifest pluginstore.Manifest
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
return "", "", true
}
return strings.TrimSpace(manifest.SourceID), strings.TrimSpace(manifest.SourceURL), true
}
func pluginStoreResolveInstalledSource(status pluginLocalStatus, sources []pluginstore.Source) (string, bool) {
sourceID := strings.TrimSpace(status.InstalledSourceID)
sourceURL := strings.TrimSpace(status.InstalledSourceURL)
if sourceID != "" {
for _, source := range sources {
if strings.TrimSpace(source.ID) != sourceID {
continue
}
if sourceURL != "" && strings.TrimSpace(source.URL) != sourceURL {
return "", false
}
return sourceID, true
}
return sourceID, true
}
if sourceURL == "" {
return "", false
}
for _, source := range sources {
if strings.TrimSpace(source.URL) == sourceURL {
return strings.TrimSpace(source.ID), true
}
}
return "", false
}
func pluginStoreInstallSourceStatus(status pluginLocalStatus, sources []pluginstore.Source, entrySourceID string, sourceCount int) (installedSourceID string, sourceStatus string, allowUpdate bool) {
if !status.Installed && !status.Configured && !status.Registered {
return "", "", true
}
if sourceID, known := pluginStoreResolveInstalledSource(status, sources); known {
if sourceID == strings.TrimSpace(entrySourceID) {
return sourceID, "matched", true
}
return sourceID, "different", false
}
if status.StoreManaged || sourceCount > 1 {
return "", "unknown", false
}
return "", "assumed", true
}
func validatePluginStoreInstallSource(c *gin.Context, configs map[string]config.PluginInstanceConfig, sources []pluginstore.Source, id string, requestedSourceID string) bool {
item, configured := configs[id]
if !configured {
return true
}
installedSourceID, installedSourceURL, managed := pluginStoreConfiguredSource(item)
if !managed {
return true
}
status := pluginLocalStatus{
StoreManaged: true,
InstalledSourceID: installedSourceID,
InstalledSourceURL: installedSourceURL,
}
resolvedSourceID, known := pluginStoreResolveInstalledSource(status, sources)
if !known {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_store_installed_source_unknown",
"message": "installed plugin source cannot be verified; uninstall it before reinstalling from the store",
"requested_source_id": strings.TrimSpace(requestedSourceID),
})
return false
}
if resolvedSourceID != strings.TrimSpace(requestedSourceID) {
c.JSON(http.StatusConflict, gin.H{
"error": "plugin_store_source_conflict",
"message": "installed plugin belongs to a different store source; uninstall it before switching sources",
"installed_source_id": resolvedSourceID,
"requested_source_id": strings.TrimSpace(requestedSourceID),
})
return false
}
return true
}
func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string {
if len(configs) == 0 {
return nil
@@ -402,6 +402,154 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) {
}
}
func TestListPluginStoreMatchesInstalledStatusToManifestSource(t *testing.T) {
t.Parallel()
pluginsDir := t.TempDir()
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
}
pluginPath := filepath.Join(archDir, "sample-provider-v0.0.1"+managementPluginExtension(runtime.GOOS))
if errWriteFile := os.WriteFile(pluginPath, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", pluginPath, errWriteFile)
}
communityURL := "https://community.example/registry.json"
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: pluginsDir,
StoreSources: []string{communityURL},
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
pluginstore.DefaultRegistryURL: registryJSON(t),
communityURL: thirdPartySampleRegistryJSON(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 struct {
Plugins []struct {
SourceID string `json:"source_id"`
InstalledSourceID string `json:"installed_source_id"`
InstallSourceStatus string `json:"install_source_status"`
UpdateAvailable bool `json:"update_available"`
} `json:"plugins"`
}
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) != 2 {
t.Fatalf("plugins len = %d, want 2", len(body.Plugins))
}
entries := make(map[string]struct {
InstalledSourceID string
InstallSourceStatus string
UpdateAvailable bool
}, len(body.Plugins))
for _, entry := range body.Plugins {
entries[entry.SourceID] = struct {
InstalledSourceID string
InstallSourceStatus string
UpdateAvailable bool
}{entry.InstalledSourceID, entry.InstallSourceStatus, entry.UpdateAvailable}
}
official := entries[pluginstore.DefaultSourceID]
if official.InstalledSourceID != pluginstore.DefaultSourceID || official.InstallSourceStatus != "matched" || !official.UpdateAvailable {
t.Fatalf("official entry = %#v, want matched update", official)
}
communitySourceID := pluginstore.SourceID(communityURL)
community := entries[communitySourceID]
if community.InstalledSourceID != pluginstore.DefaultSourceID || community.InstallSourceStatus != "different" || community.UpdateAvailable {
t.Fatalf("community entry = %#v, want different source without update", community)
}
}
func TestInstallPluginFromStoreRejectsImplicitSourceSwitch(t *testing.T) {
t.Parallel()
communityURL := "https://community.example/registry.json"
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: writeManagementPluginFile(t, "sample-provider"),
StoreSources: []string{communityURL},
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
},
},
},
configFilePath: writeTestConfigFile(t),
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
pluginstore.DefaultRegistryURL: registryJSON(t),
communityURL: thirdPartySampleRegistryJSON(t),
},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
communitySourceID := pluginstore.SourceID(communityURL)
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?source="+communitySourceID, nil)
h.InstallPluginFromStore(c)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "plugin_store_source_conflict") || !strings.Contains(rec.Body.String(), pluginstore.DefaultSourceID) {
t.Fatalf("body = %s, want source conflict with installed source", rec.Body.String())
}
}
func TestInstallPluginFromStoreRejectsUnknownManagedSource(t *testing.T) {
t.Parallel()
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: writeManagementPluginFile(t, "sample-provider"),
Configs: map[string]config.PluginInstanceConfig{
"sample-provider": pluginConfigWithStoreSource(t, "", ""),
},
},
},
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.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
h.InstallPluginFromStore(c)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "plugin_store_installed_source_unknown") {
t.Fatalf("body = %s, want unknown installed source error", rec.Body.String())
}
}
func TestListPluginStoreIncludesDirectMetadataAndAuth(t *testing.T) {
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
@@ -1147,6 +1295,18 @@ func testStoreManifest() pluginstore.Manifest {
}
}
func pluginConfigWithStoreSource(t *testing.T, sourceID string, sourceURL string) config.PluginInstanceConfig {
t.Helper()
sourceFields := ""
if sourceID != "" {
sourceFields += " source-id: " + sourceID + "\n"
}
if sourceURL != "" {
sourceFields += " source-url: " + sourceURL + "\n"
}
return pluginConfigFromYAML(t, "enabled: true\nstore:\n schema-version: 1\n id: sample-provider\n version: 0.0.1\n release-tag: v0.0.1\n repository: https://github.com/author-name/cliproxy-sample-provider-plugin\n"+sourceFields+" install:\n type: github-release\n")
}
func pluginStoreManifestFromConfig(t *testing.T, item config.PluginInstanceConfig) pluginstore.Manifest {
t.Helper()