Skip to content

Commit c743097

Browse files
authored
fix: require web credentials conditionally, and make validate actually validate (#775)
Closes #774, partially addresses #773. ## Strict validation demanded credentials for a feature you were not using A string field with no `default` tag was required unconditionally unless it appeared on an allow-list, so switching on `enable-strict-validation` produced this for a config with no web UI at all: ``` 'web-password-hash': is required 'web-secret-key': is required ``` The operator most likely to enable validation is the one who wants their config checked, and they were met with errors about a feature they do not use. The practical answer was to leave validation off — which is how the *other* validation gaps stayed invisible. A field can now name the sibling flag that governs it. The two web fields are required exactly when `web-auth-enabled` is set. A gate that cannot be resolved keeps the field required: a mapping that has drifted from the config should surface, not silently drop a check. ## `ofelia validate` did not validate The checks sat behind `enable-strict-validation` (default `false`), so the command whose only purpose is checking a config answered "looks fine" without inspecting it. That flag is about whether the **daemon** refuses to start; running `validate` is itself the request to have the config checked. `cli/config.go` even points at the command — *"Use 'ofelia validate --config=…' for detailed validation"* — while the command did not enable the detail. `validate` now runs the validator regardless. When the flag is on, `BuildFromFile` has already run it, so this only adds work in the default case. ## What this does **not** fix — #773 stays open The validator walks structs and skips maps. Every job lives in one (`map[string]*RunJobConfig` and friends), so **no job is reachable by the validator at all**. An unparsable schedule passes with the flag on or off: ``` enable-strict-validation = true + web-address = definitely-not-an-address → reported enable-strict-validation = true + schedule = not-a-schedule → not reported ``` Fixing that means traversing the job maps, which under the current *"no `default` tag means required"* rule would demand nearly every job field and reject configs that work today. It needs its own change and its own decision about the required-heuristic, so it is not bundled here. ## A test that was asserting the defect `TestE2E_ExitCode_StrictValidationFails`, added for exit codes in #771, used a config whose only genuine failure was the web-credentials false positive being fixed here — so it was pinning the bug rather than the behaviour. It now fails on a global field that is actually checked, and is renamed to say what it tests. ## Test plan - [x] `go test ./...` — green, coverage 90.32% - [x] `go test -race -tags=e2e ./e2e/...` — green - [x] `golangci-lint run` incl. `--build-tags="e2e unix"` — 0 issues - [x] `lefthook run pre-push` — exit 0 - [x] Both directions verified against the built binary: no web UI → no demand; `web-auth-enabled = true` without a hash → still demanded
2 parents 6b5f157 + 9a92e95 commit c743097

8 files changed

Lines changed: 311 additions & 25 deletions

cli/validate.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"os"
1111

1212
defaults "github.com/creasty/defaults"
13+
14+
"github.com/netresearch/ofelia/config"
1315
)
1416

1517
// ValidateCommand validates the config file
@@ -39,6 +41,21 @@ func (c *ValidateCommand) Execute(_ []string) error {
3941
}
4042
}
4143

