Skip to content
Open
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
2 changes: 2 additions & 0 deletions assets/js/components/Config/PropertyEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
:type="Type"
:unit="Unit"
:required="Required"
:pattern="Pattern"
:choice="Choice"
:service-values="serviceValues"
:label="label"
Expand All @@ -42,6 +43,7 @@ export default {
Type: String,
Unit: String,
Mask: Boolean,
Pattern: { type: Object, default: () => ({}) },
Choice: Array,
serviceValues: Array,
modelValue: [String, Number, Boolean, Object],
Expand Down
13 changes: 13 additions & 0 deletions assets/js/components/Config/PropertyField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
:step="step"
:placeholder="placeholder"
:required="required"
:pattern="patternRegex"
:title="patternTitle"
:aria-describedby="id + '_unit'"
class="form-control"
:class="{ 'text-end': endAlign }"
Expand Down Expand Up @@ -87,6 +89,8 @@
:step="step"
:placeholder="placeholder"
:required="required"
:pattern="patternRegex"
:title="patternTitle"
:autocomplete="masked || datalistId ? 'off' : null"
/>
<button
Expand Down Expand Up @@ -129,6 +133,7 @@ export default {
scale: Number,
required: Boolean,
invalid: Boolean,
pattern: { type: Object, default: () => ({}) },
choice: { type: Array, default: () => [] },
modelValue: [String, Number, Boolean, Object],
label: String,
Expand All @@ -139,6 +144,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;
},
Expand Down
44 changes: 44 additions & 0 deletions tests/config-host-pattern.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible } from "./utils";

test.use({ baseURL: baseUrl() });

test.describe("host pattern validation", async () => {
test.beforeEach(async () => {
await start();
});
test.afterEach(async () => {
await stop();
});

test("reject URL with scheme in host field", async ({ page }) => {
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("APsystems EZ1");

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");
});
});
3 changes: 3 additions & 0 deletions util/templates/defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions util/templates/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package templates
import (
"bytes"
_ "embed"
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -38,6 +40,27 @@ func (t *Template) UpdateParamsWithDefaults() error {
return nil
}

// validatePattern checks if a value matches a pattern and returns a descriptive error if not
func validatePattern(regex, value string, examples []string) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Die Methode gehört an den Param- spart die Parameter

if regex == "" {
return nil
}

matched, err := regexp.MatchString(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(examples) > 0 {
errMsg += fmt.Sprintf(". Valid examples: %s", strings.Join(examples, ", "))
}
return errors.New(errMsg)
}

// validate the template (only rudimentary for now)
func (t *Template) Validate() error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wird Validate nur beim Speichern oder auch beim Config einlesen aufgerufen?

for _, c := range t.Capabilities {
Expand Down Expand Up @@ -92,6 +115,15 @@ func (t *Template) Validate() error {
}
}
}

// validate pattern examples against pattern
if p.Pattern.Regex != "" && len(p.Pattern.Examples) > 0 {
for _, example := range p.Pattern.Examples {
if err := validatePattern(p.Pattern.Regex, example, nil); err != nil {
return fmt.Errorf("param %s: pattern example %q is invalid: pattern=%q", p.Name, example, p.Pattern.Regex)
}
}
}
}

return nil
Expand Down Expand Up @@ -362,6 +394,13 @@ func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, m
return nil, nil, fmt.Errorf("missing required `%s`", p.Name)
}

// validate pattern if defined
if s != "" && p.Pattern.Regex != "" {
if err := validatePattern(p.Pattern.Regex, s, p.Pattern.Examples); err != nil {
return nil, nil, fmt.Errorf("%s: %w", p.Name, err)
}
}

res[out] = s
}
}
Expand Down
33 changes: 33 additions & 0 deletions util/templates/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package templates
import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -65,3 +66,35 @@ func TestRequired(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")
}
})
}
}
7 changes: 7 additions & 0 deletions util/templates/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ var predefinedTemplateProperties = append(
append(ModbusParams, ModbusConnectionTypes...)...,
)

// Pattern contains regex pattern and examples for input validation
type Pattern struct {
Regex string `json:",omitempty"`
Examples []string `json:",omitempty"`
}

// TextLanguage contains language-specific texts
type TextLanguage struct {
Generic string `json:",omitempty"` // language independent
Expand Down Expand Up @@ -201,6 +207,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
AllInOne bool `json:"-"` // defines if the defined usages can all be present in a single device

// TODO move somewhere else should not be part of the param definition
Expand Down
Loading