Merge commit from fork

* Config: require admin password for script plugins

* refactor
This commit is contained in:
Michael Geers 2026-06-24 15:45:15 +02:00 • committed by GitHub
parent a193236395
commit 4c907c5afa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 594 additions and 207 deletions

View file

@ -0,0 +1,27 @@
<template>
<div class="alert alert-warning mt-4 pb-0" role="alert" data-testid="admin-password-prompt">
<p class="fw-bold mb-2">{{ $t("config.adminPassword.title") }}</p>
<p>{{ $t("config.adminPassword.description") }}</p>
<PasswordInput
class="text-body"
:password="password"
:error="invalid ? $t('config.adminPassword.invalid') : ''"
@update:password="$emit('update:password', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import PasswordInput from "./PasswordInput.vue";
export default defineComponent({
name: "AdminPasswordPrompt",
components: { PasswordInput },
props: {
password: { type: String, default: "" },
invalid: Boolean,
},
emits: ["update:password"],
});
</script>

View file

@ -1,5 +1,7 @@
<template>
<div>
<slot name="before-test"></slot>
<TestResult
v-if="testState"
v-bind="testState"

View file

@ -179,6 +179,13 @@
@remove="handleRemove"
@test="testManually"
>
<template #before-test>
<AdminPasswordPrompt
v-if="adminPasswordRequired"
v-model:password="adminPasswordValue"
:invalid="adminPasswordInvalid"
/>
</template>
<template #after-test>
<slot name="after-test" :values="values"></slot>
</template>
@ -207,6 +214,7 @@ import AuthConnectButton from "../AuthConnectButton.vue";
import { initialTestState, performTest } from "../utils/test";
import { reportValidityInModal } from "../utils/reportValidityInModal";
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
import AdminPasswordPrompt from "@/components/Auth/AdminPasswordPrompt.vue";
import sleep from "@/utils/sleep";
import { ConfigType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc";
@ -223,6 +231,7 @@ import {
applyDefaultsFromTemplate,
createDeviceUtils,
fetchServiceValues,
ADMIN_PASSWORD_REQUIRED,
} from "./index";
import deepEqual from "@/utils/deepEqual";
@ -244,6 +253,7 @@ export default defineComponent({
YamlEntry,
AuthCodeDisplay,
AuthConnectButton,
AdminPasswordPrompt,
},
props: {
deviceType: { type: String as PropType<DeviceType>, required: true },
@ -313,6 +323,9 @@ export default defineComponent({
test: initialTestState(),
serviceValues: {} as Record<string, string[]>,
serviceValuesTimer: null as Timeout | null,
adminPasswordValue: "",
adminPasswordRequired: false,
adminPasswordInvalid: false,
};
},
computed: {
@ -546,10 +559,15 @@ export default defineComponent({
if (this.test.isError || this.test.isSuccess) {
this.test = initialTestState();
}
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
this.updateServiceValues();
},
deep: true,
},
adminPasswordValue() {
this.adminPasswordInvalid = false;
},
authValues: {
handler() {
if (this.authRequired) {
@ -699,22 +717,35 @@ export default defineComponent({
this.saving = true;
try {
const { name } = await this.device.create(this.apiData, force);
const res = await this.device.create(this.apiData, force, this.adminPasswordValue);
this.applyAdminPasswordState(res.status);
if (res.status === ADMIN_PASSWORD_REQUIRED) {
this.saving = false;
return;
}
this.saving = false;
this.succeeded = true;
await sleep(500);
this.$emit("added", name);
await closeModal({ action: "added", name });
this.$emit("added", res.data.name);
await closeModal({ action: "added", name: res.data.name });
} catch (e) {
handleError(e, "create failed");
this.saving = false;
handleError(e, "create failed");
}
},
async testManually() {
await performTest(this.test, this.testDevice, this.$refs["form"] as HTMLFormElement);
},
async testDevice() {
return this.device.test(this.id, this.apiData);
const res = await this.device.test(this.id, this.apiData, this.adminPasswordValue);
this.applyAdminPasswordState(res.status);
return res;
},
// reveal the admin password field when required, flag it invalid if a password was already sent
applyAdminPasswordState(status: number) {
this.adminPasswordRequired = status === ADMIN_PASSWORD_REQUIRED;
this.adminPasswordInvalid =
status === ADMIN_PASSWORD_REQUIRED && !!this.adminPasswordValue;
},
async update(force = false) {
if (this.test.isUnknown && !force) {
@ -729,16 +760,26 @@ export default defineComponent({
}
this.saving = true;
try {
await this.device.update(this.id!, this.apiData, force);
const res = await this.device.update(
this.id!,
this.apiData,
force,
this.adminPasswordValue
);
this.applyAdminPasswordState(res.status);
if (res.status === ADMIN_PASSWORD_REQUIRED) {
this.saving = false;
return;
}
this.saving = false;
this.succeeded = true;
await sleep(500);
this.$emit("updated");
await closeModal({ action: "updated" });
} catch (e) {
this.saving = false;
console.error("update failed", e);
handleError(e, "update failed");
this.saving = false;
}
},
async remove() {
@ -752,6 +793,8 @@ export default defineComponent({
},
handleOpen() {
this.isModalVisible = true;
this.adminPasswordRequired = false;
this.adminPasswordInvalid = false;
},
handleClose() {
this.$emit("close");

View file

@ -3,6 +3,16 @@ import { ConfigType } from "@/types/evcc";
import api from "@/api";
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
// config write needs the admin password (script plugin)
export const ADMIN_PASSWORD_REQUIRED = 428;
const allowAdminPasswordRequired = (status: number) =>
(status >= 200 && status < 300) || status === ADMIN_PASSWORD_REQUIRED;
function adminPasswordHeader(adminPassword = ""): Record<string, string> {
return adminPassword ? { "X-Admin-Password": adminPassword } : {};
}
export type Product = {
group: string;
name: string;
@ -189,17 +199,25 @@ export const fetchServiceValues = async (
};
export function createDeviceUtils(deviceType: DeviceType) {
function test(id: number | undefined, data: any) {
function test(id: number | undefined, data: any, adminPassword = "") {
let url = `config/test/${deviceType}`;
if (id !== undefined) {
url += `/merge/${id}`;
}
return api.post(url, data);
const opts = {
headers: adminPasswordHeader(adminPassword),
validateStatus: allowAdminPasswordRequired,
};
return api.post(url, data, opts);
}
function update(id: number, data: any, force = false) {
const params = { force };
return api.put(`config/devices/${deviceType}/${id}`, data, { params });
function update(id: number, data: any, force = false, adminPassword = "") {
const opts = {
headers: adminPasswordHeader(adminPassword),
validateStatus: allowAdminPasswordRequired,
params: { force },
};
return api.put(`config/devices/${deviceType}/${id}`, data, opts);
}
function remove(id: number) {
@ -211,10 +229,13 @@ export function createDeviceUtils(deviceType: DeviceType) {
return response.data;
}
async function create(data: any, force = false) {
const params = { force };
const response = await api.post(`config/devices/${deviceType}`, data, { params });
return response.data;
function create(data: any, force = false, adminPassword = "") {
const opts = {
headers: adminPasswordHeader(adminPassword),
validateStatus: allowAdminPasswordRequired,
params: { force },
};
return api.post(`config/devices/${deviceType}`, data, opts);
}
async function loadProducts(lang?: string, usage?: string) {

View file

@ -61,7 +61,6 @@ export default {
<style scoped>
.editor {
border: 1px solid var(--bs-border-color);
min-height: 150px;
}
.editor-loading {
padding: 0.5rem 0.75rem;

View file

@ -47,17 +47,7 @@ export default {
</script>
<style scoped>
@import "../../../css/breakpoints.css";
.editor-container {
width: 100%;
overflow: hidden;
margin: 0 -1rem 0 -1.25rem;
}
/* reset margins on lg */
@media (--lg-and-up) {
.editor-container {
margin: 0;
}
}
</style>

View file

@ -1,5 +1,6 @@
import type { AxiosResponse } from "axios";
import sleep from "@/utils/sleep";
import { ADMIN_PASSWORD_REQUIRED } from "../DeviceModal/index";
import { reportValidityInModal } from "./reportValidityInModal";
export type TestState = {
@ -36,6 +37,10 @@ export const performTest = async (
const startTime = Date.now();
try {
const res = await api();
if (res.status === ADMIN_PASSWORD_REQUIRED) {
state.isUnknown = true; // not testable until the admin password is provided
return false;
}
state.isError = false;
state.error = null;
state.errorLine = null;

View file

@ -88,11 +88,21 @@ achieves the same effect (the previous key stops working immediately).
| State / read-only / basic charging control | Public | |
| Set or update admin password | Public | admin password |
| Configuration | Secure | |
| Configuration embedding a script plugin | Critical | api key or admin password |
| System: logs, cache, shutdown | Secure | |
| API key status | Secure | |
| System: backup / restore / reset | Critical | api key or admin password |
| API key regenerate | Critical | admin password |
Device test, create, and update (`/api/config/test/{class}` and `/api/config/devices/{class}`)
instantiate a config immediately, so a `script` plugin in the payload runs a shell command on the
server. Because that command could read credentials a session is not otherwise allowed to see (for
example the contents of the database), these requests are treated as Critical when the config embeds
a script plugin, at any nesting depth. A session caller must supply the admin password in the
`X-Admin-Password` header; an API-key caller passes without it. The `go` (yaegi) and `js` (otto)
plugins are excluded: their interpreters are sandboxed to pure computation and cannot read files,
spawn processes, or open network connections.
**Public** endpoints accept any caller. **Secure** endpoints require a
valid session (cookie or API key). **Critical** endpoints require extra
authentication in the form of an admin password (or, for some, an API

View file

@ -51,6 +51,11 @@
"hex": "Hex-Farbe"
},
"config": {
"adminPassword": {
"description": "Das Skript-Plugin führt einen Befehl auf Systemebene aus, der mit den Rechten des evcc-Prozesses läuft. Gib dein Administrator-Passwort erneut ein, um fortzufahren.",
"invalid": "Ungültiges Passwort. Bitte versuche es erneut.",
"title": "Potenziell gefährliche Aktion"
},
"apiKey": {
"description": "Gibt Skripten und Automatisierungsaufgaben wie geplanten Backups sicheren Zugriff, ohne dein Administrator-Passwort zu teilen.",
"exampleLabel": "Ausprobieren: Backup mit curl herunterladen",

View file

@ -51,6 +51,11 @@
"hex": "Hex color"
},
"config": {
"adminPassword": {
"description": "The script plugin executes a system-level command that runs with the permissions of the evcc process. Re-enter your admin password to continue.",
"invalid": "Invalid password. Please try again.",
"title": "Potentially dangerous operation"
},
"apiKey": {
"description": "Give scripts and automation tasks like scheduled backups secure access without sharing your admin password.",
"exampleLabel": "Try it: download a backup with curl",

View file

@ -298,11 +298,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)},
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)},
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},

View file

@ -2,6 +2,7 @@ package server
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
@ -241,6 +242,21 @@ func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc {
}
}
// requireCriticalConfigAuth guards script-plugin configs: API key passes; session users must supply the admin password.
func requireCriticalConfigAuth(w http.ResponseWriter, r *http.Request, authObject auth.Auth, req configReq) bool {
if authObject.GetAuthMode() == auth.Disabled || !configHasCriticalPlugin(req) {
return true
}
if key := apiKeyFromRequest(r); key != "" && authObject.ValidateApiKey(key) {
return true
}
if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) {
jsonError(w, http.StatusPreconditionRequired, errors.New("admin password required"))
return false
}
return true
}
// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
// session users must also supply the admin password in X-Admin-Password header.
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {

74
server/http_auth_test.go Normal file
View file

@ -0,0 +1,74 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/evcc-io/evcc/util/auth"
"github.com/stretchr/testify/assert"
)
// fakeAuth is a minimal auth.Auth stub for gate tests.
type fakeAuth struct {
mode auth.AuthMode
password string
apiKey string
}
func (f fakeAuth) GetAuthMode() auth.AuthMode { return f.mode }
func (f fakeAuth) IsAdminPasswordValid(pw string) bool { return pw != "" && pw == f.password }
func (f fakeAuth) ValidateApiKey(key string) bool { return key != "" && key == f.apiKey }
func (f fakeAuth) SetAuthMode(auth.AuthMode) {}
func (f fakeAuth) RemoveAdminPassword() {}
func (f fakeAuth) SetAdminPassword(string) error { return nil }
func (f fakeAuth) GenerateJwtToken(time.Duration) (string, error) { return "", nil }
func (f fakeAuth) ValidateJwtToken(string) bool { return true }
func (f fakeAuth) IsAdminPasswordConfigured() bool { return f.password != "" }
func (f fakeAuth) SetApiKey() (string, error) { return "", nil }
func (f fakeAuth) IsApiKeyConfigured() bool { return f.apiKey != "" }
func TestRequireCriticalConfig(t *testing.T) {
const pw = "secret"
const key = "evcc_token"
scriptReq := configReq{Yaml: "power:\n source: script\n cmd: echo 1"}
benignReq := configReq{Yaml: "power:\n source: http\n uri: http://localhost"}
base := fakeAuth{mode: auth.Enabled, password: pw, apiKey: key}
tc := []struct {
name string
req configReq
header map[string]string
mode auth.AuthMode
ok bool
}{
{"no critical plugin passes", benignReq, nil, auth.Enabled, true},
{"disabled mode passes", scriptReq, nil, auth.Disabled, true},
{"session without password rejected", scriptReq, nil, auth.Enabled, false},
{"session with wrong password rejected", scriptReq, map[string]string{"X-Admin-Password": "nope"}, auth.Enabled, false},
{"session with valid password passes", scriptReq, map[string]string{"X-Admin-Password": pw}, auth.Enabled, true},
{"valid api key passes without password", scriptReq, map[string]string{"Authorization": "Bearer " + key}, auth.Enabled, true},
}
for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
a := base
a.mode = tc.mode
r := httptest.NewRequest(http.MethodPost, "/api/config/test/meter", nil)
for k, v := range tc.header {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
ok := requireCriticalConfigAuth(w, r, a, tc.req)
assert.Equal(t, tc.ok, ok)
if !tc.ok {
assert.Equal(t, http.StatusPreconditionRequired, w.Code)
}
})
}
}

View file

@ -21,6 +21,7 @@ import (
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle"
@ -308,7 +309,8 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq,
}
// newDeviceHandler creates a new device by class
func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
@ -323,6 +325,10 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !requireCriticalConfigAuth(w, r, authObject, req) {
return
}
var conf *config.Config
ctx, cancel, done := startDeviceTimeout()
@ -369,6 +375,7 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, res)
}
}
func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) error {
dev, instance, merged, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h)
@ -388,7 +395,8 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req
}
// updateDeviceHandler updates database device's configuration by class
func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
@ -409,6 +417,10 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !requireCriticalConfigAuth(w, r, authObject, req) {
return
}
ctx, cancel, done := startDeviceTimeout()
force := r.URL.Query().Get("force") == "true"
@ -452,6 +464,7 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, res)
}
}
func configurableDevice[T any](name string, h config.Handler[T]) (config.ConfigurableDevice[T], error) {
dev, err := h.ByName(name)
@ -644,7 +657,8 @@ func testConfig[T any](ctx context.Context, id int, class templates.Class, req c
}
// testConfigHandler tests a configuration by class
func testConfigHandler(w http.ResponseWriter, r *http.Request) {
func testConfigHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
@ -669,6 +683,10 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !requireCriticalConfigAuth(w, r, authObject, req) {
return
}
var instance any
ctx, cancel, done := startDeviceTimeout()
@ -708,3 +726,4 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, testInstance(probeCtx, instance))
}
}

View file

@ -528,6 +528,38 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val
}
}
var criticalPluginSources = []string{"script"}
func configHasCriticalPlugin(req configReq) bool {
if req.Yaml != "" {
var m map[string]any
if err := yaml.Unmarshal([]byte(req.Yaml), &m); err != nil {
return false // malformed yaml already rejected by decodeDeviceConfig
}
return valueHasCriticalSource(m)
}
return valueHasCriticalSource(req.Other)
}
func valueHasCriticalSource(v any) bool {
switch t := v.(type) {
case map[string]any:
for k, val := range t {
if strings.EqualFold(k, "source") {
if s, ok := val.(string); ok && slices.Contains(criticalPluginSources, strings.ToLower(strings.TrimSpace(s))) {
return true
}
}
if valueHasCriticalSource(val) {
return true
}
}
case []any:
return slices.ContainsFunc(t, valueHasCriticalSource)
}
return false
}
// decodeDeviceConfig extracts device configuration and yaml details
func decodeDeviceConfig(r io.Reader) (configReq, error) {
var res configReq

View file

@ -210,6 +210,38 @@ func TestMergeMaskedFiltersBehavior(t *testing.T) {
assert.NotContains(t, result, "outdatedField")
}
func TestConfigHasCriticalPlugin(t *testing.T) {
tc := []struct {
name string
yaml string
want bool
}{
{"script top level", "power:\n source: script\n cmd: echo 1", true},
{"script case insensitive", "power:\n Source: SCRIPT\n cmd: echo 1", true},
{"script nested in calc", "power:\n source: calc\n add:\n - source: const\n value: 1\n - source: script\n cmd: echo 1", true},
{"script nested in sequence set", "power:\n source: sequence\n set:\n - source: script\n cmd: echo 1", true},
{"script nested in js transformation", "power:\n source: js\n script: x\n in:\n - name: x\n type: float\n source: script\n cmd: echo 1", true},
{"js without script", "power:\n source: js\n script: \"x = 1\"", false},
{"go without script", "power:\n source: go\n script: \"return 1\"", false},
{"http without script", "power:\n source: http\n uri: http://localhost", false},
{"plain template", "power: 100", false},
}
for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, configHasCriticalPlugin(configReq{Yaml: tc.yaml}))
})
}
// non-yaml custom config carried in Other
assert.True(t, configHasCriticalPlugin(configReq{
Other: map[string]any{"power": map[string]any{"source": "script", "cmd": "echo 1"}},
}))
assert.False(t, configHasCriticalPlugin(configReq{
Other: map[string]any{"template": "tesla"},
}))
}
func TestFilterValidTemplateParams(t *testing.T) {
conf := map[string]any{
"template": "generic",

View file

@ -0,0 +1,107 @@
import { test, expect, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
test.use({ baseURL: baseUrl() });
test.afterEach(async () => {
await stop();
});
const SCRIPT_YAML = `power:
source: script
cmd: echo 9999`;
const UPDATED_YAML = `power:
source: script
cmd: echo 8888`;
async function login(page: Page) {
const loginModal = page.getByTestId("login-modal");
await expectModalVisible(loginModal);
await loginModal.getByLabel("Administrator Password").fill("secret");
await loginModal.getByRole("button", { name: "Login" }).click();
await expectModalHidden(loginModal);
}
async function addCustomGridMeter(page: Page, yaml: string) {
await page.getByRole("button", { name: "Add grid meter" }).click();
const modal = page.getByTestId("meter-modal");
await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toBeVisible();
await editorClear(editor);
await editorPaste(editor, page, yaml);
return modal;
}
test.describe("script plugin requires admin password", async () => {
test("caches password across validate and create", async ({ page }) => {
await start(undefined, "password.sql", "");
await page.goto("/#/config");
await login(page);
const meterModal = await addCustomGridMeter(page, SCRIPT_YAML);
const prompt = meterModal.getByTestId("admin-password-prompt");
const validate = meterModal.getByTestId("test-result").getByRole("link", { name: "validate" });
// validate without password reveals the field
await validate.click();
await expect(prompt).toBeVisible();
// editing the config hides the field again
await editorClear(meterModal.getByTestId("yaml-editor"));
await editorPaste(meterModal.getByTestId("yaml-editor"), page, SCRIPT_YAML);
await expect(prompt).not.toBeVisible();
// wrong password keeps the field with an invalid hint
await validate.click();
await expect(prompt).toBeVisible();
await prompt.getByLabel("Administrator Password").fill("wrong");
await validate.click();
await expect(prompt.getByText("Invalid password. Please try again.")).toBeVisible();
// correct password validates and hides the field
await prompt.getByLabel("Administrator Password").fill("secret");
await validate.click();
await expect(meterModal.getByTestId("test-result")).toContainText("Status: successful");
await expect(prompt).not.toBeVisible();
// save reuses the cached password, no field reappears
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(prompt).not.toBeVisible();
await expectModalHidden(meterModal);
await expect(page.getByTestId("grid")).toBeVisible();
});
test("re-prompts on update after reload", async ({ page }) => {
await start(undefined, "password.sql", "");
await page.goto("/#/config");
await login(page);
// create a script meter (enter the password once)
const meterModal = await addCustomGridMeter(page, SCRIPT_YAML);
const prompt = meterModal.getByTestId("admin-password-prompt");
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(prompt).toBeVisible();
await prompt.getByLabel("Administrator Password").fill("secret");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("grid")).toBeVisible();
// reload drops the cached password
await page.reload();
// editing and saving the existing device prompts again
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await editorClear(meterModal.getByTestId("yaml-editor"));
await editorPaste(meterModal.getByTestId("yaml-editor"), page, UPDATED_YAML);
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(prompt).toBeVisible();
await prompt.getByLabel("Administrator Password").fill("secret");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
});
});