Config UI: add param value validation: host (BC) (#26149)
This commit is contained in:
parent
d3d902ebd8
commit
c0e9e4c913
8 changed files with 157 additions and 0 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
11
tests/config-host-pattern.evcc.yaml
Normal file
11
tests/config-host-pattern.evcc.yaml
Normal file
|
|
@ -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
|
||||
53
tests/config-host-pattern.spec.ts
Normal file
53
tests/config-host-pattern.spec.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue