feat(ui): replace configuration editors with graphical forms
This commit is contained in:
@@ -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()
|
||||
@@ -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"]}))
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user