Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ func runInfraPlan(args []string) error {
fmt.Println()
fmt.Println("Pending JIT resolution (apply-time):")
for _, d := range resolutionDiags {
fmt.Printf(" %s: ${%s}\n", d.ResourceName, d.Ref)
fmt.Printf(" %s: %s\n", d.ResourceName, formatResolutionDiagnosticRef(d.Ref))
}
}

Expand Down Expand Up @@ -688,6 +688,34 @@ func formatPlanMarkdown(plan interfaces.IaCPlan, showSensitive bool) string {
return sb.String()
}

func formatResolutionDiagnosticRef(ref string) string {
if isSensitiveResolutionRef(ref) {
return "<redacted sensitive ref>"
}
return "${" + ref + "}"
}

func isSensitiveResolutionRef(ref string) bool {
ref = strings.ToLower(ref)
if ref == "" {
return false
}
parts := strings.Split(ref, ".")
last := parts[len(parts)-1]
for _, sensitiveKey := range secrets.DefaultSensitiveKeys() {
k := strings.ToLower(sensitiveKey)
if ref == k || last == k {
return true
}
}
for _, token := range []string{"secret", "token", "password", "passwd", "pwd", "private", "credential", "dsn", "uri", "url"} {
if strings.Contains(ref, token) {
return true
}
}
return false
}

// resourceSummaryKeys returns the most relevant key-value pairs to display for
// a given resource type. Each entry is a [key, value] pair. Sensitive keys are
// masked as "(sensitive)" unless showSensitive is true.
Expand Down
15 changes: 15 additions & 0 deletions cmd/wfctl/infra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,21 @@ func TestFormatPlanTable_MasksSensitiveInDefaultMode(t *testing.T) {
}
}

func TestFormatResolutionDiagnosticRefRedactsSensitiveRefs(t *testing.T) {
for _, ref := range []string{"JWT_SECRET", "STRIPE_SECRET_KEY", "DATABASE_URL", "bmw-database.uri"} {
got := formatResolutionDiagnosticRef(ref)
if strings.Contains(got, ref) || strings.Contains(got, "${") {
t.Fatalf("formatResolutionDiagnosticRef(%q) = %q, want redacted", ref, got)
}
}
for _, ref := range []string{"IMAGE_SHA", "bmw-database.id"} {
got := formatResolutionDiagnosticRef(ref)
if got != "${"+ref+"}" {
t.Fatalf("formatResolutionDiagnosticRef(%q) = %q, want literal ref", ref, got)
}
}
}

// --- helpers ---

func writeTempYAML(t *testing.T, content string) (string, error) {
Expand Down
129 changes: 119 additions & 10 deletions cmd/wfctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,65 @@ func TestRunValidateInvalid(t *testing.T) {
}
}

