diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -2627,8 +2627,12 @@ 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 } diff --git a/internal/api/handlers/management/oauth_callback.go b/internal/api/handlers/management/oauth_callback.go --- a/internal/api/handlers/management/oauth_callback.go +++ b/internal/api/handlers/management/oauth_callback.go @@ -86,9 +86,13 @@ 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) diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -12,8 +12,9 @@ ) const ( - oauthSessionTTL = 10 * time.Minute - maxOAuthStateLength = 128 + oauthSessionTTL = 10 * time.Minute + oauthCompletedSessionTTL = time.Minute + maxOAuthStateLength = 128 ) const ( @@ -33,23 +34,30 @@ 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 @@ s.purgeExpiredLocked(now) session, ok := s.sessions[state] - if !ok { + if !ok || session.Completed { return } session.Status = message @@ -146,7 +154,15 @@ 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 @@ 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 @@ if !ok { return false } - if session.Status != "" { + if session.Completed || session.Status != "" { return false } if provider == "" { @@ -239,18 +259,18 @@ 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 { diff --git a/internal/api/handlers/management/oauth_sessions_test.go b/internal/api/handlers/management/oauth_sessions_test.go new file mode 100644 --- /dev/null +++ b/internal/api/handlers/management/oauth_sessions_test.go @@ -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 + }) +} diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -59,32 +59,34 @@ } 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 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 @@ } 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 @@ 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 @@ 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()}) @@ -221,6 +238,9 @@ } source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, storeAuth, sources, id, c.Query("source"), c) if !okPlugin { + return + } + if !validatePluginStoreInstallSource(c, configs, sources, id, source.ID) { return } pluginIsBusy := func() bool { return pluginBusy(host, id) } @@ -717,6 +737,7 @@ status := statuses[id] status.Configured = true status.Enabled = pluginInstanceEnabled(item) + status.InstalledSourceID, status.InstalledSourceURL, status.StoreManaged = pluginStoreConfiguredSource(item) statuses[id] = status } if host != nil { @@ -736,6 +757,95 @@ statuses[id] = status } 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 { diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -402,6 +402,154 @@ } } +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") @@ -1145,6 +1293,18 @@ Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin", Install: pluginstore.InstallPlan{Type: pluginstore.InstallTypeGitHubRelease}, } +} + +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 {