fae2303db3
* 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>
412 lines
14 KiB
Go
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())
|
|
}
|
|
}
|