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"
|
:type="Type"
|
||||||
:unit="Unit"
|
:unit="Unit"
|
||||||
:required="Required"
|
:required="Required"
|
||||||
|
:pattern="Pattern"
|
||||||
:choice="Choice"
|
:choice="Choice"
|
||||||
:service-values="serviceValues"
|
:service-values="serviceValues"
|
||||||
:label="label"
|
:label="label"
|
||||||
|
|
@ -41,6 +42,7 @@ export default {
|
||||||
Type: String,
|
Type: String,
|
||||||
Unit: String,
|
Unit: String,
|
||||||
Mask: Boolean,
|
Mask: Boolean,
|
||||||
|
Pattern: { type: Object, default: () => ({}) },
|
||||||
Choice: Array,
|
Choice: Array,
|
||||||
serviceValues: Array,
|
serviceValues: Array,
|
||||||
modelValue: [String, Number, Boolean, Object],
|
modelValue: [String, Number, Boolean, Object],
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@
|
||||||
:step="step"
|
:step="step"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
:required="required"
|
||||||
|
:pattern="patternRegex"
|
||||||
|
:title="patternTitle"
|
||||||
:aria-describedby="unitValue ? id + '_unit' : null"
|
: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' : ''}`"
|
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''} ${endAlign ? 'text-end' : ''}`"
|
||||||
:style="
|
:style="
|
||||||
|
|
@ -126,6 +128,7 @@ export default {
|
||||||
scale: Number,
|
scale: Number,
|
||||||
required: Boolean,
|
required: Boolean,
|
||||||
invalid: Boolean,
|
invalid: Boolean,
|
||||||
|
pattern: { type: Object, default: () => ({}) },
|
||||||
choice: { type: Array, default: () => [] },
|
choice: { type: Array, default: () => [] },
|
||||||
modelValue: [String, Number, Boolean, Object],
|
modelValue: [String, Number, Boolean, Object],
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -136,6 +139,14 @@ export default {
|
||||||
return { selectMode: false };
|
return { selectMode: false };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
patternRegex() {
|
||||||
|
return this.pattern.Regex || null;
|
||||||
|
},
|
||||||
|
patternTitle() {
|
||||||
|
const examples = this.pattern.Examples || [];
|
||||||
|
if (!examples.length) return null;
|
||||||
|
return examples.join(", ");
|
||||||
|
},
|
||||||
datalistId() {
|
datalistId() {
|
||||||
return this.serviceValues.length > 0 ? `${this.id}-datalist` : null;
|
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
|
de: IP-Adresse oder Hostname
|
||||||
en: IP address or hostname
|
en: IP address or hostname
|
||||||
example: 192.0.2.2
|
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
|
- name: ip
|
||||||
description:
|
description:
|
||||||
de: IP-Adresse
|
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
|
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
|
res[out] = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,35 @@ func TestRequiredPerUsage(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -79,6 +80,33 @@ var predefinedTemplateProperties = slices.Concat(
|
||||||
[]string{"type", "template", "name"}, ModbusParams, ModbusConnectionTypes,
|
[]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
|
// TextLanguage contains language-specific texts
|
||||||
type TextLanguage struct {
|
type TextLanguage struct {
|
||||||
Generic string `json:",omitempty"` // language independent
|
Generic string `json:",omitempty"` // language independent
|
||||||
|
|
@ -191,6 +219,7 @@ type Param struct {
|
||||||
Type ParamType // string representation of the value type, "string" is default
|
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"
|
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
|
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
|
// TODO move somewhere else should not be part of the param definition
|
||||||
Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate
|
Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue