Files
kandev/apps/backend/internal/plugins/config_test.go
T
João Salavisa fae2303db3 feat(plugins): per-plugin settings pages driven by manifest config_schema (#1761)
* feat(plugins): per-plugin settings pages driven by manifest config_schema

Settings > Plugins rows now open /settings/plugins/:id — a detail page
showing the manifest plus a settings form generated from the plugin
author's config_schema. Secret fields (secret: true / format: password)
are masked on the operator API and kept on masked re-submits; a running
plugin restarts on save and reads its config (cleartext secrets included)
through the new Host GetConfig gRPC RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): write plugin config file owner-only (0600)

Operator config can now carry cleartext secret values (config_schema
fields marked secret), so <id>.config.yml must not be world-readable —
match the 0600 mode the record file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets

Plugins get vault-backed secret primitives on the Host API
(GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to
plugin:<id>㊙️<key> so a plugin can only touch its own entries;
purged on uninstall). Secret config_schema fields now live in kandev's
AES-256-GCM vault too — <id>.config.yml persists only a vault reference,
and the Host GetConfig RPC resolves it back to cleartext for the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo

The PR body now points at raw URLs pinned to an unmerged media ref, so
the binaries never land in main (PRs are squash-merged; only the branch
tip tree merges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review feedback on settings validation and save flow

- integer form fields reject non-integral input instead of parseInt-truncating
- enum selections keep their schema-declared types (numeric enums round-trip)
- backend enum validation normalizes int-vs-float64 before comparison
- maskSecrets masks any non-zero secret value regardless of runtime type
- save flow splits PATCH from refetch: a refetch failure no longer reports a
  successful save as an error, and typed secrets are masked on screen
- optional enum fields gain a 'Not set' option so they can be cleared
- resolveConfigSecrets copies before mutating; restart-failure SetStatus
  errors are logged; SDK test fake returns a non-nil empty config map
- add missing tests: usePluginConfigForm hook, getPluginConfig client,
  integer/enum regression cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden secret config invariants from review

- reject non-string values on declared secret fields at validation time,
  closing the path where a numeric/boolean secret bypassed the vault and
  persisted cleartext in the config file
- delete removed secret fields' vault entries only AFTER the config commit
  succeeds, so a failed SetConfig never leaves the still-current config
  referencing a deleted vault entry
- uninstall fails closed: the plugin's vault namespace is purged before any
  destructive step, and a cleanup failure aborts the uninstall (retryable)
  instead of letting a reinstall inherit stale secrets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation

- UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets
  snapshots each vault entry before writing and returns a rollback that
  restores prior values (or deletes newly-created ones). A vault.Set failure
  mid-loop or a failed SetConfig commit rolls back, so a request reported as
  failed never changes the value a still-current config ref resolves to.
- Uninstall stops the plugin process BEFORE purging its vault namespace, so
  the plugin can no longer race the cleanup with a SetSecret between ListIDs
  and the deletes; the purge stays fail-closed and retryable.
- Thread the request context through Uninstall -> deletePluginSecrets instead
  of context.Background(), per the pass-context-through-chains convention.
- Consolidate the 'secret not found:' prefix check into isSecretNotFound
  (host.go + service rollback share one definition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status

- vault rollback closures now run on context.WithoutCancel(ctx): a request
  cancelled mid-save (browser closed) can no longer abort the rollback and
  leave the vault holding new values while the unchanged config still points
  at them (Greptile P1).
- storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin
  declares secret config fields but no vault is wired, instead of silently
  persisting the secret in cleartext.
- vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a
  backend read failure aborts the update before writing rather than risk a
  rollback that deletes a real secret; rollback now returns errors, joined
  into UpdateConfig's failure so an inconsistent vault is surfaced, not just
  logged.
- Uninstall reconciles lifecycle status when a secret-purge failure aborts
  after the process was stopped: a previously-running plugin transitions to
  error and notifies observers instead of being reported active with no
  process running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag

- cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching
  the rollback path, so a client disconnect right after the config commit can
  no longer cancel the delete and orphan now-unreferenced vault entries.
- tests: assert GetConfig succeeds before treating an empty map as proof that
  nothing persisted (a swallowed read error would be a false pass).
- web: on a post-save refetch failure, rebase initialValues onto the masked
  snapshot so the saved form no longer reads as dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater

- config_test: capture and fatal on GetConfig errors at the four remaining
  positive-assertion sites (a nil from a failed read would panic on the next
  map access instead of giving a clear failure), matching the earlier fix.
- web hook: compute the masked post-save snapshot from the current values and
  apply it to setValues + setInitialValues directly, instead of calling
  setInitialValues inside a setValues functional updater (a React anti-pattern
  that runs twice under StrictMode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): skip validation of a secret field's own vault ref

- validateConfigSchema takes the plugin id and skips a secret field whose
  value is that field's config vault ref (an internal storage marker,
  already validated when first set). Without this, a field declaring both
  secret: true and enum: [...] 400s on every save that keeps the mask,
  because the enum check saw the vault ref instead of a user value. Freshly
  entered secret values are still validated; a foreign plugin's ref is not
  skipped. Covered by unit + service-level regression tests.
- surface mid-loop vault rollback failures in storeConfigSecrets via a
  warning instead of discarding them (double-fault observability).
- web: integer inputs get step=1 so browsers flag non-integral input in
  place (serializeConfigValues remains the backstop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split config secret tests into config_secrets_test.go

config_test.go had grown past the 800-line backend file-length limit
(revive), failing CI lint. Move the vault-backed config and plugin-scoped
secret tests (plus their fake-vault/store helpers) into a sibling file;
no test logic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:13:58 +01:00

412 lines
14 KiB
Go

package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
goruntime "runtime"
"testing"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
)
// testConfigSchema mirrors the JSON shape a manifest's config_schema decodes
// to: a JSON-Schema-like object with a secret token field (the GitHub-PAT
// case), a plain string, an integer, an enum, and a boolean.
func testConfigSchema() map[string]any {
return map[string]any{
"type": "object",
"required": []any{"github_token"},
"properties": map[string]any{
"github_token": map[string]any{"type": "string", "secret": true},
"webhook_key": map[string]any{"type": "string", "format": "password"},
"org": map[string]any{"type": "string"},
"max_items": map[string]any{"type": "integer"},
"channel": map[string]any{"type": "string", "enum": []any{"dev", "ops"}},
"verbose": map[string]any{"type": "boolean"},
},
}
}
// testPackageWithConfigSchema builds a valid runtime-managed package whose
// manifest declares the config schema above.
func testPackageWithConfigSchema(t *testing.T, id string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: 1.0.0
display_name: Test Plugin
capabilities:
state: true
config_schema:
type: object
required: ["github_token"]
properties:
github_token:
type: string
secret: true
webhook_key:
type: string
format: password
org:
type: string
max_items:
type: integer
channel:
type: string
enum: ["dev", "ops"]
verbose:
type: boolean
runtime:
type: binary
executables:
%s: server/plugin
`, id, platformKey)
var buf bytes.Buffer
files := map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}
if err := pkgtartest.WritePackage(&buf, files); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
func installConfigPlugin(t *testing.T, svc *Service, id string) *store.Record {
t.Helper()
rec, err := svc.Install(context.Background(), testPackageWithConfigSchema(t, id))
if err != nil {
t.Fatalf("Install(%q): %v", id, err)
}
return rec
}
// --- helper unit tests ---
func TestSecretPropertyKeysDetectsSecretAndPasswordFormat(t *testing.T) {
keys := secretPropertyKeys(testConfigSchema())
if !keys["github_token"] || !keys["webhook_key"] {
t.Fatalf("secretPropertyKeys() = %v, want github_token and webhook_key", keys)
}
if keys["org"] {
t.Fatalf("secretPropertyKeys() flagged non-secret field org")
}
}
func TestMaskSecretsMasksOnlyNonEmptySecretStrings(t *testing.T) {
masked := maskSecrets(map[string]any{
"github_token": "ghp_real",
"webhook_key": "",
"org": "kdlbs",
}, testConfigSchema())
if masked["github_token"] != configSecretMask {
t.Fatalf("github_token = %v, want mask", masked["github_token"])
}
if masked["webhook_key"] != "" {
t.Fatalf("empty secret should stay empty, got %v", masked["webhook_key"])
}
if masked["org"] != "kdlbs" {
t.Fatalf("org = %v, want kdlbs", masked["org"])
}
}
func TestMergeMaskedSecretsKeepsStoredValueForMask(t *testing.T) {
merged := mergeMaskedSecrets(
map[string]any{"github_token": configSecretMask, "org": "new-org"},
map[string]any{"github_token": "ghp_real", "org": "old-org"},
testConfigSchema(),
)
if merged["github_token"] != "ghp_real" {
t.Fatalf("github_token = %v, want stored ghp_real", merged["github_token"])
}
if merged["org"] != "new-org" {
t.Fatalf("org = %v, want new-org (full replace for non-secrets)", merged["org"])
}
}
func TestMergeMaskedSecretsDropsMaskWithNoStoredValue(t *testing.T) {
merged := mergeMaskedSecrets(
map[string]any{"github_token": configSecretMask},
map[string]any{},
testConfigSchema(),
)
if _, present := merged["github_token"]; present {
t.Fatalf("mask with no stored value should be dropped, got %v", merged)
}
}
func TestValidateConfigSchema(t *testing.T) {
schema := testConfigSchema()
cases := []struct {
name string
config map[string]any
wantErr bool
}{
{"valid full", map[string]any{
"github_token": "ghp_x", "org": "kdlbs", "max_items": float64(10),
"channel": "dev", "verbose": true,
}, false},
{"missing required", map[string]any{"org": "kdlbs"}, true},
{"wrong string type", map[string]any{"github_token": "x", "org": float64(3)}, true},
{"wrong boolean type", map[string]any{"github_token": "x", "verbose": "yes"}, true},
{"non-integral integer", map[string]any{"github_token": "x", "max_items": 2.5}, true},
{"integral float accepted", map[string]any{"github_token": "x", "max_items": float64(5)}, false},
{"yaml int accepted", map[string]any{"github_token": "x", "max_items": 5}, false},
{"enum mismatch", map[string]any{"github_token": "x", "channel": "prod"}, true},
{"undeclared key allowed", map[string]any{"github_token": "x", "extra": "ok"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateConfigSchema("test-plugin", tc.config, schema)
if tc.wantErr && !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("error = %v, want ErrConfigInvalid", err)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateConfigSchemaNilSchemaIsPermissive(t *testing.T) {
if err := validateConfigSchema("test-plugin", map[string]any{"anything": 1}, nil); err != nil {
t.Fatalf("nil schema should accept anything, got %v", err)
}
}
// --- service tests ---
func TestServiceGetMaskedConfigMasksSecrets(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": "ghp_real", "org": "kdlbs",
})
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
masked, err := svc.GetMaskedConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetMaskedConfig: %v", err)
}
if masked["github_token"] != configSecretMask {
t.Fatalf("masked github_token = %v, want mask", masked["github_token"])
}
if masked["org"] != "kdlbs" {
t.Fatalf("masked org = %v, want kdlbs", masked["org"])
}
// The config file holds a vault ref, never the cleartext; the vault holds
// the cleartext.
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("store GetConfig: %v", err)
}
if stored["github_token"] != configVaultRef("kandev-plugin-github", "github_token") {
t.Fatalf("stored github_token = %v, want vault ref", stored["github_token"])
}
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_real" {
t.Fatalf("vault value = %q, want cleartext ghp_real", v)
}
}
func TestServiceUpdateConfigPreservesMaskedSecret(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
}
must(svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}))
// Re-submitting the form: token comes back as the mask, org changes.
must(svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": configSecretMask, "org": "kdlbs",
}))
// The masked round trip keeps the vault's cleartext value; org updates.
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_real" {
t.Fatalf("vault value = %q, want preserved ghp_real", v)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if stored["org"] != "kdlbs" {
t.Fatalf("stored org = %v, want kdlbs", stored["org"])
}
}
func TestServiceUpdateConfigInvalidRejectedAndNotPersisted(t *testing.T) {
svc, fsStore, _ := newTestService(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"org": "no-token"})
if !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("error = %v, want ErrConfigInvalid", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if len(stored) != 0 {
t.Fatalf("invalid config must not persist, got %v", stored)
}
}
func TestServiceUpdateConfigRestartsRunningPlugin(t *testing.T) {
svc, _, rt := newTestService(t)
svc.SetSecrets(newFakeSecretRevealer())
installConfigPlugin(t, svc, "kandev-plugin-github") // Install activates -> running
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
if !rt.stopped("kandev-plugin-github") {
t.Fatalf("running plugin should be stopped on config change")
}
if got := rt.startCallCount("kandev-plugin-github"); got != 2 {
t.Fatalf("start calls = %d, want 2 (install + config restart)", got)
}
rec, _ := svc.Get("kandev-plugin-github")
if rec.Status != StatusActive {
t.Fatalf("status = %q, want active after restart", rec.Status)
}
}
func TestServiceUpdateConfigDoesNotSpawnStoppedPlugin(t *testing.T) {
svc, _, rt := newTestService(t)
svc.SetSecrets(newFakeSecretRevealer())
installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.Disable("kandev-plugin-github"); err != nil {
t.Fatalf("Disable: %v", err)
}
before := rt.startCallCount("kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
if got := rt.startCallCount("kandev-plugin-github"); got != before {
t.Fatalf("start calls = %d, want %d (no spawn for a stopped plugin)", got, before)
}
}
func TestServiceUpdateConfigRestartFailurePersistsConfigAndSetsError(t *testing.T) {
svc, fsStore, rt := newTestService(t)
vault := newFakeSecretRevealer()
svc.SetSecrets(vault)
installConfigPlugin(t, svc, "kandev-plugin-github")
rt.setStartErr("kandev-plugin-github", errors.New("spawn boom"))
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"})
if err == nil {
t.Fatalf("UpdateConfig should surface the restart failure")
}
// The config commit and vault write succeeded (only the restart failed):
// the config file keeps the ref, the vault keeps the value.
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if stored["github_token"] != configVaultRef("kandev-plugin-github", "github_token") {
t.Fatalf("config should persist despite restart failure, got %v", stored)
}
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_x" {
t.Fatalf("vault value = %q, want ghp_x", v)
}
rec, _ := svc.Get("kandev-plugin-github")
if rec.Status != StatusError {
t.Fatalf("status = %q, want error after failed restart", rec.Status)
}
}
// --- host RPC tests ---
func TestPluginHostGetConfigReturnsCleartextConfig(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
rec := installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// The host resolves the config file's vault ref back to cleartext for the
// plugin process.
host := &pluginHost{
pluginID: "kandev-plugin-github",
configSchema: rec.ConfigSchema,
configs: fsStore,
secrets: vault,
}
config, err := host.GetConfig(context.Background())
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if config["github_token"] != "ghp_real" {
t.Fatalf("host GetConfig github_token = %v, want cleartext ghp_real", config["github_token"])
}
}
func TestPluginHostGetConfigWithoutStoreReturnsEmpty(t *testing.T) {
host := &pluginHost{pluginID: "p"}
config, err := host.GetConfig(context.Background())
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if config == nil || len(config) != 0 {
t.Fatalf("GetConfig = %v, want empty non-nil map", config)
}
}
// --- handler tests ---
func TestGetConfigHandlerReturnsMaskedConfig(t *testing.T) {
router, svc := newTestRouter(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-github/config", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !bytes.Contains([]byte(body), []byte(configSecretMask)) {
t.Fatalf("body should carry the mask, got %s", body)
}
if bytes.Contains([]byte(body), []byte("ghp_real")) {
t.Fatalf("cleartext secret leaked on the operator API: %s", body)
}
}
func TestGetConfigHandlerMissingReturns404(t *testing.T) {
router, _ := newTestRouter(t)
rec := doRequest(router, http.MethodGet, "/api/plugins/missing/config", "", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestUpdateConfigHandlerInvalidSchemaReturns400(t *testing.T) {
router, svc := newTestRouter(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
rec := doRequest(router, http.MethodPatch, "/api/plugins/kandev-plugin-github",
`{"config":{"org":"missing-token"}}`, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
}
}