func TestRunValidateStrict(t *testing.T) {
func TestRunValidateStrictByDefault(t *testing.T) {
dir := t.TempDir()
emptyConfig := "modules: []\n"
path := writeTestConfig(t, dir, "empty.yaml", emptyConfig)
err := runValidate([]string{"-strict", path})
err := runValidate([]string{path})
if err == nil {
t.Fatal("expected error in strict mode with empty modules")
t.Fatal("expected error by default with empty modules")
}
}

func TestRunValidateLooseAllowsEmptyModules(t *testing.T) {
dir := t.TempDir()
emptyConfig := "modules: []\n"
path := writeTestConfig(t, dir, "empty.yaml", emptyConfig)
if err := runValidate([]string{"--loose", path}); err != nil {
t.Fatalf("expected --loose to allow empty modules, got: %v", err)
}
if err := runValidate([]string{"--non-strict", path}); err != nil {
t.Fatalf("expected --non-strict to allow empty modules, got: %v", err)
}
}

func TestRunValidateCatchesDBQueryCachedRowWrapperByDefault(t *testing.T) {
dir := t.TempDir()
cfg := `
modules:
- name: router
type: http.router
pipelines:
payment-create-intent:
trigger:
type: http
config:
path: /api/v1/payments/intents
method: POST
steps:
- name: check_mock_mode
type: step.db_query_cached
config:
database: db
query: "SELECT COALESCE((SELECT settings->>'mock_payments' FROM tenants WHERE id = $1), 'false') AS mock_payments"
mode: single
cache_key: tenant:test:mock_payments
- name: set_mock_flag
type: step.set
config:
values:
is_mock: '{{ index .steps "check_mock_mode" "row" "mock_payments" | default "false" }}'
`
path := writeTestConfig(t, dir, "payment.yaml", cfg)
err := runValidate([]string{path})
if err == nil {
t.Fatal("expected validate to fail on stale db_query_cached row wrapper")
}
if !strings.Contains(err.Error(), "pipeline-refs warning") || !strings.Contains(err.Error(), "check_mock_mode.row") {
t.Fatalf("validate error should mention pipeline refs and check_mock_mode.row, got: %v", err)
}
if err := runValidate([]string{"--loose", path}); err != nil {
t.Fatalf("--loose should allow transitional pipeline reference warnings, got: %v", err)
}
}

Expand Down Expand Up @@ -252,12 +304,12 @@ modules:
`
path := writeTestConfig(t, dir, "custom.yaml", unknownTypeConfig)
// Should fail without the flag
err := runValidate([]string{path})
err := runValidate([]string{"--allow-no-entry-points", path})
if err == nil {
t.Fatal("expected error for unknown type")
}
// Should pass with the flag
if err := runValidate([]string{"--skip-unknown-types", path}); err != nil {
if err := runValidate([]string{"--skip-unknown-types", "--allow-no-entry-points", path}); err != nil {
t.Fatalf("expected pass with --skip-unknown-types, got: %v", err)
}
}
Expand Down Expand Up @@ -382,12 +434,12 @@ modules:
path := writeTestConfig(t, dir, "workflow.yaml", configContent)

// Without --plugin-dir: should fail (unknown type)
if err := runValidate([]string{path}); err == nil {
if err := runValidate([]string{"--allow-no-entry-points", path}); err == nil {
t.Fatal("expected error for unknown external module type without --plugin-dir")
}

// With --plugin-dir: should pass
if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil {
if err := runValidate([]string{"--plugin-dir", pluginsDir, "--allow-no-entry-points", path}); err != nil {
t.Errorf("expected valid config with --plugin-dir, got: %v", err)
}
t.Cleanup(func() {
Expand All @@ -414,12 +466,12 @@ func TestRunValidatePluginDirCapabilities(t *testing.T) {
path := writeTestConfig(t, dir, "workflow.yaml", configContent)

// Without --plugin-dir: should fail (unknown type)
if err := runValidate([]string{path}); err == nil {
if err := runValidate([]string{"--allow-no-entry-points", path}); err == nil {
t.Fatal("expected error for unknown external module type without --plugin-dir")
}

// With --plugin-dir: should pass (types from capabilities object are recognized)
if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil {
if err := runValidate([]string{"--plugin-dir", pluginsDir, "--allow-no-entry-points", path}); err != nil {
t.Errorf("expected valid config with --plugin-dir (capabilities format), got: %v", err)
}
t.Cleanup(func() {
Expand Down Expand Up @@ -462,7 +514,7 @@ modules:
- name: ext-mod
type: custom.step_schema_validate_testonly
`)
if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil {
if err := runValidate([]string{"--plugin-dir", pluginsDir, "--allow-no-entry-points", path}); err != nil {
t.Fatalf("expected valid config with --plugin-dir, got: %v", err)
}
if got := reg.Get("step.schema_validate_testonly"); got == nil {
Expand All @@ -473,3 +525,60 @@ modules:
reg.Unregister("step.schema_validate_testonly")
})
}

