5db3e48262
* feat: add opt-in plugin auto-updater Automatically update installed plugins in the background, off by default and opt-in. Builds on the existing verified install pipeline — an auto-update is just an automatic reinstall of a newer catalog package_url. Two-tier toggle on Settings > Plugins: an instance-wide default (SQLite plugin_settings) plus a per-plugin override (tri-state auto_update on the plugin record, carried forward on upgrade). The AutoUpdatePoller (~90-min cadence + a boot sweep) upgrades only plugins that are currently active, opted in, and report update_available; disabled/errored plugins stay on their installed version. New endpoints: GET/PUT /api/plugins/settings and PUT /api/plugins/:id/auto-update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): recheck auto-update eligibility at install and fix test mock Address PR #1903 review feedback: - Re-check each plugin's current status + effective opt-in immediately before InstallFromURL (eligibleForAutoUpdate), closing the TOCTOU window between the candidate snapshot / catalog fetch and the install so a plugin disabled or opted out mid-sweep is never reinstalled and reactivated. - Correct the marketplace spec to describe the real failure behavior (shared with manual updates) instead of promising a version rollback the install pipeline does not perform. - Fix the frontend CI failure: the plugins page test mocked the whole plugins-api module without the new settings/auto-update functions, so useAutoUpdateSettings threw at mount. - Add an aria-label to the per-plugin auto-update Switch and Reset control. - Add a use-auto-update-settings hook test (load, failure, optimistic setDefault, rollback) and an eligibleForAutoUpdate unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
3.6 KiB
Go
112 lines
3.6 KiB
Go
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
)
|
|
|
|
func newTestRouterWithSettings(t *testing.T) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetSecrets(newFakeSecretRevealer())
|
|
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
ss, err := newSettingsStore(db.NewPool(conn, conn))
|
|
if err != nil {
|
|
t.Fatalf("new settings store: %v", err)
|
|
}
|
|
svc.SetSettings(ss)
|
|
|
|
router := gin.New()
|
|
RegisterRoutes(router, svc, nil, testLogger(t))
|
|
return router, svc
|
|
}
|
|
|
|
func TestGetSettingsReturnsDefault(t *testing.T) {
|
|
router, _ := newTestRouterWithSettings(t)
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/settings", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /settings status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
|
}
|
|
var got Settings
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.AutoUpdateDefault {
|
|
t.Fatal("fresh settings should report auto_update_default=false")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsPersistsDefault(t *testing.T) {
|
|
router, _ := newTestRouterWithSettings(t)
|
|
rec := doRequest(router, http.MethodPut, "/api/plugins/settings",
|
|
`{"auto_update_default":true}`, map[string]string{"Content-Type": "application/json"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT /settings status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
|
}
|
|
rec = doRequest(router, http.MethodGet, "/api/plugins/settings", "", nil)
|
|
var got Settings
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !got.AutoUpdateDefault {
|
|
t.Fatal("auto_update_default did not persist across PUT then GET")
|
|
}
|
|
}
|
|
|
|
func TestSetAutoUpdateEndpointSetsAndClearsOverride(t *testing.T) {
|
|
router, svc := newTestRouterWithSettings(t)
|
|
installTestPlugin(t, svc, "kandev-plugin-slack")
|
|
|
|
// Set the override on.
|
|
rec := doRequest(router, http.MethodPut, "/api/plugins/kandev-plugin-slack/auto-update",
|
|
`{"auto_update":true}`, map[string]string{"Content-Type": "application/json"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT auto-update status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
|
}
|
|
var got store.Record
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.AutoUpdate == nil || !*got.AutoUpdate {
|
|
t.Fatalf("response record AutoUpdate = %v, want *true", got.AutoUpdate)
|
|
}
|
|
|
|
// Clear it with an explicit null.
|
|
rec = doRequest(router, http.MethodPut, "/api/plugins/kandev-plugin-slack/auto-update",
|
|
`{"auto_update":null}`, map[string]string{"Content-Type": "application/json"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT auto-update(null) status = %d, want 200", rec.Code)
|
|
}
|
|
got = store.Record{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.AutoUpdate != nil {
|
|
t.Fatalf("response record AutoUpdate = %v, want nil after clear", got.AutoUpdate)
|
|
}
|
|
}
|
|
|
|
func TestSetAutoUpdateEndpointUnknownPluginIs404(t *testing.T) {
|
|
router, _ := newTestRouterWithSettings(t)
|
|
rec := doRequest(router, http.MethodPut, "/api/plugins/does-not-exist/auto-update",
|
|
`{"auto_update":true}`, map[string]string{"Content-Type": "application/json"})
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("PUT auto-update on unknown plugin status = %d, want 404 (body: %s)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|