44+
// Validate regardless of enable-strict-validation. That flag decides
45+
// whether the *daemon* refuses to start on a questionable config; running
46+
// this command is itself the request to have the config checked, so
47+
// answering "looks fine" because a runtime toggle is off would make the
48+
// one command whose purpose is validation the one that does not validate.
49+
//
50+
// BuildFromFile has already run the same validator when the flag is on, so
51+
// this only adds work in the default case.
52+
if !conf.Global.EnableStrictValidation {
53+
if err := config.NewConfigValidator(conf).Validate(); err != nil {
54+
c.Logger.Error("ERROR")
55+
return fmt.Errorf("configuration validation failed: %w", err)
56+
}
57+
}
58+
4259
applyConfigDefaults(conf)
4360
out, err := json.MarshalIndent(conf, "", " ")
4461
if err != nil {

cli/validate_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,40 @@ func TestValidateExecuteMissingFile(t *testing.T) {
6767
err := cmd.Execute(nil)
6868
assert.Error(t, err)
6969
}
70+
71+
// TestValidateExecuteRunsValidatorWithoutStrictFlag pins that running validate
72+
// is itself the request to have the config checked. The checks used to sit
73+
// behind enable-strict-validation, which defaults to false, so the one command
74+
// whose purpose is validation reported success on a config it had not
75+
// inspected.
76+
//
77+
// The config below parses cleanly as INI and is only wrong semantically, so it
78+
// exercises the validator rather than the loader.
79+
func TestValidateExecuteRunsValidatorWithoutStrictFlag(t *testing.T) {
80+
// Not parallel: modifies global os.Stdout which races with other tests.
81+
82+
configFile := filepath.Join(t.TempDir(), "config.ini")
83+
content := `
84+
[global]
85+
web-address = definitely-not-an-address
86+
87+
[job-exec "foo"]
88+
schedule = @every 10s
89+
command = echo "foo"
90+
`
91+
require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
92+
93+
r, w, _ := os.Pipe()
94+
oldStdout := os.Stdout
95+
os.Stdout = w
96+
defer func() { os.Stdout = oldStdout }()
97+
98+
cmd := ValidateCommand{ConfigFile: configFile, Logger: test.NewTestLogger()}
99+
err := cmd.Execute(nil)
100+
101+
w.Close()
102+
_, _ = io.ReadAll(r)
103+
104+
require.Error(t, err, "an invalid web-address was accepted without the strict flag")
105+
assert.Contains(t, err.Error(), "web-address")
106+
}

config/validator.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,9 @@ func (cv *Validator2) validateStruct(v *Validator, obj any, path string) {
231231
continue
232232
}
233233

234-
// Validate based on field type and value
235-
cv.validateField(v, field, fieldPath, defaultTag)
234+
// Validate based on field type and value. The enclosing struct travels
235+
// with it so a field can be required conditionally on a sibling.
236+
cv.validateField(v, val, field, fieldPath, defaultTag)
236237
}
237238
}
238239

@@ -274,10 +275,12 @@ func resolveFieldPath(parentPath, fieldName, gcfgTag, mapstructureTag string) st
274275
}
275276