func TestRunValidatePluginDirUsesStepSchemasForPipelineRefs(t *testing.T) {
pluginsDir := t.TempDir()
pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin-output-schema")
if err := os.MkdirAll(pluginSubdir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name": "my-ext-plugin-output-schema",
"version": "1.0.0",
"stepTypes": ["step.output_schema_validate_testonly"],
"stepSchemas": [
{
"type": "step.output_schema_validate_testonly",
"description": "test-only plugin step output schema",
"outputs": [
{"key": "known_output", "type": "string"}
]
}
]
}`
if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
reg := schema.GetStepSchemaRegistry()
t.Cleanup(func() {
schema.UnregisterModuleType("step.output_schema_validate_testonly")
reg.Unregister("step.output_schema_validate_testonly")
})

dir := t.TempDir()
path := writeTestConfig(t, dir, "workflow.yaml", `
modules:
- name: server
type: http.server
config:
address: ":8080"
pipelines:
test:
steps:
- name: plugin-step
type: step.output_schema_validate_testonly
- name: consume
type: step.set
config:
values:
result: '{{ step "plugin-step" "missing_output" }}'
`)

err := runValidate([]string{"--plugin-dir", pluginsDir, "--allow-no-entry-points", path})
if err == nil {
t.Fatal("expected strict validation to reject plugin step output field not declared by plugin schema")
}
if !strings.Contains(err.Error(), "missing_output") {
t.Fatalf("expected error to mention missing plugin output field, got: %v", err)
}
}
81 changes: 81 additions & 0 deletions cmd/wfctl/plugin_install_lockfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,87 @@ plugins:
}
}

func TestInstallFromWfctlLockfile_UsesCachedInstallWhenLockMetadataMatches(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, ".wfctl-lock.yaml")
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}

origWD, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(origWD) }) //nolint:errcheck

var downloadHits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
downloadHits.Add(1)
http.Error(w, "cache should satisfy lockfile install", http.StatusInternalServerError)
}))
defer srv.Close()

const pluginName = "auth"
installDir := filepath.Join(pluginDir, pluginName)
if err := os.MkdirAll(installDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, pluginName), []byte("#!/bin/sh\necho cached auth\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "plugin.json"), minimalPluginJSON(pluginName, "v1.2.3"), 0o644); err != nil {
t.Fatal(err)
}

plat := config.WfctlLockPlatform{
URL: srv.URL + "/workflow-plugin-auth-" + currentPlatformKey() + ".tar.gz",
SHA256: strings.Repeat("a", 64),
}
entry := config.WfctlLockPluginEntry{
Version: "v1.2.3",
Source: "github.com/GoCodeAlone/workflow-plugin-auth",
Platforms: map[string]config.WfctlLockPlatform{
currentPlatformKey(): plat,
},
}
meta := lockfileInstallMetadata{
Version: entry.Version,
Source: entry.Source,
Platform: currentPlatformKey(),
URL: plat.URL,
SHA256: plat.SHA256,
}
metaData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, lockfileInstallMetadataName), append(metaData, '\n'), 0o600); err != nil {
t.Fatal(err)
}

lf := &config.WfctlLockfile{
Version: 1,
GeneratedAt: time.Now(),
Plugins: map[string]config.WfctlLockPluginEntry{
"workflow-plugin-auth": entry,
},
}
if err := config.SaveWfctlLockfile(lockPath, lf); err != nil {
t.Fatal(err)
}

if err := installFromWfctlLockfile(pluginDir, lockPath, lf); err != nil {
t.Fatalf("installFromWfctlLockfile should reuse cached plugin matching lock metadata: %v", err)
}
if got := downloadHits.Load(); got != 0 {
t.Fatalf("download endpoint was hit %d times; cached install should satisfy lockfile", got)
}
}

func TestInstallFromWfctlLockfile_ScrubsExplicitEmptyTopLevelSHA256(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, ".wfctl-lock.yaml")
Expand Down
Loading
Loading