Files
kandev/apps/backend/internal/plugins/autoupdate_handlers_test.go
T
João Salavisa 5db3e48262 feat: add opt-in plugin auto-updater (#1903)
* 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>
2026-07-24 13:04:54 +01:00

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())
}
}