276277
// validateField validates individual fields based on their type and tags
277-
func (cv *Validator2) validateField(v *Validator, field reflect.Value, path string, defaultTag string) {
278+
func (cv *Validator2) validateField(
279+
v *Validator, parent, field reflect.Value, path string, defaultTag string,
280+
) {
278281
switch field.Kind() {
279282
case reflect.String:
280-
cv.validateStringField(v, field, path, defaultTag)
283+
cv.validateStringField(v, parent, field, path, defaultTag)
281284
case reflect.Int, reflect.Int64:
282285
cv.validateIntField(v, field, path)
283286
case reflect.Slice:
@@ -296,7 +299,9 @@ func (cv *Validator2) validateField(v *Validator, field reflect.Value, path stri
296299
}
297300

298301
// validateStringField validates string type fields
299-
func (cv *Validator2) validateStringField(v *Validator, field reflect.Value, path string, defaultTag string) {
302+
func (cv *Validator2) validateStringField(
303+
v *Validator, parent, field reflect.Value, path string, defaultTag string,
304+
) {
300305
str := field.String()
301306

302307
// Skip validation for fields with defaults when they're empty
@@ -305,7 +310,7 @@ func (cv *Validator2) validateStringField(v *Validator, field reflect.Value, pat
305310
}
306311

307312
// Check for required fields
308-
if defaultTag == "" && str == "" && !cv.isOptionalField(path) {
313+
if defaultTag == "" && str == "" && !cv.isOptionalField(path) && cv.gateIsOpen(parent, path) {
309314
v.ValidateRequired(path, str)
310315
}
311316

@@ -491,6 +496,50 @@ func (cv *Validator2) isOptionalField(path string) bool {
491496
return false
492497
}
493498

499+
// requiredWhen names, for a field that only means anything alongside a
500+
// feature, the sibling boolean that switches that feature on.
501+
//
502+
// Without this, a field carrying no `default` tag is required unconditionally,
503+
// which demanded web-UI credentials from every config that enabled strict
504+
// validation — including the ones with no web UI at all. That made strict
505+
// validation impractical to adopt, and an operator who cannot adopt it does
506+
// not get the checks it exists to provide.
507+
// #nosec G101 -- these are INI key names the validator matches on, not values
508+
var requiredWhen = map[string]string{
509+
"web-password-hash": "web-auth-enabled",
510+
"web-secret-key": "web-auth-enabled",
511+
}
512+
513+
// gateIsOpen reports whether a conditionally-required field is currently
514+
// required, i.e. whether the sibling flag that governs it is set. Fields with
515+
// no entry in requiredWhen are always required and answer true.
516+
//
517+
// A gate that cannot be found answers true as well: an unresolvable gate means
518+
// the mapping and the config have drifted apart, and demanding the field is
519+
// the safe direction — it surfaces, where silently dropping the check would
520+
// not.
521+
func (cv *Validator2) gateIsOpen(parent reflect.Value, path string) bool {
522+
gate, conditional := requiredWhen[path]
523+
if !conditional {
524+
return true
525+
}
526+
if !parent.IsValid() || parent.Kind() != reflect.Struct {
527+
return true
528+
}
529+
530+
typ := parent.Type()
531+
for fieldType := range typ.Fields() {
532+
if !fieldType.IsExported() || fieldType.Type.Kind() != reflect.Bool {
533+
continue
534+
}
535+
if fieldType.Tag.Get("gcfg") != gate && fieldType.Tag.Get("mapstructure") != gate {
536+
continue
537+
}
538+
return parent.FieldByIndex(fieldType.Index).Bool()
539+
}
540+
return true
541+
}
542+
494543
// isValidAddress checks if an address string is valid
495544
func (cv *Validator2) isValidAddress(addr string) bool {
496545
// Allow formats like ":8080", "localhost:8080", "127.0.0.1:8080"

config/validator_boundary_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ func TestValidator2ValidateStringFieldDefaults(t *testing.T) {
432432
v := NewValidator()
433433

434434
field := reflect.ValueOf(tt.value)
435-
cv.validateStringField(v, field, tt.path, tt.defaultTag)
435+
cv.validateStringField(v, reflect.Value{}, field, tt.path, tt.defaultTag)
436436

437437
if v.HasErrors() != tt.wantError {
438438
t.Errorf("validateStringField(%q, %q, %q) hasError = %v, want %v",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package config
5+
6+
import (
7+
"reflect"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// A string field carrying no `default` tag is required unless it is on the
13+
// optional list. That heuristic demanded web-UI credentials from every config
14+
// that switched strict validation on, including the ones with no web UI, so
15+
// the only way to run ofelia was to leave the checking off. These pin the
16+
// conditional rule that replaced it: the credentials are required exactly when
17+
// the flag that uses them is set.
18+
19+
// gatedConfig mirrors the shape of the real global section: a boolean that
20+
// turns a feature on, and the fields that feature needs.
21+
type gatedConfig struct {
22+
WebAuthEnabled bool `gcfg:"web-auth-enabled" mapstructure:"web-auth-enabled" default:"false"`
23+
WebPasswordHash string `gcfg:"web-password-hash" mapstructure:"web-password-hash"`
24+
WebSecretKey string `gcfg:"web-secret-key" mapstructure:"web-secret-key"`
25+
}
26+
27+
func TestConditionalRequired_NotDemandedWhenFeatureIsOff(t *testing.T) {
28+
t.Parallel()
29+
30+
err := NewConfigValidator(&gatedConfig{WebAuthEnabled: false}).Validate()
31+
if err != nil {
32+
t.Errorf("web credentials were demanded with web auth off: %v", err)
33+
}
34+
}
35+
36+
func TestConditionalRequired_DemandedWhenFeatureIsOn(t *testing.T) {
37+
t.Parallel()
38+
39+
err := NewConfigValidator(&gatedConfig{WebAuthEnabled: true}).Validate()
40+
if err == nil {
41+
t.Fatal("web auth is on with no password hash or secret key, expected an error")
42+
}
43+
44+
for _, want := range []string{"web-password-hash", "web-secret-key"} {
45+
if !strings.Contains(err.Error(), want) {
46+
t.Errorf("error %q does not mention the missing %s", err, want)
47+
}
48+
}
49+
}
50+
51+
// TestConditionalRequired_SatisfiedWhenProvided closes the loop: with the
52+
// feature on and the fields filled in, nothing is reported.
53+
func TestConditionalRequired_SatisfiedWhenProvided(t *testing.T) {
54+
t.Parallel()
55+
56+
err := NewConfigValidator(&gatedConfig{
57+
WebAuthEnabled: true,
58+
WebPasswordHash: "$2a$12$abcdefghijklmnopqrstuv",
59+
WebSecretKey: "a-secret",
60+
}).Validate()
61+
if err != nil {
62+
t.Errorf("a complete web-auth config was rejected: %v", err)
63+
}
64+
}
65+
66+
// TestConditionalRequired_UnknownGateStaysRequired pins the fallback. If the
67+
// mapping and the config drift apart so the gate cannot be found, the field
68+
// stays required — surfacing beats silently dropping a check.
69+
func TestConditionalRequired_UnknownGateStaysRequired(t *testing.T) {
70+
t.Parallel()
71+
72+
// No web-auth-enabled field at all, so the gate is unresolvable.
73+
type noGate struct {
74+
WebSecretKey string `gcfg:"web-secret-key" mapstructure:"web-secret-key"`
75+
}
76+
77+
if err := NewConfigValidator(&noGate{}).Validate(); err == nil {
78+
t.Error("with no gate to consult the field should stay required, got no error")
79+
}
80+
}
81+
82+
// TestGateIsOpen_InvalidParentKeepsFieldRequired covers the guard for a caller
83+
// that has no enclosing struct to offer — the internal helpers are called that
84+
// way in tests. With nothing to consult, the field stays required, which is
85+
// the same safe direction as an unresolvable gate.
86+
func TestGateIsOpen_InvalidParentKeepsFieldRequired(t *testing.T) {
87+
t.Parallel()
88+
89+
cv := NewConfigValidator(nil)
90+
if !cv.gateIsOpen(reflect.Value{}, "web-secret-key") {
91+
t.Error("with no parent to inspect the field should stay required")
92+
}
93+
}
94+
95+
// TestGateIsOpen_IgnoresMismatchedFields covers the skip inside the search: a
96+
// field is only the gate if it is a bool AND carries the expected key. A
97+
// string field named like the gate, or a bool with a different key, must not
98+
// be mistaken for it.
99+
func TestGateIsOpen_IgnoresMismatchedFields(t *testing.T) {
100+
t.Parallel()
101+
102+
type decoys struct {
103+
// Right key, wrong type.
104+
WebAuthEnabled string `gcfg:"web-auth-enabled"`
105+
// Right type, wrong key.
106+
SomethingElse bool `gcfg:"some-other-flag"`
107+
}
108+
109+
cv := NewConfigValidator(nil)
110+
parent := reflect.ValueOf(decoys{WebAuthEnabled: "true", SomethingElse: true})
111+
112+
// Neither decoy qualifies, so the search finds no gate and the field stays
113+
// required rather than being switched on by the wrong field.
114+
if !cv.gateIsOpen(parent, "web-secret-key") {
115+
t.Error("a string field and an unrelated bool were treated as the gate")
116+
}
117+
}
118+
119+
// TestGateIsOpen_UnconditionalFieldsAlwaysRequired pins the common case: a
120+
// field with no entry in requiredWhen is not gated at all.
121+
func TestGateIsOpen_UnconditionalFieldsAlwaysRequired(t *testing.T) {
122+
t.Parallel()
123+
124+
cv := NewConfigValidator(nil)
125+
if !cv.gateIsOpen(reflect.ValueOf(gatedConfig{}), "web-address") {
126+
t.Error("an ungated field reported as not required")
127+
}
128+
}

config/validator_mutation_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ func TestValidateStringField_DefaultTagConditions(t *testing.T) {
454454
t.Parallel()
455455
v := NewValidator()
456456
field := reflect.ValueOf(tt.value)
457-
cv.validateStringField(v, field, tt.path, tt.defaultTag)
457+
cv.validateStringField(v, reflect.Value{}, field, tt.path, tt.defaultTag)
458458
if v.HasErrors() != tt.wantError {
459459
t.Errorf("validateStringField(%q, %q, %q) hasErrors=%v, want %v",
460460
tt.value, tt.path, tt.defaultTag, v.HasErrors(), tt.wantError)
@@ -826,7 +826,7 @@ func TestMut_Line280_DefaultTagEmptyString(t *testing.T) {
826826
cv := &Validator2{sanitizer: NewSanitizer()}
827827
v := NewValidator()
828828
field := reflect.ValueOf("")
829-
cv.validateStringField(v, field, "schedule", "some-default")
829+
cv.validateStringField(v, reflect.Value{}, field, "schedule", "some-default")
830830

831831
if v.HasErrors() {
832832
t.Error("empty value with default tag must skip validation (no error expected)")
@@ -838,7 +838,7 @@ func TestMut_Line280_DefaultTagEmptyString(t *testing.T) {
838838
cv := &Validator2{sanitizer: NewSanitizer()}
839839
v := NewValidator()
840840
field := reflect.ValueOf("GARBAGE_CRON!!!")
841-
cv.validateStringField(v, field, "schedule", "some-default")
841+
cv.validateStringField(v, reflect.Value{}, field, "schedule", "some-default")
842842

843843
if !v.HasErrors() {
844844
t.Error("non-empty value with default tag must still be validated (invalid cron → error)")
@@ -850,7 +850,7 @@ func TestMut_Line280_DefaultTagEmptyString(t *testing.T) {
850850
cv := &Validator2{sanitizer: NewSanitizer()}
851851
v := NewValidator()
852852
field := reflect.ValueOf("")
853-
cv.validateStringField(v, field, "schedule", "") // no default, required
853+
cv.validateStringField(v, reflect.Value{}, field, "schedule", "") // no default, required
854854

855855
if !v.HasErrors() {
856856
t.Error("empty value without default on required field must produce error")
@@ -862,7 +862,7 @@ func TestMut_Line280_DefaultTagEmptyString(t *testing.T) {
862862
cv := &Validator2{sanitizer: NewSanitizer()}
863863
v := NewValidator()
864864
field := reflect.ValueOf("")
865-
cv.validateStringField(v, field, "image", "") // no default, optional
865+
cv.validateStringField(v, reflect.Value{}, field, "image", "") // no default, optional
866866

867867
if v.HasErrors() {
868868
t.Error("empty value without default on optional field should not error")

e2e/cli_exit_codes_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,24 @@ func TestE2E_ExitCode_SuccessPaths(t *testing.T) {
6666
}
6767
}
6868

69-
// TestE2E_ExitCode_StrictValidationFails complements the INI-syntax case in
70-
// config_validation_test.go with a file that parses but does not satisfy
71-
// strict validation. Both have to fail, and for a deploy gate the distinction
72-
// does not matter — what matters is that neither is silently accepted.
69+
// TestE2E_ExitCode_SemanticFailureExits1 complements the INI-syntax case in
70+
// config_validation_test.go with a file that parses but is semantically wrong.
71+
// Both have to fail, and for a deploy gate the distinction does not matter —
72+
// what matters is that neither is silently accepted.
7373
//
74-
// Strict validation is opt-in (`enable-strict-validation`, default false), so
75-
// the config turns it on. Without it ofelia accepts semantically broken jobs
76-
// here — including an unparsable schedule, which the daemon then logs as a
77-
// warning while starting anyway, leaving a job that never fires. That is a
78-
// separate question from exit codes and is not pinned here.
79-
func TestE2E_ExitCode_StrictValidationFails(t *testing.T) {
74+
// The first version of this test used an unparsable schedule and only passed
75+
// because validation demanded web-UI credentials from a config that had no web
76+
// UI. That false positive is gone, and jobs are not reachable by the validator
77+
// at all, so the config here fails on a global field that genuinely is checked.
78+
// The schedule gap is tracked separately in #773.
79+
func TestE2E_ExitCode_SemanticFailureExits1(t *testing.T) {
8080
t.Parallel()
8181

8282
configPath := writeConfig(t, `[global]
83-
enable-strict-validation = true
83+
web-address = definitely-not-an-address
8484
85-
[job-local "broken"]
86-
schedule = not-a-schedule
85+
[job-local "hello"]
86+
schedule = @every 30s
8787
command = echo hi
8888
`)
8989

0 commit comments

Comments
 (0)