diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue index 6bdcf275d..508690aee 100644 --- a/assets/js/components/Config/PropertyEntry.vue +++ b/assets/js/components/Config/PropertyEntry.vue @@ -15,6 +15,7 @@ :type="Type" :unit="Unit" :required="Required" + :pattern="Pattern" :choice="Choice" :service-values="serviceValues" :label="label" @@ -41,6 +42,7 @@ export default { Type: String, Unit: String, Mask: Boolean, + Pattern: { type: Object, default: () => ({}) }, Choice: Array, serviceValues: Array, modelValue: [String, Number, Boolean, Object], diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 72d80714c..3184bfb22 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -73,6 +73,8 @@ :step="step" :placeholder="placeholder" :required="required" + :pattern="patternRegex" + :title="patternTitle" :aria-describedby="unitValue ? id + '_unit' : null" :class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''} ${endAlign ? 'text-end' : ''}`" :style=" @@ -126,6 +128,7 @@ export default { scale: Number, required: Boolean, invalid: Boolean, + pattern: { type: Object, default: () => ({}) }, choice: { type: Array, default: () => [] }, modelValue: [String, Number, Boolean, Object], label: String, @@ -136,6 +139,14 @@ export default { return { selectMode: false }; }, computed: { + patternRegex() { + return this.pattern.Regex || null; + }, + patternTitle() { + const examples = this.pattern.Examples || []; + if (!examples.length) return null; + return examples.join(", "); + }, datalistId() { return this.serviceValues.length > 0 ? `${this.id}-datalist` : null; }, diff --git a/tests/config-host-pattern.evcc.yaml b/tests/config-host-pattern.evcc.yaml new file mode 100644 index 000000000..6e7840f11 --- /dev/null +++ b/tests/config-host-pattern.evcc.yaml @@ -0,0 +1,11 @@ +site: + title: Host Pattern Test + meters: + grid: grid + +meters: + - name: grid + type: template + template: shelly-pro-3em + usage: grid + host: http://192.168.1.100 diff --git a/tests/config-host-pattern.spec.ts b/tests/config-host-pattern.spec.ts new file mode 100644 index 000000000..d5b2c36f8 --- /dev/null +++ b/tests/config-host-pattern.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible } from "./utils"; + +const CONFIG_HOST_PATTERN = "config-host-pattern.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.describe("host pattern validation", async () => { + test.afterEach(async () => { + await stop(); + }); + + test("show error for meter with invalid host pattern on startup", async ({ page }) => { + await start(CONFIG_HOST_PATTERN); + await page.goto("/#/config"); + + // verify fatal error is shown + await expect(page.getByTestId("fatal-error")).toBeVisible(); + await expect(page.getByTestId("fatal-error")).toContainText("does not match required pattern"); + }); + + test("reject URL with scheme in host field", async ({ page }) => { + await start(); + await page.goto("/#/config"); + + const modal = page.getByTestId("meter-modal"); + await page.getByRole("button", { name: "Add solar or battery" }).click(); + await expectModalVisible(modal); + await modal.getByRole("button", { name: "Add solar meter" }).click(); + + await modal.getByLabel("Title").fill("Test PV"); + await page.waitForLoadState("networkidle"); + await modal.getByLabel("Manufacturer").selectOption("Shelly Pro 3 EM"); + + const hostInput = modal.getByLabel("IP address or hostname"); + await hostInput.fill("http://192.168.1.100"); + + // Check browser invalid state + const isValid = await hostInput.evaluate((el: HTMLInputElement) => el.checkValidity()); + expect(isValid).toBe(false); + + // Check validate status is still unknown (hasn't tried to validate yet) + const testResult = modal.getByTestId("test-result"); + await expect(testResult).toContainText("Status: unknown"); + + // Manually delete the pattern attribute to bypass client validation + await hostInput.evaluate((el: HTMLInputElement) => el.removeAttribute("pattern")); + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("Status: failed"); + await expect(testResult).toContainText("does not match required pattern"); + }); +}); diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 23a04ef6f..316c8d15a 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -20,6 +20,9 @@ params: de: IP-Adresse oder Hostname en: IP address or hostname example: 192.0.2.2 + pattern: + regex: "^[^\\/\\s]+(:[0-9]{1,5})?$" # any char except slash/space, optional :port + examples: ["192.168.1.100", "example.com", "server.local:8080"] - name: ip description: de: IP-Adresse diff --git a/util/templates/template.go b/util/templates/template.go index ae2508734..6c92cc2af 100644 --- a/util/templates/template.go +++ b/util/templates/template.go @@ -119,6 +119,15 @@ func (t *Template) Validate() error { } } } + + // validate pattern examples against pattern + if p.Pattern != nil && p.Pattern.Regex != "" && len(p.Pattern.Examples) > 0 { + for _, example := range p.Pattern.Examples { + if err := p.Pattern.Validate(example); err != nil { + return fmt.Errorf("param %s: pattern example %q is invalid: pattern=%q", p.Name, example, p.Pattern.Regex) + } + } + } } return nil @@ -371,6 +380,13 @@ func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, m } } + // validate pattern if defined + if s != "" && p.Pattern != nil && p.Pattern.Regex != "" { + if err := p.Pattern.Validate(s); err != nil { + return nil, nil, fmt.Errorf("%s: %w", p.Name, err) + } + } + res[out] = s } } diff --git a/util/templates/template_test.go b/util/templates/template_test.go index 25f73d2fa..5b2cd75c9 100644 --- a/util/templates/template_test.go +++ b/util/templates/template_test.go @@ -133,3 +133,35 @@ func TestRequiredPerUsage(t *testing.T) { }) require.NoError(t, err) } + +func TestValidatePattern(t *testing.T) { + tmpl := &Template{ + TemplateDefinition: TemplateDefinition{ + Params: []Param{{Name: "host", Pattern: &Pattern{Regex: `^[^\\/\s]+(:[0-9]{1,5})?$`}}}, + }, + } + + tests := []struct { + host string + valid bool + }{ + {"192.168.1.100", true}, + {"192.168.1.100:8080", true}, + {"example.com", true}, + {"http://192.168.1.100", false}, + {"192.168.1.100/admin", false}, + {"192.168.1.100 ", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + _, _, err := tmpl.RenderResult(RenderModeInstance, map[string]any{"host": tt.host}) + if tt.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match required pattern") + } + }) + } +} diff --git a/util/templates/types.go b/util/templates/types.go index b15269865..ebc259262 100644 --- a/util/templates/types.go +++ b/util/templates/types.go @@ -3,6 +3,7 @@ package templates import ( "bufio" "encoding/json" + "errors" "fmt" "regexp" "slices" @@ -79,6 +80,33 @@ var predefinedTemplateProperties = slices.Concat( []string{"type", "template", "name"}, ModbusParams, ModbusConnectionTypes, ) +// Pattern contains regex pattern and examples for input validation +type Pattern struct { + Regex string `json:",omitempty"` + Examples []string `json:",omitempty"` +} + +// Validate checks if a value matches the pattern and returns a descriptive error if not +func (p *Pattern) Validate(value string) error { + if p.Regex == "" { + return nil + } + + matched, err := regexp.MatchString(p.Regex, value) + if err != nil { + return fmt.Errorf("invalid regex pattern: %w", err) + } + if matched { + return nil + } + + errMsg := fmt.Sprintf("value %q does not match required pattern", value) + if len(p.Examples) > 0 { + errMsg += fmt.Sprintf(". Valid examples: %s", strings.Join(p.Examples, ", ")) + } + return errors.New(errMsg) +} + // TextLanguage contains language-specific texts type TextLanguage struct { Generic string `json:",omitempty"` // language independent @@ -191,6 +219,7 @@ type Param struct { Type ParamType // string representation of the value type, "string" is default Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage" Service string `json:",omitempty"` // defines a service to provide choices + Pattern *Pattern `json:",omitempty"` // regex pattern and examples for input validation // TODO move somewhere else should not be part of the param definition Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate