feat(ui): replace configuration editors with graphical forms
CI / javascript-check (push) Successful in 14s
CI / container-policy (push) Successful in 4s
CI / python-tests (push) Successful in 1m48s
CI / container-verify (push) Skipped
CI / container-publish (push) Successful in 49s

This commit is contained in:
BartelLuis
2026-09-14 21:52:39 +02:00
parent 9a6dea34c4
commit 7b979e6243
22 changed files with 1603 additions and 81 deletions
+302
View File
@@ -0,0 +1,302 @@
"""Optional real-browser regression checks, outside default pytest collection.
From the repository root after installing the normal development dependencies:
uv pip install --python .venv/Scripts/python.exe -e ".[dev,browser]"
.venv/Scripts/python.exe -m playwright install chromium
.venv/Scripts/python.exe tests/browser_forms.py
On Linux/macOS use .venv/bin/python in place of .venv/Scripts/python.exe.
The suite starts isolated loopback servers; no deployment or real data is used.
Screenshots, fixture databases, and logs are in .cache/ui-browser/ (gitignored).
"""
from copy import deepcopy
import json
from browser_support import local_browser
PUBLICATION = {"test_evidence": "Synthetic browser acceptance fixture", "reason": "Browser fixture publication"}
def graphical_workflows():
results = []
with local_browser() as (page, api, ctx):
page.set_default_timeout(8000)
modal = page.locator("#modal")
def route(name):
page.goto(ctx["base"] + "/#/" + name)
page.wait_for_selector("#main .page-header")
def fill(name, value):
modal.locator(f'[name="{name}"]').fill(str(value))
def select(name, value):
modal.locator(f'[name="{name}"]').select_option(value)
def builds(name):
editor = modal.locator(f'[data-editor="{name}"]')
editor.get_by_role("button", name="Eintrag hinzufügen", exact=True).click()
editor.locator("textarea[data-scalar-value]").last.fill("9.1-1")
def submit(label="Entwurf speichern"):
modal.get_by_role("button", name=label, exact=True).click()
try:
modal.wait_for(state="hidden", timeout=12000)
except Exception:
print("FORM_FAILURE", modal.inner_text(), flush=True)
raise
def no_code():
modal.wait_for(state="visible")
assert modal.locator("textarea.code:not([readonly])").count() == 0
assert "(JSON)" not in modal.inner_text()
# New root password: no hash, command or source needed.
route("settings")
page.get_by_role("tab", name="Geheimnisse", exact=True).click()
page.locator('[data-action="create-secret"]').first.click()
fill("name", "Browser root password")
fill("value", "Browser-root-password-2026!")
assert modal.locator('[name="kind"]').input_value() == "root_password"
submit("Verschlüsselt speichern")
secret = next(s for s in api("/secrets") if s["name"] == "Browser root password")
results.append("Root password entered and saved through graphical UI")
# Module template selection and schema preservation.
route("modules")
page.locator('[data-action="create-module"]').first.click()
select("module_template", "prerequisites")
builds("module-builds")
no_code()
submit()
prerequisites = next(m for m in api("/modules") if m["name"] == "Voraussetzungen")
catalog = {m["id"]: m for m in api("/modules/builtin")}
assert prerequisites["source"] == catalog["prerequisites"]["source"].strip()
assert prerequisites["parameters_schema"] == catalog["prerequisites"]["parameters_schema"], (prerequisites["parameters_schema"],catalog["prerequisites"]["parameters_schema"])
api(f'/modules/{prerequisites["id"]}/publish', PUBLICATION)
template = catalog["final-verification"]
final_module = api("/modules", {key: deepcopy(template[key]) for key in ["name", "source", "parameters_schema", "dependencies", "timeout_seconds", "retry_safe"]} | {"target_builds": ["9.1-1"]})
api(f'/modules/{final_module["id"]}/publish', PUBLICATION)
results.append("Template module created with unchanged source/schema")
# Single-disk installation profile, entirely filled through normal controls.
route("installation")
page.locator('[data-action="create-profile"]').first.click()
fill("name", "Browser graphical installation")
builds("profile-builds")
fill("installation.global.mailto", "admin@example.net")
select("installation.root_secret_id", secret["id"])
fill("installation.network.gateway", "192.0.2.1")
fill("installation.network.dns", "192.0.2.53")
select("installation.interface-key", "INTERFACE")
fill("installation.interface-value", "eno1")
assert modal.locator('[name="installation.disk.mode"]').input_value() == "all"
assert modal.locator('[name="installation.disk.zfs.raid"]').input_value() == "raid0"
no_code()
page.screenshot(path=str(ctx["artifacts"] / "installation-desktop.png"), full_page=True)
page.set_viewport_size({"width":390,"height":844})
page.screenshot(path=str(ctx["artifacts"] / "installation-mobile.png"), full_page=True)
dimensions = modal.evaluate("node=>({width:node.clientWidth,scroll:node.scrollWidth,body:node.querySelector('#modal-body').scrollWidth})")
assert dimensions["scroll"] <= dimensions["width"] + 1, dimensions
page.set_viewport_size({"width":1440,"height":1000})
submit()
install = next(p for p in api("/profiles") if p["name"] == "Browser graphical installation")
assert install["values"]["disk_setup"] == {"filesystem":"zfs", "selection":"all", "zfs":{"raid":"raid0"}}
api(f'/profiles/{install["id"]}/publish', PUBLICATION)
results.append("Single-disk ZFS RAID0 installation saved without filter/code; 390px layout fits")
# Postinstallation step creation, ordering, parameter boolean and integer zero.
route("postinstall")
page.locator('[data-action="create-profile"]').first.click()
fill("name", "Browser graphical postinstall")
builds("profile-builds")
modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click()
first = modal.locator('[data-postinstall-step]').nth(0)
first.locator('select[name$="-module"]').select_option(prerequisites["id"])
modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click()
second = modal.locator('[data-postinstall-step]').nth(1)
second.locator('select[name$="-module"]').select_option(final_module["id"])
second.locator('[data-new-property]').select_option("require_time_sync")
second.get_by_role("button", name="Feld hinzufügen", exact=True).click()
checkbox = second.locator('[data-scalar-value][type="checkbox"]')
checkbox.uncheck()
second.get_by_role("button", name="↑ Nach oben", exact=True).click()
modal.locator('[data-postinstall-step]').nth(0).get_by_role("button", name="↓ Nach unten", exact=True).click()
fill("postinstall-reboot-budget", 0)
no_code()
page.screenshot(path=str(ctx["artifacts"] / "postinstall-desktop.png"), full_page=True)
submit()
postinstall = next(p for p in api("/profiles") if p["name"] == "Browser graphical postinstall")
assert [s["module_id"] for s in postinstall["steps"]] == [prerequisites["id"],final_module["id"]]
assert postinstall["steps"][1]["parameters"] == {"require_time_sync":False}
assert postinstall["reboot_budget"] == 0
api(f'/profiles/{postinstall["id"]}/publish', PUBLICATION)
results.append("Postinstall step add/reorder/boolean false and zero restart budget preserved")
# A complete host preview proves controls generated backend-supported configuration.
group = api("/groups", {"name":"browser-lab", "site":"lab", "valid_hours":1})
iso = api("/iso-records", {"name":"Synthetic browser ISO", "build":"9.1-1", "sha256":"1"*64,"assistant_version":"test-only","fingerprint":"2"*64,"group_id":group["id"],"native_token_support":True,"test_status":"passed","test_evidence":"Synthetic browser fixture only"})
route("hosts")
page.locator('[data-action="create-host"]').first.click()
fill("fqdn", "browser.lab.example.net")
fill("site", "lab")
fill("management_ip", "192.0.2.10/24")
fill("identity_value", "BROWSER-SERVER-01")
modal.get_by_role("button", name="Kennung hinzufügen", exact=True).click()
identity = modal.locator('[data-identity-row]').nth(1)
identity.locator('[name="identity_kind"]').select_option("mac")
identity.locator('[name="identity_value"]').fill("02:00:00:00:00:01")
select("installation_profile_id", install["id"])
select("postinstall_profile_id", postinstall["id"])
select("iso_id", iso["id"])
no_code()
submit("Server anlegen")
host = next(h for h in api("/hosts") if h["fqdn"] == "browser.lab.example.net")
assert len(host["identities"]) == 2 and host["overrides"] == {}
preview = api(f'/hosts/{host["id"]}/preview')
assert preview["resolved"]["disk_setup"] == install["values"]["disk_setup"]
assert preview["resolved"]["global"]["fqdn"] == host["fqdn"]
assert preview["resolved"]["network"]["cidr"] == "192.0.2.10/24"
route("hosts/" + host["id"])
page.locator('[data-action="preview-host"]').click()
no_code()
assert "raid0" in modal.inner_text().lower(), modal.inner_text()
modal.get_by_role("button", name="Dialog schließen").click()
page.locator('[data-action="edit-host"]').click()
submit("Änderungen speichern")
assert api(f'/hosts/{host["id"]}')["version"] == host["version"]
results.append("Host identity rows, assignments, no-change edit and full resolved preview passed")
# Multiple host entry uses repeatable rows, retains optional management IP.
route("hosts")
page.locator('[data-action="import-hosts"]').click()
fill("site", "batch")
rows = modal.locator('[data-import-host]')
rows.nth(0).locator('[name="import_fqdn"]').fill("batch1.lab.example.net")
rows.nth(0).locator('[name="identity_value"]').fill("BROWSER-BATCH-1")
modal.get_by_role("button", name="Weiteren Server hinzufügen", exact=True).click()
rows.nth(1).locator('[name="import_fqdn"]').fill("batch2.lab.example.net")
rows.nth(1).locator('[name="identity_value"]').fill("BROWSER-BATCH-2")
no_code()
submit("Server anlegen")
batch = [h for h in api("/hosts") if h["site"] == "batch"]
assert len(batch) == 2 and all(h["management_ip"] is None for h in batch)
results.append("Multiple host entry saved two rows with optional management IP")
(ctx["artifacts"] / "acceptance-results.json").write_text(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2),encoding="utf-8")
print(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2), flush=True)
PUB = PUBLICATION
def normalized_schema(value):
if isinstance(value,dict):
return {key:sorted(item) if key=='required' and isinstance(item,list) else normalized_schema(item) for key,item in value.items()}
if isinstance(value,list):
return [normalized_schema(item) for item in value]
return value
def preservation_checks():
with local_browser() as (page,api,ctx):
page.set_default_timeout(8000)
modal=page.locator("#modal")
results=[]
def route(path):
page.goto(ctx["base"]+"/#/"+path)
page.wait_for_selector("#main .page-header")
def submit():
modal.get_by_role("button",name="Entwurf speichern",exact=True).click()
try: modal.wait_for(state="hidden")
except Exception:
print(modal.inner_text(),flush=True)
raise
secret=api("/secrets",{"name":"fixture","value":"$6$fixture$"+"A"*86})
values={
"global":{"keyboard":"de-ch","country":"ch","timezone":"Europe/Zurich","mailto":"admin@example.net","fqdn":"template.example.net","root-ssh-keys":[],"reboot-on-error":False},
"network":{"source":"from-answer","cidr":"192.0.2.10/24","gateway":"192.0.2.1","dns":"192.0.2.53","filter":{"INTERFACE":"eno1","CUSTOM_MARKER":"firmware-slot"}},
"disk_setup":{"filesystem":"zfs","filter":{"ID_SERIAL":"DISK-*"},"filter_match":"any","expected_count":2,"expected_serials":["DISK-A","DISK-B"],"inventory_evidence":"Inventory fixture reference","zfs":{"raid":"raid1","ashift":12,"arc-max":2048,"copies":2,"hdsize":123.5,"checksum":"sha256","compress":"off"}},
"root_secret_id":secret["id"],
}
profile=api("/profiles",{"name":"Detailed existing profile","kind":"installation","values":values,"target_builds":["9.1-1"],"locked_fields":["disk_setup","network.gateway"]})
route("installation")
page.locator(f'[data-action="version-profile"][data-id="{profile["id"]}"]').click()
submit()
versions=sorted([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
assert versions[-1]["values"]==values,(versions[-1]["values"],values)
assert versions[-1]["locked_fields"]==profile["locked_fields"]
results.append("Detailed ZFS/network/global config and custom locked fields roundtrip unchanged")
# Switching filesystem removes incompatible settings and retains a zero swap.
page.locator(f'[data-action="version-profile"][data-id="{versions[-1]["id"]}"]').click()
modal.locator('[name="installation.disk.filesystem"]').select_option("ext4")
serial=modal.locator('[data-editor="installation.disk.expected_serials"]')
serial.locator('[data-edit-action="remove"]').last.click()
modal.locator('[name="installation.disk.expected_count"]').fill("1")
modal.locator('[name="installation.disk.lvm.swapsize"]').fill("0")
submit()
latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
assert "zfs" not in latest["values"]["disk_setup"]
assert latest["values"]["disk_setup"]["lvm"]["swapsize"]==0
page.locator(f'[data-action="version-profile"][data-id="{latest["id"]}"]').click()
modal.locator('[name="installation.disk.filesystem"]').select_option("zfs")
modal.locator('[name="installation.disk.mode"]').select_option("all")
modal.locator('[name="installation.disk.zfs.raid"]').select_option("raid0")
submit()
latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
disks=latest["values"]["disk_setup"]
assert disks["selection"]=="all" and disks["zfs"]["raid"]=="raid0"
assert not set(disks).intersection({"lvm","filter","filter_match","expected_count","expected_serials"})
results.append("RAID1 to ext4 to automatic RAID0 strips incompatible fields and preserves numeric zero")
# Sparse host override editing must not replace inherited configuration.
overrides={"global":{"reboot-on-error":False},"network":{"dns":"192.0.2.54"},"root_secret_id":secret["id"]}
host=api("/hosts",{"fqdn":"preserve.example.net","site":"lab","identities":[{"kind":"serial","value":"PRESERVE-HOST"}],"overrides":overrides})
route("hosts/"+host["id"])
page.locator('[data-action="edit-host"]').click()
modal.locator('[name="site"]').fill("new-lab")
modal.get_by_role("button",name="Änderungen speichern",exact=True).click()
modal.wait_for(state="hidden")
assert api('/hosts/'+host['id'])['overrides']==overrides
results.append("Sparse host overrides preserve inherited values and explicit false")
# Nested catalog schemas and nonstandard module fields preserve their rules.
catalog={m["id"]:m for m in api("/modules/builtin")}
modules=[]
for key in ["prerequisites","ssh"]:
template=catalog[key]
body={k:deepcopy(template[k]) for k in ["name","source","parameters_schema","dependencies","timeout_seconds","retry_safe"]}
body['target_builds']=['9.1-1']
module=api('/modules',body)
api('/modules/'+module['id']+'/publish',PUB)
modules.append(module)
route('modules')
page.locator(f'[data-action="version-module"][data-id="{modules[1]["id"]}"]').click()
submit()
ssh_versions=[m for m in api('/modules') if m['name']==modules[1]['name']]
assert normalized_schema(max(ssh_versions,key=lambda m:m['version'])['parameters_schema'])==normalized_schema(modules[1]['parameters_schema'])
parameters={"users":[{"name":"root","authorized_keys":["ssh-ed25519 AAAATEST original"]}]}
steps=[{"id":"fixed-first","module_id":modules[0]['id'],"parameters":{},"secret_refs":{},"required":True},{"id":"fixed-ssh","module_id":modules[1]['id'],"parameters":parameters,"secret_refs":{"TEST_KEY":secret['id']},"required":False}]
post=api('/profiles',{"name":"Nested existing post","kind":"postinstall","target_builds":["9.1-1"],"steps":steps,"values":{"custom":{"enabled":False,"zero":0,"empty":[],"missing":None}},"reboot_budget":0})
route('postinstall')
page.locator(f'[data-action="version-profile"][data-id="{post["id"]}"]').click()
second=modal.locator('[data-postinstall-step]').nth(1)
keys=second.locator('[data-entry]:has(> .data-key-label > [data-entry-key][value="authorized_keys"]) > .data-entry-value > [data-value-node]')
keys.locator(':scope > .data-node-content > .data-add > button').click()
keys.locator('textarea[data-scalar-value]').last.fill("ssh-ed25519 AAAATEST second")
page.set_viewport_size({"width":390,"height":844})
second.scroll_into_view_if_needed()
page.screenshot(path=str(ctx["artifacts"]/'postinstall-nested-mobile.png'),full_page=True)
size=modal.evaluate('n=>({width:n.clientWidth,scroll:n.scrollWidth})')
assert size['scroll']<=size['width']+1,size
page.set_viewport_size({"width":1440,"height":1000})
submit()
updated=max([p for p in api('/profiles') if p['name']==post['name']],key=lambda p:p['version'])
assert updated['values']==post['values']
expected=deepcopy(steps)
expected[1]['parameters']['users'][0]['authorized_keys'].append('ssh-ed25519 AAAATEST second')
assert updated['steps']==expected,(updated['steps'],expected)
results.append('Nested SSH key add preserves fixed step IDs, references, false required flag, custom null/empty/false/zero values; mobile layout fits')
print(json.dumps({'passed':results,'errors':ctx['errors']},indent=2),flush=True)
if __name__ == "__main__":
graphical_workflows()
preservation_checks()
+98
View File
@@ -0,0 +1,98 @@
"""Isolated localhost application and Chromium support for browser_forms.py.
Only generated test credentials/data are used. Fixture databases, server logs,
and screenshots are written below the repository's ignored .cache/ui-browser.
The server process and browser are always terminated after each scenario group.
"""
from contextlib import contextmanager
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import time
import httpx
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
TEST_PASSWORD = "Browser-fixture-admin-2026!"
@contextmanager
def local_browser(viewport=None):
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
base = f"http://127.0.0.1:{port}"
artifacts = ROOT / ".cache" / "ui-browser"
artifacts.mkdir(parents=True, exist_ok=True)
tempdir = Path(tempfile.mkdtemp(prefix="fixture-", dir=artifacts))
env = {
**os.environ,
"PYTHONPATH": str(ROOT),
"DATA_DIR": str(tempdir / "data"),
"MASTER_KEY_FILE": str(tempdir / "keys" / "master.key"),
"PUBLIC_URL": base,
"SECURE_COOKIES": "false",
"BOOTSTRAP_USERNAME": "admin",
"BOOTSTRAP_PASSWORD": TEST_PASSWORD,
"TESTING": "true",
"FOUR_EYES": "false",
}
log = (tempdir / "server.log").open("w", encoding="utf-8")
server = subprocess.Popen(
[sys.executable, "-m", "provisioner.cli", "serve", "--host", "127.0.0.1", "--port", str(port)],
cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
try:
for _ in range(80):
if server.poll() is not None:
raise RuntimeError((tempdir / "server.log").read_text(encoding="utf-8"))
try:
if httpx.get(base + "/health/ready", timeout=1).status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(0.15)
else:
raise TimeoutError("Local application did not become ready")
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(viewport=viewport or {"width": 1440, "height": 1000})
page = context.new_page()
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto(base + "/")
page.get_by_label("Benutzername").fill("admin")
page.get_by_label("Passwort", exact=True).fill(TEST_PASSWORD)
page.get_by_role("button", name="Anmelden").click()
page.wait_for_selector("#main .page-header")
csrf = context.request.get(base + "/api/v1/me").json()["csrf_token"]
def api(path, body=None):
response = context.request.get(base + "/api/v1" + path) if body is None else context.request.post(
base + "/api/v1" + path, data=body, headers={"X-CSRF-Token": csrf})
assert response.ok, f"{path}: {response.status} {response.text()}"
return response.json()
yield page, api, {"base": base, "artifacts": artifacts, "fixture": tempdir, "errors": errors}
assert not errors, errors
context.close()
browser.close()
finally:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
log.close()
if __name__ == "__main__":
with local_browser() as (page, api, context):
print(json.dumps({"title": page.title(), "url": page.url, "me": api("/me")["username"], "errors": context["errors"]}))
+9 -3
View File
@@ -7,6 +7,7 @@ from http.cookiejar import CookieJar
import json
import os
from pathlib import Path
import re
import secrets
import socket
import ssl
@@ -77,13 +78,18 @@ def main():
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
assert response.status == 200
assert b"/static/app.js" in response.read()
page = response.read()
assert b"/static/app.js" in page
with request("/api/v1/me") as response:
csrf = json.load(response)["csrf_token"]
with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response:
host_id = json.load(response)["id"]
with opener.open(base+"/static/app.js",timeout=5) as response:
assert response.status == 200 and len(response.read())>1000
assets = set(re.findall(rb'(?:src|href)="(/static/[^\"]+)"', page))
assert b"/static/installation-form.js" in assets
assert b"/static/module-forms.js" in assets
for asset in assets:
with opener.open(base+asset.decode(),timeout=5) as response:
assert response.status == 200 and response.read(), asset
process.terminate()
process.wait(timeout=15)
process = start()
+76
View File
@@ -0,0 +1,76 @@
"""Root passwords entered through the UI become encrypted installer hashes."""
import json
from passlib.hash import sha512_crypt
import pytest
from provisioner.security import Security
from test_acceptance import PASSWORD, environment, login, post
def test_root_password_secret_is_hashed_encrypted_and_never_returned(environment):
app, client, csrf = environment
password = " Installer-password-2026! "
created = post(client, "/api/v1/secrets", {
"name": "Installer root password", "kind": "root_password", "value": password,
}, csrf)
assert set(created) == {"id", "name"}
with app.state.db.connection() as connection:
row = connection.execute("SELECT * FROM secrets WHERE id=?", (created["id"],)).fetchone()
audit_data = json.dumps([dict(entry) for entry in connection.execute("SELECT * FROM audit")])
stored = app.state.security.decrypt(row["ciphertext"])
assert stored.startswith("$6$rounds=656000$")
assert sha512_crypt.verify(password, stored)
assert not sha512_crypt.verify(password.strip(), stored)
assert password not in row["ciphertext"] and stored not in row["ciphertext"]
public_data = json.dumps(created) + client.get("/api/v1/secrets").text + audit_data
assert password not in public_data and stored not in public_data
@pytest.mark.parametrize("password", ["short", "x" * 1025, "Valid-password\x00invalid"])
def test_root_password_secret_rejects_invalid_password_without_echo(environment, password):
app, client, csrf = environment
response = client.post("/api/v1/secrets", json={
"name": "Invalid root password", "kind": "root_password", "value": password,
}, headers=csrf)
assert response.status_code == 422
assert password not in response.text
with app.state.db.connection() as connection:
assert connection.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] == 0
def test_raw_secret_default_preserves_existing_behavior(environment):
app, client, csrf = environment
created = post(client, "/api/v1/secrets", {"name": "Raw value", "value": " unchanged-value "}, csrf)
with app.state.db.connection() as connection:
row = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (created["id"],)).fetchone()
assert app.state.security.decrypt(row["ciphertext"]) == "unchanged-value"
assert client.post("/api/v1/secrets", json={"name": "Empty raw value", "value": " "}, headers=csrf).status_code == 422
@pytest.mark.parametrize("role, metadata_status", [("author", 200), ("operator", 200), ("reader", 403)])
def test_secret_metadata_roles_cannot_create_or_read_secret_values(environment, role, metadata_status):
app, client, csrf = environment
created = post(client, "/api/v1/secrets", {"name": "Private root reference", "value": "private-value"}, csrf)
post(client, "/api/v1/users", {"username": role, "password": PASSWORD, "role": role}, csrf)
user_csrf = login(client, username=role)
response = client.get("/api/v1/secrets")
assert response.status_code == metadata_status
if metadata_status == 200:
assert response.json()[0]["id"] == created["id"]
assert set(response.json()[0]) == {"id", "name", "created_at"}
assert "private-value" not in response.text
denied = client.post("/api/v1/secrets", json={
"name": "Denied password", "kind": "root_password", "value": "Forbidden-password-2026!",
}, headers=user_csrf)
assert denied.status_code == 403
assert "Forbidden-password" not in denied.text
def test_installer_password_hash_uses_random_salt():
password = "Installer-password-2026!"
first = Security.hash_installer_password(password)
second = Security.hash_installer_password(password)
assert first != second
assert sha512_crypt.verify(password, first)
assert sha512_crypt.verify(password, second)