Files
kandev/apps/backend/internal/plugins/config_test.go
T
2026-09-15 09:40:39 +01:00

426 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 TestMergeMaskedSecretsDoesNotPreserveLegacyUtilitySelector(t *testing.T) {
schema := map[string]any{"properties": map[string]any{
"agent_profile": map[string]any{"type": "string", "format": "agent-profile"},
}}
merged := mergeMaskedSecrets(
map[string]any{"agent_profile": "profile-1"},
map[string]any{"utility_agent": "utility-agent-1"},
schema,
)
if _, found := merged["utility_agent"]; found {
t.Fatalf("mergeMaskedSecrets() retained the legacy selector: %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 := newAdminTestRouter(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())
}
}