UmbrelApps: community app store whatsnext

Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HarmenandClaude Opus 5.5 committed 2026-10-09 10:40:25 +02:00
commit 85d85a1295
63 files changed
+13987

No files matched your search

+629
View File
@@ -0,0 +1,629 @@
"""Toetst de certificaatlezer en de keuze-guard van de agent.
Waarom deze test bestaat: de agent leest een X.509-certificaat met een eigen
DER-lezer, omdat de standaardbibliotheek er geen heeft en de app bij het starten
niets bijinstalleert. Zo'n lezer faalt niet met een foutmelding maar met een
verkeerd antwoord, en een certificaatdatum die er een jaar naast zit valt nooit
op. Daarom wordt hij hier vergeleken met `ssl`, dat een onafhankelijke
implementatie is.
Draaien:
python tests/test_agent_certificates.py
De test laadt `tools/electrum-gate/agent.py` rechtstreeks: het bestand dat de
Dockerfile in de image zet. Tot 0.1.0 heette dat `agent.py.template` en stond het
in de app-map; de eerste toets hieronder is uit die tijd en blijft staan, want
het script haalt zijn instellingen nog altijd uit de omgeving en niet uit een
ingevulde constante.
"""
import sys
# Vóór de imports, want anders is het te laat: Python legt bytecode naast agent.py
# zodra die geïmporteerd wordt, en die rommel hoort niet in de repo. Een keer is
# zo'n .pyc meegegaan in een commit.
sys.dont_write_bytecode = True
import importlib.machinery # noqa: E402
import importlib.util # noqa: E402
import os # noqa: E402
import shutil # noqa: E402
import socket # noqa: E402
import ssl # noqa: E402
import subprocess # noqa: E402
import tempfile # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, os.pardir, "tools", "electrum-gate", "agent.py")
def load_agent():
"""Laadt agent.py als module, buiten sys.path om."""
loader = importlib.machinery.SourceFileLoader("gate_agent", AGENT)
spec = importlib.util.spec_from_file_location("gate_agent", AGENT, loader=loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def der(tag, inhoud):
"""Eén DER-element, met de lengte in korte of lange vorm."""
if len(inhoud) < 0x80:
return bytes([tag, len(inhoud)]) + inhoud
lengte = len(inhoud).to_bytes((len(inhoud).bit_length() + 7) // 8, "big")
return bytes([tag, 0x80 | len(lengte)]) + lengte + inhoud
def nep_certificaat(tijdtag, tijdtekst):
"""Het kleinste certificaat waarin not_after zijn validity vindt."""
tijd = der(tijdtag, tijdtekst.encode("ascii"))
return der(0x30, der(0x30, der(0x30, tijd + tijd)))
def test_template_is_invulbaar_zonder_schade(u):
"""Een restant uit de template-tijd dat blijft staan.
Het bestand zit sinds 0.1.0 in de image en gaat door geen envsubst meer. Maar
een accolade-variabele in Python-code is ook zonder umbreld een fout, en als
iemand dit bestand ooit weer als template in een app-map zet, is dit de toets
die het meteen ziet.
"""
with open(AGENT, "r", encoding="utf-8") as f:
inhoud = f.read()
u.check("agent.py bevat geen accolade-variabelen",
"${" not in inhoud,
"een envsubst-stap zou die invullen en de Python-code slopen")
def test_einddatum_gelijk_aan_ssl(agent, u):
"""De echte certificaten uit de certificaatwinkel van het besturingssysteem.
Dit is de belangrijkste toets, want `ssl` levert hier het antwoordblad. Zijn
er geen certificaten te vinden, dan wordt dit overgeslagen en gemeld, in
plaats van stil als geslaagd geteld.
"""
ctx = ssl.create_default_context()
geparseerd = ctx.get_ca_certs(binary_form=False)
ders = ctx.get_ca_certs(binary_form=True)
if not ders or len(ders) != len(geparseerd):
print("OVERGESLAGEN: geen CA-certificaten beschikbaar op dit systeem, "
"dus de vergelijking met ssl is niet gedaan")
return
afwijkingen = []
for i, d in enumerate(ders):
verwacht = int(ssl.cert_time_to_seconds(geparseerd[i]["notAfter"]))
try:
gekregen = agent.not_after(d)
except Exception as e: # noqa: BLE001
afwijkingen.append("cert %d wierp %r" % (i, e))
continue
if gekregen != verwacht:
afwijkingen.append("cert %d: verwacht %d, kreeg %d"
% (i, verwacht, gekregen))
u.check("einddatum van %d CA-certificaten gelijk aan ssl" % len(ders),
not afwijkingen, "; ".join(afwijkingen[:3]))
def test_beide_tijdvormen(agent, u):
"""UTCTime en GeneralizedTime, en beide takken van de eeuwregel.
Het CA-materiaal gebruikt uitsluitend UTCTime met jaren na 2000, dus zonder
deze zelfgebouwde certificaten blijven twee takken ongetoetst.
"""
gevallen = [
(0x18, "20700101000000Z", "Jan 1 00:00:00 2070 GMT"),
(0x18, "99991231235959Z", "Dec 31 23:59:59 9999 GMT"),
(0x17, "491231235959Z", "Dec 31 23:59:59 2049 GMT"),
(0x17, "500101000000Z", "Jan 1 00:00:00 1950 GMT"),
]
for tag, tekst, ssl_tekst in gevallen:
verwacht = int(ssl.cert_time_to_seconds(ssl_tekst))
gekregen = agent.not_after(nep_certificaat(tag, tekst))
u.check("tijd %s" % tekst, gekregen == verwacht,
"verwacht %d, kreeg %d" % (verwacht, gekregen))
def test_tijd_zonder_zulu_faalt_hard(agent, u):
"""Het niet-gelukkige pad: een tijd die DER niet toestaat.
Dit is met opzet een uitzondering en geen benadering. Een certificaatdatum die
er een uur naast zit, valt nooit op.
"""
slecht = nep_certificaat(0x18, "20700101000000")
try:
agent.not_after(slecht)
u.check("tijd zonder Z faalt", False, "er kwam een antwoord uit")
except ValueError:
u.check("tijd zonder Z faalt", True)
except Exception as e: # noqa: BLE001
u.check("tijd zonder Z faalt", False, "verkeerde uitzondering: %r" % e)
def test_certificaat_zonder_validity_faalt(agent, u):
"""Een certificaat waar niets in staat, mag geen datum opleveren."""
leeg = der(0x30, der(0x30, der(0x02, b"\x01")))
try:
agent.not_after(leeg)
u.check("geen validity faalt", False, "er kwam een antwoord uit")
except ValueError:
u.check("geen validity faalt", True)
def test_live_certificaat_met_san(agent, u):
"""Een echt servercertificaat, want CA-certificaten hebben geen dNSName.
Zonder netwerk wordt dit overgeslagen en blijft subjectAltName ongetoetst.
Dat is gemeld, niet verzwegen.
"""
try:
with ssl.create_default_context().wrap_socket(
socket.create_connection(("example.com", 443), timeout=6),
server_hostname="example.com",
) as s:
binair = s.getpeercert(binary_form=True)
info = s.getpeercert()
except Exception as e: # noqa: BLE001
print("OVERGESLAGEN: geen verbinding voor de SAN-toets (%s), "
"subjectAltName blijft dus ongetoetst" % e)
return
verwacht = sorted(v for k, v in info.get("subjectAltName", ()) if k == "DNS")
gekregen = sorted(agent.dns_names(binair))
u.check("subjectAltName gelijk aan ssl", gekregen == verwacht,
"verwacht %r, kreeg %r" % (verwacht[:3], gekregen[:3]))
u.check("einddatum van een levend certificaat gelijk aan ssl",
agent.not_after(binair) == int(ssl.cert_time_to_seconds(info["notAfter"])))
def test_keuze_negeert_onbekende_id(agent, u):
"""De guard. Dit is het pad waar een fout stil TLS zou uitschakelen.
Wijst de keuze naar iets wat niet bestaat, dan mag de app niet doen alsof.
Hij valt terug op wat er wel is, en meldt dat.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.CONFIG_DIR = tmp
agent.SELECTED_FILE = os.path.join(tmp, "selected-cert")
nu = 2_000_000_000
certs = [
{"id": "zoraxy/kort", "source": "zoraxy", "name": "kort",
"cert": "/x", "key": "/y", "not_after": nu + 86400},
{"id": "zoraxy/lang", "source": "zoraxy", "name": "lang",
"cert": "/a", "key": "/b", "not_after": nu + 400 * 86400},
]
agent.write_selection("zoraxy/bestaat-niet")
gekozen, waarom = agent.choose(certs)
u.check("onbekende keuze wordt niet gehonoreerd",
gekozen is None, "koos %r" % (gekozen and gekozen["id"]))
u.check("en er wordt ook niet zomaar een ander gepakt",
"2 certificates found" in waarom, "waarom was %r" % waarom)
# De reden noemt het aantal en niet de namen. Op de Umbrel van de
# gebruiker zijn dat er veertien, en die opsomming maakte van de melding
# een muur tekst die zegt wat de keuzelijst eronder al toont.
u.check("de reden somt de kandidaten niet op",
"zoraxy/kort" not in waarom and "zoraxy/lang" not in waarom,
"waarom was %r" % waarom)
# Eén kandidaat is geen keuze, dus die mag wel automatisch.
gekozen, waarom = agent.choose(certs[:1])
u.check("bij precies één certificaat kiest de app zelf",
gekozen is not None and gekozen["id"] == "zoraxy/kort",
"koos %r" % (gekozen and gekozen["id"]))
agent.write_selection("zoraxy/kort")
gekozen, waarom = agent.choose(certs)
u.check("een bestaande keuze wint van de automaat",
gekozen["id"] == "zoraxy/kort", "koos %r" % gekozen["id"])
onleesbaar = [dict(certs[0], unreadable="stuk")]
gekozen, waarom = agent.choose(onleesbaar)
u.check("een onleesbaar certificaat wordt niet gekozen",
gekozen is None, "koos %r" % (gekozen and gekozen["id"]))
def test_keuze_zonder_geldig_certificaat(agent, u):
"""Alles verlopen: dan liever niets dan een verlopen certificaat opdringen."""
with tempfile.TemporaryDirectory() as tmp:
agent.CONFIG_DIR = tmp
agent.SELECTED_FILE = os.path.join(tmp, "selected-cert")
verlopen = [{"id": "own/oud", "source": "own", "name": "oud",
"cert": "/a", "key": "/b", "not_after": 1}]
gekozen, waarom = agent.choose(verlopen)
u.check("alles verlopen levert geen keuze op", gekozen is None)
u.check("en de reden wordt genoemd", "expired" in waarom,
"waarom was %r" % waarom)
gekozen, waarom = agent.choose([])
u.check("een lege lijst levert geen keuze op", gekozen is None)
def test_paren_vinden(agent, u):
"""De drie naamvormen van de bronnen, in een echte map."""
with tempfile.TemporaryDirectory() as tmp:
# Zoraxy-vorm
open(os.path.join(tmp, "a.example.org.pem"), "w").close()
open(os.path.join(tmp, "a.example.org.key"), "w").close()
# Een certificaat zonder sleutel hoort niet mee te komen
open(os.path.join(tmp, "b.example.org.pem"), "w").close()
# Nginx Proxy Manager-vorm
os.mkdir(os.path.join(tmp, "npm-7"))
open(os.path.join(tmp, "npm-7", "fullchain.pem"), "w").close()
open(os.path.join(tmp, "npm-7", "privkey.pem"), "w").close()
# Een map zonder sleutel hoort ook niet mee te komen
os.mkdir(os.path.join(tmp, "npm-8"))
open(os.path.join(tmp, "npm-8", "fullchain.pem"), "w").close()
namen = sorted(n for n, _, _ in agent._pairs_in(tmp))
u.check("alleen volledige paren", namen == ["a.example.org", "npm-7"],
"vond %r" % namen)
u.check("een map die niet bestaat levert een lege lijst",
agent._pairs_in(os.path.join(tmp, "weg")) == [])
def openssl_paar(map_, naam, cn, dagen=365, wachtwoord=None, san=True):
"""Een echt zelfondertekend paar. Geeft (certpad, keypad) of None.
Echt en niet zelfgebouwd, want de controle die hier getoetst wordt is die van
OpenSSL zelf: hoort deze sleutel bij dit certificaat. Een met de hand
geknutseld DER-certificaat heeft geen sleutel en bewijst daar dus niets over.
"""
if not shutil.which("openssl"):
return None
cert = os.path.join(map_, naam + ".crt")
key = os.path.join(map_, naam + ".key")
opdracht = [
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key, "-out", cert, "-days", str(dagen),
# De schuine streep is in -subj het scheidingsteken tussen velden, dus een
# hostnaam die er zelf een bevat moet ontsnapt worden. Zonder dit sloeg de
# padtruc-toets zichzelf stil over, en dat is precies de toets die er moet
# zijn.
"-subj", "/CN=" + cn.replace("/", "\\/"),
]
if san:
opdracht += ["-addext", "subjectAltName=DNS:" + cn]
if wachtwoord:
opdracht += ["-passout", "pass:" + wachtwoord]
else:
opdracht += ["-nodes"]
klaar = subprocess.run(opdracht, capture_output=True)
if klaar.returncode != 0:
# De laatste regel, want openssl vult stderr met voortgangspuntjes en de
# echte reden staat onderaan.
regels = [r for r in klaar.stderr.decode("utf-8", "replace").splitlines()
if r.strip()]
print("OVERGESLAGEN: openssl gaf een fout bij %s (%s)"
% (naam, regels[-1][:160] if regels else "geen melding"))
return None
return cert, key
class Tijdreis:
"""Een tijdmodule die alleen over `time()` liegt.
Nodig om de vervaldatum-guard te toetsen: een certificaat dat al verlopen ís
kan openssl 3.1 niet maken, en de datum vooruitzetten kan wel.
"""
def __init__(self, echt, wanneer):
self._echt = echt
self._wanneer = wanneer
def time(self):
return self._wanneer
def __getattr__(self, naam):
return getattr(self._echt, naam)
def test_upload(agent, u):
"""Het uploadpad, en vooral wat het weigert.
Dit is het enige pad waarlangs iets van buiten bestanden neerzet, dus de
toetsen gaan over de niet-gelukkige gevallen. De duurste fout die hier
voorkomen moet worden is een sleutel die niet bij het certificaat hoort: die
levert een nginx op die niet meer herlaadt, en dat is dezelfde klasse storing
als waardoor 0.0.3 helemaal niet startte.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "bron")
doel = os.path.join(tmp, "own")
os.makedirs(bron)
agent.UPLOAD_DIR = doel
agent.SOURCES = [("Own folder", doel)]
# Uit zonder map. Eerst, want dit hoort niet stil door te vallen naar een
# pad dat toevallig bestaat.
agent.UPLOAD_DIR = ""
naam, fout = agent.accept_upload("x", "y")
u.check("zonder uploadmap weigert de agent",
naam is None and "switched off" in (fout or ""),
"gaf %r, %r" % (naam, fout))
agent.UPLOAD_DIR = doel
naam, fout = agent.accept_upload("hallo", "-----BEGIN PRIVATE KEY-----")
u.check("rommel in plaats van een certificaat wordt geweigerd",
naam is None and "does not contain a certificate" in (fout or ""),
"gaf %r, %r" % (naam, fout))
naam, fout = agent.accept_upload(
"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----", "hallo")
u.check("rommel in plaats van een sleutel wordt geweigerd",
naam is None and "does not contain a private key" in (fout or ""),
"gaf %r, %r" % (naam, fout))
u.check("een weigering laat niets achter in de doelmap",
not os.path.isdir(doel) or os.listdir(doel) == [],
"er staat: %r" % (os.path.isdir(doel) and os.listdir(doel)))
paar = openssl_paar(bron, "goed", "gate.example.org")
if paar is None:
print("OVERGESLAGEN: geen openssl, dus het gelukkige pad en de "
"sleutelcontrole van deze toets blijven ongetoetst")
return
cert_tekst = open(paar[0], encoding="ascii").read()
key_tekst = open(paar[1], encoding="ascii").read()
# De belangrijkste toets van dit bestand na de datumvergelijking: een
# sleutel van een ánder paar hoort hier te stranden.
ander = openssl_paar(bron, "ander", "andere.example.org")
naam, fout = agent.accept_upload(cert_tekst,
open(ander[1], encoding="ascii").read())
u.check("een sleutel van een ander paar wordt geweigerd",
naam is None and "does not go with" in (fout or ""),
"gaf %r, %r" % (naam, fout))
u.check("en ook dan blijft de doelmap leeg",
not os.path.isdir(doel) or os.listdir(doel) == [],
"er staat: %r" % (os.path.isdir(doel) and os.listdir(doel)))
# Een versleutelde sleutel: nginx kan die niet openen zonder wachtwoord.
met_slot = openssl_paar(bron, "slot", "slot.example.org",
wachtwoord="geheim")
if met_slot:
naam, fout = agent.accept_upload(
open(met_slot[0], encoding="ascii").read(),
open(met_slot[1], encoding="ascii").read())
u.check("een versleutelde sleutel wordt geweigerd",
naam is None and "encrypted" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# Verlopen. De klok gaat vooruit in plaats van het certificaat achteruit.
echt = agent.time
agent.time = Tijdreis(echt, echt.time() + 400 * 86400)
try:
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
finally:
agent.time = echt
u.check("een verlopen certificaat wordt geweigerd",
naam is None and "expired on" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# Een certificaat zonder bruikbare naam: er is dan niets om een wallet
# tegen te vergelijken, dus plaatsen heeft geen zin.
naamloos = openssl_paar(bron, "naamloos", "!!!", san=False)
if naamloos:
naam, fout = agent.accept_upload(
open(naamloos[0], encoding="ascii").read(),
open(naamloos[1], encoding="ascii").read())
u.check("een certificaat zonder hostnaam wordt geweigerd",
naam is None and "no host name" in (fout or ""),
"gaf %r, %r" % (naam, fout))
# En dan het gelukkige pad.
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
u.check("een geldig paar wordt aangenomen",
naam == "gate.example.org" and fout is None,
"gaf %r, %r" % (naam, fout))
u.check("en staat als paar in de doelmap",
sorted(os.listdir(doel)) == ["gate.example.org.key",
"gate.example.org.pem"],
"er staat: %r" % sorted(os.listdir(doel)))
u.check("de scanner vindt het meteen",
[n for n, _, _ in agent._pairs_in(doel)] == ["gate.example.org"])
u.check("de id verwijst naar de bron uit de omgeving",
agent.upload_id(naam) == "Own folder/gate.example.org",
"gaf %r" % agent.upload_id(naam))
# Nog een keer hetzelfde: dat vervangt, en laat geen .tmp achter. Een
# vernieuwd certificaat is precies waarom dit pad bestaat.
naam, fout = agent.accept_upload(cert_tekst, key_tekst)
u.check("hetzelfde nog eens vervangt zonder klagen",
naam == "gate.example.org" and fout is None,
"gaf %r, %r" % (naam, fout))
u.check("en er blijft geen tijdelijk bestand liggen",
not [n for n in os.listdir(doel) if n.endswith(".tmp")],
"er staat: %r" % sorted(os.listdir(doel)))
# Een naam met padtrucs erin kan niet uit het doel ontsnappen, want de
# naam komt uit het certificaat en niet uit het verzoek.
stout = openssl_paar(bron, "stout", "../../etc/nginx/evil", san=False)
if stout:
naam, fout = agent.accept_upload(
open(stout[0], encoding="ascii").read(),
open(stout[1], encoding="ascii").read())
u.check("een hostnaam met padtekens levert een platte naam op",
fout is None and "/" not in (naam or "/") and ".." not in (naam or ".."),
"gaf %r, %r" % (naam, fout))
u.check("en het bestand staat in de doelmap zelf",
fout is not None or os.path.isfile(
os.path.join(doel, naam + ".pem")))
def test_sessieregels_indelen(agent, u):
"""De drie soorten sessieregels uit de nginx-log.
Het onderscheid tussen `probe` en `refused` is het punt: een doorgestuurde
poort wordt gescand, en zonder dat onderscheid gaf elke scan een rode regel
"refused" terwijl er niets geweigerd is. De grens ligt bij de bytes en niet bij
de duur of de status, en dat is precies wat hier vastligt.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
with open(agent.STREAM_LOG, "w", encoding="utf-8") as f:
# Een echte sessie, een scan, en een verbinding die wél iets
# doorgaf en daarna omviel.
f.write("2026-08-20T12:39:29+02:00 200 95300 218000 240.5\n")
f.write("2026-08-20T12:38:29+02:00 500 0 0 1.001\n")
f.write("2026-08-20T12:40:29+02:00 502 120 0 0.5\n")
# Een scan die lang open bleef staan: nog steeds een scan.
f.write("2026-08-20T12:41:29+02:00 500 0 0 45.0\n")
f.write("rommel die geen sessieregel is\n")
regels = agent.read_stream_log(0)
soorten = [r["event"] for r in regels]
u.check("de drie soorten komen er in de juiste orde uit",
soorten == ["disconnect", "probe", "refused", "probe"],
"gaf %r" % soorten)
u.check("een onleesbare regel wordt overgeslagen", len(regels) == 4,
"gaf %d regels" % len(regels))
probe = regels[1]
u.check("de scan houdt zijn status in de notitie",
probe.get("note") == "nothing exchanged, status 500",
"gaf %r" % probe.get("note"))
u.check("en zijn bytes staan op nul",
probe["bytes_in"] == 0 and probe["bytes_out"] == 0)
stuk = regels[2]
u.check("een sessie die wél verkeer had heet refused",
stuk.get("note") == "session ended with status 502",
"gaf %r" % stuk.get("note"))
echt = regels[0]
# 240 en niet 241: round() in Python rondt een halve naar even af.
u.check("een geslaagde sessie heeft geen notitie nodig",
"note" not in echt and echt["seconds"] == 240,
"gaf %r" % echt)
def test_open_verbindingen(agent, u):
"""De verbindingsteller, en de logregel die eruit volgt.
Dit gaat via `build_status`, dus via de ronde die de agent echt draait, en
niet via een interne helper. Twee dingen moeten kloppen die makkelijk stil
fout gaan: een teller die er niet is mag geen nul worden, en een
connect-regel moet de volgende ronde overleven. Die laatste zat er eerst
naast: `build_status` neemt de eigen gebeurtenissen van de vorige ronde over
aan de hand van een opsomming van soorten, en `connect` stond er niet in.
Een regel die na een minuut weer verdwijnt is nutteloos.
"""
with tempfile.TemporaryDirectory() as tmp:
agent.SOURCES = []
agent.ELECTRUM_HOST = "" # dan faalt de probe meteen, zonder netwerk
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
# Geen tellerbestand: dan hoort er niets in de status te staan. Nul zou
# hier "geen wallet verbonden" beweren op grond van niets.
status = ronde()
u.check("zonder teller geen bewering over verbindingen",
"open_connections" not in status["tls"],
"stond er: %r" % status["tls"])
def zet_teller(tekst):
with open(agent.SESSIONS_FILE, "w", encoding="utf-8") as f:
f.write(tekst)
zet_teller("0\n")
status = ronde()
u.check("een teller van nul komt wel in de status",
status["tls"].get("open_connections") == 0)
u.check("nul verbindingen levert geen connect-regel op",
not [e for e in status["log"] if e.get("event") == "connect"])
zet_teller("1\n")
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("een verbinding erbij levert één connect-regel op",
len(connects) == 1, "regels: %r" % connects)
u.check("en die regel noemt hoeveel er nu openstaan",
connects and connects[0].get("note") == "1 open now",
"regel: %r" % (connects[0] if connects else None))
# Zelfde aantal, dus geen nieuwe regel. En de vorige moet blijven staan:
# dit is de toets die de vergeten opsomming vond.
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("een gelijk aantal levert geen tweede regel op",
len(connects) == 1, "regels: %r" % connects)
zet_teller("rommel")
status = ronde()
u.check("een onleesbare teller wordt niets, niet nul",
"open_connections" not in status["tls"],
"stond er: %r" % status["tls"])
u.check("en de eerdere connect-regel staat er nog",
len([e for e in status["log"] if e.get("event") == "connect"]) == 1)
# Van onbekend naar een aantal hoort ook een regel op te leveren. Dit is
# het geval "de wallet hing er al voordat de agent begon", en dat is
# precies wanneer iemand op de pagina komt kijken. Zonder deze regel blijft
# het log dan uren stil.
zet_teller("2\n")
status = ronde()
connects = [e for e in status["log"] if e.get("event") == "connect"]
u.check("na een onbekend aantal levert een verbinding alsnog een regel op",
len(connects) == 2, "regels: %r" % connects)
u.check("en die noemt het aantal dat er nu staat",
connects[-1].get("note") == "2 open now",
"regel: %r" % connects[-1])
def main():
u = Uitslag()
test_template_is_invulbaar_zonder_schade(u)
agent = load_agent()
test_einddatum_gelijk_aan_ssl(agent, u)
test_beide_tijdvormen(agent, u)
test_tijd_zonder_zulu_faalt_hard(agent, u)
test_certificaat_zonder_validity_faalt(agent, u)
test_live_certificaat_met_san(agent, u)
test_keuze_negeert_onbekende_id(agent, u)
test_keuze_zonder_geldig_certificaat(agent, u)
test_paren_vinden(agent, u)
# Deze twee als laatste, want ze verzetten de module-instellingen naar een
# tijdelijke map.
test_upload(agent, u)
test_sessieregels_indelen(agent, u)
test_open_verbindingen(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+434
View File
@@ -0,0 +1,434 @@
"""Toetst de zelfcontrole van de agent: antwoordt de eigen TLS-poort?
Waarom deze test bestaat: dit is de enige controle die over de app zelf gaat, en
hij kan op twee manieren stil verkeerd zijn. Hij kan "ok" melden terwijl er iets
anders aan de lijn hangt dan het gekozen certificaat, en hij kan het
activiteitenlog vullen met zijn eigen verbindingen. Dat tweede is geen schoonheids-
foutje: elke meting is voor nginx een gewone sessie, dus zonder rem en zonder
filter gaat het log over onszelf in plaats van over wallets.
De nadruk ligt daarom op wat er níet moet gebeuren: niet meten vlak na een
herlading, niet meten als er net gemeten is, en de eigen sessieregel niet laten
staan.
Draaien:
python tests/test_agent_zelfcontrole.py
Wat een echte TLS-server vraagt, vraagt openssl. Is dat er niet, dan slaan die
toetsen zichzelf over en zeggen dat erbij; de rest draait gewoon.
"""
import sys
# Zelfde reden als in test_agent_certificates.py: geen .pyc naast de app-map.
sys.dont_write_bytecode = True
import json # noqa: E402
import os # noqa: E402
import socket # noqa: E402
import ssl # noqa: E402
import tempfile # noqa: E402
import threading # noqa: E402
import time # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
# De buurtest is hier de gereedschapskist: hij laadt agent.py.template als module
# en maakt echte sleutelparen met openssl. Dat tweede keer overdoen zou een tweede
# versie opleveren die uit elkaar loopt.
from test_agent_certificates import Uitslag, load_agent, openssl_paar # noqa: E402
class TlsServer:
"""Een echte TLS-server op een vrije poort van de loopback.
Echt en niet nagebootst, want wat hier getoetst wordt is een handdruk. Een
verzonnen server bewijst niets over `wrap_socket`.
"""
def __init__(self, cert, key):
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.context.load_cert_chain(cert, key)
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
self.stoppen = False
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while not self.stoppen:
try:
rauw, _ = self.sock.accept()
except OSError:
return
try:
with self.context.wrap_socket(rauw, server_side=True) as tls:
# Wachten tot de client sluit. De agent sluit meteen na de
# handdruk, dus dit levert b"" op en niet een hangende lus.
tls.recv(1)
except OSError:
pass
def close(self):
self.stoppen = True
try:
self.sock.close()
except OSError:
pass
class KaleServer:
"""Luistert wel, maar spreekt geen TLS. Accepteert en sluit meteen."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
self.stoppen = False
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
def _loop(self):
while not self.stoppen:
try:
rauw, _ = self.sock.accept()
except OSError:
return
rauw.close()
def close(self):
self.stoppen = True
try:
self.sock.close()
except OSError:
pass
def dichte_poort():
"""Een poortnummer waar zeker niets luistert."""
s = socket.socket()
s.bind(("127.0.0.1", 0))
poort = s.getsockname()[1]
s.close()
return poort
def richt(agent, poort):
agent.TLS_HOST = "127.0.0.1"
agent.TLS_PORT = poort
def test_zonder_certificaat_wordt_er_niet_verbonden(agent, u):
"""Geen keuze betekent geen poort 50022, en dus geen storing.
Dit is de normale toestand van een verse installatie. Hem als fout melden zou
de ergste soort onwaarheid zijn: precies daar staat al een foutmelding dat er
een certificaat gekozen moet worden, en een tweede rode melding erbij leidt
weg van de knop die het oplost.
"""
richt(agent, dichte_poort())
for leeg in (None, {}):
uitkomst = agent.probe_self(leeg)
u.check("zonder certificaat is de toestand 'off' (%r)" % (leeg,),
uitkomst.get("state") == "off", "gaf %r" % uitkomst)
u.check("en dan is er niets gemeten (%r)" % (leeg,),
"ms" not in uitkomst and "error" not in uitkomst,
"gaf %r" % uitkomst)
def test_niemand_luistert(agent, u):
"""Wel een certificaat, geen poort. Dat is wel een storing."""
richt(agent, dichte_poort())
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
u.check("een dichte poort geeft 'failed'",
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
u.check("en de reden staat erbij",
bool(uitkomst.get("error")), "gaf %r" % uitkomst)
u.check("een mislukking meldt geen meettijd",
"ms" not in uitkomst, "gaf %r" % uitkomst)
def test_luisteren_is_niet_genoeg(agent, u):
"""Iets dat luistert maar geen TLS spreekt, is ook een storing.
Dit onderscheidt de controle van een simpele poortcheck. Een open poort
bewijst niet dat er een bruikbaar certificaat achter zit, en juist dat is wat
een wallet nodig heeft.
"""
server = KaleServer()
try:
richt(agent, server.port)
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
u.check("luisteren zonder TLS geeft 'failed'",
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
finally:
server.close()
def test_gelukkig_pad_en_verkeerd_certificaat(agent, u):
"""De handdruk lukt, en dan gaat het erom wélk certificaat er terugkomt.
Het tweede geval is de reden dat deze controle niet op vertrouwen leunt maar
op de bytes van het certificaat: nginx dat na een mislukte herlading nog het
vorige certificaat serveert, is voor elke trust-controle in orde en is toch
precies de storing die je zoekt.
"""
with tempfile.TemporaryDirectory() as tmp:
paar = openssl_paar(tmp, "gekozen", "gate.example.org")
ander = openssl_paar(tmp, "ander", "andere.example.org")
if not paar or not ander:
print("OVERGESLAGEN: geen openssl, dus de handdruk is niet getoetst")
return
cert, key = paar
ander_cert, _ = ander
server = TlsServer(cert, key)
try:
richt(agent, server.port)
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": cert})
u.check("het gekozen certificaat aan de lijn geeft 'ok'",
uitkomst.get("state") == "ok", "gaf %r" % uitkomst)
u.check("en een meettijd in milliseconden",
isinstance(uitkomst.get("ms"), int) and uitkomst["ms"] >= 0,
"gaf %r" % uitkomst)
uitkomst = agent.probe_self({"domain": "andere.example.org",
"cert": ander_cert})
u.check("een ander certificaat aan de lijn wordt herkend",
uitkomst.get("state") == "wrong-certificate", "gaf %r" % uitkomst)
finally:
server.close()
def test_eigen_sessies_uit_het_log(agent, u):
"""De filter die het log over wallets laat gaan in plaats van over onszelf."""
def regel(t, event="probe", inn=0, uit=0):
return {"t": t, "event": event, "bytes_in": inn, "bytes_out": uit}
entries = [regel(1000), regel(2000)]
over = agent.suppress_self_probes(entries, [1001])
u.check("een eigen meting verdwijnt uit het log",
[e["t"] for e in over] == [2000], "over: %r" % over)
over = agent.suppress_self_probes([regel(1000)], [1010])
u.check("een scan buiten het venster blijft staan",
len(over) == 1, "over: %r" % over)
# Dit is de belangrijkste: een sessie waarin wél iets is doorgegeven, is per
# definitie niet van ons. Die weglaten zou een storing verbergen.
echt = regel(1000, event="refused", inn=120, uit=300)
over = agent.suppress_self_probes([echt], [1000])
u.check("een sessie met verkeer blijft staan, ook op hetzelfde moment",
over == [echt], "over: %r" % over)
# Eén onthouden meting laat hoogstens één regel weg. Anders zou een enkele
# meting een reeks echte scans kunnen opslokken.
over = agent.suppress_self_probes([regel(1000), regel(1001)], [1000])
u.check("één meting laat hoogstens één regel weg",
len(over) == 1, "over: %r" % over)
u.check("zonder metingen verandert er niets",
agent.suppress_self_probes([regel(1000)], []) == [regel(1000)])
def test_ronde_meet_niet_elke_keer(agent, u):
"""De rem, via `build_status`, dus via de ronde die de agent echt draait.
Drie dingen op een rij die alle drie stil fout kunnen gaan:
1. vlak na een certificaatwissel wordt er niet gemeten. nginx herlaadt binnen
tien seconden, dus meten zou het vorige certificaat zien en dat als storing
melden;
2. de ronde daarna wordt er wel gemeten;
3. de ronde daarna weer niet, want er is net gemeten. Zonder deze rem staat er
elke minuut een eigen regel in het activiteitenlog.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "certs")
os.makedirs(bron)
paar = openssl_paar(bron, "gate", "gate.example.org")
if not paar:
print("OVERGESLAGEN: geen openssl, dus de rem is niet getoetst")
return
cert, key = paar
agent.SOURCES = [("Own folder", bron)]
agent.ELECTRUM_HOST = "" # dan faalt de backend-probe zonder netwerk
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
agent.SELF_CHECK_INTERVAL = 300
server = TlsServer(cert, key)
try:
richt(agent, server.port)
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
eerste = ronde()["tls"]["self_check"]
u.check("vlak na de eerste keuze wordt er niet gemeten",
eerste.get("state") == "pending", "gaf %r" % eerste)
tweede = ronde()["tls"]["self_check"]
u.check("de ronde daarna wel, en die lukt",
tweede.get("state") == "ok", "gaf %r" % tweede)
derde = ronde()
u.check("en daarna niet opnieuw, want er is net gemeten",
derde["tls"]["self_check"].get("at") == tweede.get("at"),
"gaf %r" % derde["tls"]["self_check"])
u.check("er is dus precies één eigen meting onthouden",
len(derde.get("self_probes", [])) == 1,
"gaf %r" % derde.get("self_probes"))
# En nu het sluitstuk: de sessieregel die nginx van diezelfde meting
# geschreven zou hebben, hoort niet in het log te komen. Een tweede
# regel even ervoor is niet van ons en moet blijven staan.
gemeten = tweede["at"]
with open(agent.STREAM_LOG, "w", encoding="utf-8") as f:
for t in (gemeten - 60, gemeten):
f.write("%s 500 0 0 0.010\n"
% time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(t)))
log = ronde()["log"]
momenten = [e["t"] for e in log if e.get("event") == "probe"]
u.check("de eigen sessieregel staat niet in het log",
gemeten not in momenten, "regels op: %r" % momenten)
u.check("en de scan van iemand anders wel",
(gemeten - 60) in momenten, "regels op: %r" % momenten)
finally:
server.close()
def test_eerste_ronde_en_snelle_herhaling(agent, u):
"""De startrace, en de rem die een mislukking vasthield.
AANLEIDING (gemeld door de gebruiker, 31-08-2026): na elke herstart of update
las de statuskaart vijf minuten lang "Not answering" met
`[Errno 111] Connection refused`, terwijl de gateway van buiten gewoon werkte.
De oorzaak was een startvolgorde en geen storing. De compose laat `server` ná
de agent starten (`depends_on: [agent]`), en `build_status` draait meteen bij
het opstarten. De allereerste meting valt dus gegarandeerd in het gat waarin
nginx nog niet luistert. Dat was op zichzelf te verdragen; het probleem was dat
de volgende meting pas na SELF_CHECK_INTERVAL kwam.
Twee dingen worden hier vastgelegd, en ze repareren allebei een helft:
1. de eerste ronde van een proces meet niet, ook niet als het certificaat
ongewijzigd is (dus met `reloaded` op False, en dát is het geval dat de
bestaande toets hierboven NIET raakt: daar is de eerste ronde `pending`
omdat het certificaat net gekozen is);
2. na een mislukking wordt er na SELF_CHECK_RETRY opnieuw gemeten en niet pas
na SELF_CHECK_INTERVAL.
"""
with tempfile.TemporaryDirectory() as tmp:
bron = os.path.join(tmp, "certs")
os.makedirs(bron)
paar = openssl_paar(bron, "gate", "gate.example.org")
if not paar:
print("OVERGESLAGEN: geen openssl, dus de startrace is niet getoetst")
return
cert, key = paar
agent.SOURCES = [("Own folder", bron)]
agent.ELECTRUM_HOST = ""
agent.STATE_DIR = tmp
agent.CONFIG_DIR = os.path.join(tmp, "config")
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
agent.STATUS_FILE = os.path.join(tmp, "status.json")
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
agent.SELF_CHECK_INTERVAL = 300
agent.SELF_CHECK_RETRY = 60
server = TlsServer(cert, key)
try:
richt(agent, server.port)
def ronde():
status = agent.build_status([])
agent.write_status(status)
return status
ronde() # certificaat gekozen: pending via reloaded
tweede = ronde()["tls"]["self_check"]
u.check("de meting lukt zodra het certificaat staat",
tweede.get("state") == "ok", "gaf %r" % tweede)
# Nu doen alsof het proces opnieuw begint, met een ONGEWIJZIGD
# certificaat. Vóór de reparatie mat hij hier gewoon door.
agent.EERSTE_RONDE = True
herstart = ronde()["tls"]["self_check"]
u.check("de eerste ronde na een herstart meet niet",
herstart.get("state") == "pending", "gaf %r" % herstart)
u.check("en zonder `at`, zodat de volgende ronde wél meet",
"at" not in herstart, "gaf %r" % herstart)
derde = ronde()["tls"]["self_check"]
u.check("de ronde daarna meet wel",
derde.get("state") == "ok", "gaf %r" % derde)
# De tweede helft: een mislukking mag niet vijf minuten blijven staan.
# De opgeslagen meting met de hand op 'failed' zetten, 90 seconden oud:
# dat is meer dan SELF_CHECK_RETRY en minder dan SELF_CHECK_INTERVAL.
def zet_vorige(state, ouderdom):
with open(agent.STATUS_FILE, "r", encoding="utf-8") as f:
status = json.load(f)
status["tls"]["self_check"] = {
"at": int(time.time()) - ouderdom, "state": state,
}
with open(agent.STATUS_FILE, "w", encoding="utf-8") as f:
json.dump(status, f)
zet_vorige("failed", 90)
na_fout = ronde()["tls"]["self_check"]
u.check("na een mislukking wordt er binnen een minuut opnieuw gemeten",
na_fout.get("state") == "ok", "gaf %r" % na_fout)
# En de tegenproef, want anders toetst het bovenstaande alleen dat er
# ooit gemeten wordt: een GESLAAGDE meting van dezelfde ouderdom hoort
# juist te blijven staan.
zet_vorige("ok", 90)
na_goed = ronde()["tls"]["self_check"]
u.check("een geslaagde meting van 90 seconden oud blijft staan",
na_goed.get("at") == int(time.time()) - 90,
"gaf %r" % na_goed)
finally:
server.close()
def main():
u = Uitslag()
agent = load_agent()
test_zonder_certificaat_wordt_er_niet_verbonden(agent, u)
test_niemand_luistert(agent, u)
test_luisteren_is_niet_genoeg(agent, u)
test_gelukkig_pad_en_verkeerd_certificaat(agent, u)
test_eigen_sessies_uit_het_log(agent, u)
# Als laatste, want deze verzet de module-instellingen naar een tijdelijke map.
test_ronde_meet_niet_elke_keer(agent, u)
test_eerste_ronde_en_snelle_herhaling(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+546
View File
@@ -0,0 +1,546 @@
"""Toetst de vorm die umbrelOS van deze app store eist, voor élke app erin.
Waarom deze test bestaat. De twee bestaande testbestanden gaan over Electrum
Gate; ze noemen die app-map bij naam. Sinds 25-08-2026 zit er een tweede app in
deze repo en zijn er dus regels die niet over één app gaan maar over alle:
- het `id` in `umbrel-app.yml` moet gelijk zijn aan de mapnaam;
- dat id moet beginnen met het store-id uit `umbrel-app-store.yml`;
- de manifestvelden staan in de volgorde die de packaging-documentatie
voorschrijft;
- er is een `docker-compose.yml`, en de `app_proxy` daarin wijst naar een
service die in datzelfde bestand bestaat.
Dat zijn precies de fouten die je op het apparaat pas merkt: de app verschijnt
niet in de store, of hij verschijnt en start niet. Er komt geen nette
foutmelding, want de runtime-validatie van het manifest staat in umbreld
uitgecommentarieerd.
Wat deze toetsen expres NIET doen: eisen dat elke image op een digest gepind is.
Dat is wél de regel, maar op 25-08-2026 haalt geen van de twee apps hem, en een
suite die altijd rood staat wordt niet gelezen. Het staat als taak in de plannen
Umbrelapp en Publicatie-Gate. Wat hier wel gebeurt is de pinstatus afdrukken, zodat
je hem ziet zonder erover te struikelen. Wat wél getoetst wordt, sinds 07-09-2026:
dat de tag van een eigen image gelijk is aan VERSION in het bijbehorende
tools/<app>/build.sh, want een tag die nooit gebouwd is faalt pas op het apparaat.
Deze toetsen vinden hun apps zelf. Komt er een derde app bij, dan valt die
automatisch onder alles hierboven en hoeft hier niets bij.
Draaien:
python tests/test_appstore_vorm.py
"""
import sys
sys.dont_write_bytecode = True
import os # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
STORE = os.path.join(REPO, "umbrel-app-store.yml")
# De volgorde uit de packaging-documentatie. Wat de spec niet noemt (icon,
# backupIgnore, submitter, submission) mag erachter, niet ertussen.
VOORGESCHREVEN = [
"manifestVersion", "id", "category", "name", "version", "tagline",
"description", "releaseNotes", "developer", "website", "dependencies",
"repo", "support", "port", "gallery", "path",
]
# Velden die er per se moeten staan, ook al is het schema soepeler: zonder deze
# is de winkelpagina leeg of start de app niet.
VERPLICHT = ["manifestVersion", "id", "category", "name", "version", "tagline",
"description", "port"]
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def lees(pad):
with open(pad, "r", encoding="utf-8") as f:
return f.read()
def veld(tekst, naam):
"""De waarde van een veld op het eerste niveau, of None.
Geen YAML-lezer: de suite heeft geen afhankelijkheden en dat is een
projectregel. Daarom alleen velden die aan het begin van een regel staan.
"""
for regel in tekst.splitlines():
if regel.startswith(naam + ":"):
return regel.split(":", 1)[1].strip().strip('"').strip("'")
return None
def velden_in_volgorde(tekst):
"""De veldnamen op het eerste niveau, in de volgorde waarin ze staan."""
namen = []
for regel in tekst.splitlines():
if not regel or regel[0] in " \t#-":
continue
if ":" not in regel:
continue
namen.append(regel.split(":", 1)[0].strip())
return namen
def app_mappen():
"""Elke map in de repo-root met een umbrel-app.yml erin."""
gevonden = []
for naam in sorted(os.listdir(REPO)):
pad = os.path.join(REPO, naam)
if not os.path.isdir(pad):
continue
if os.path.isfile(os.path.join(pad, "umbrel-app.yml")):
gevonden.append(naam)
return gevonden
def test_er_is_een_store_met_apps(u, store_id, apps):
u.check("umbrel-app-store.yml heeft een id", bool(store_id))
u.check("en er is minstens een app-map gevonden", bool(apps),
"geen enkele map met een umbrel-app.yml")
def test_id_en_mapnaam(u, store_id, app):
"""Mapnaam == id == store-prefix + rest. Alle drie of de app bestaat niet."""
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
app_id = veld(tekst, "id")
u.check("%s: het manifest heeft een id" % app, bool(app_id))
if not app_id:
return
u.check("%s: id is gelijk aan de mapnaam" % app, app_id == app,
"manifest zegt %r" % app_id)
u.check("%s: id begint met het store-id %r" % (app, store_id),
app_id.startswith(store_id + "-"),
"id is %r" % app_id)
u.check("%s: id is lowercase kebab-case" % app,
app_id == app_id.lower() and " " not in app_id and "_" not in app_id,
"id is %r" % app_id)
def test_verplichte_velden(u, app):
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
ontbreekt = [naam for naam in VERPLICHT if veld(tekst, naam) is None]
u.check("%s: alle verplichte manifestvelden staan erin" % app,
not ontbreekt, "ontbreekt: %r" % ontbreekt)
def test_manifest_volgorde(u, app):
"""De voorgeschreven velden in de voorgeschreven onderlinge orde.
Niet dat ze er alle zestien zijn: `dependencies` hoort weg te blijven bij een
app zonder afhankelijkheden. Wel dat wat er staat niet door elkaar loopt, want
dat is de eis waar een review op valt.
"""
aanwezig = velden_in_volgorde(lees(os.path.join(REPO, app, "umbrel-app.yml")))
volgens_spec = [n for n in aanwezig if n in VOORGESCHREVEN]
verwacht = [n for n in VOORGESCHREVEN if n in volgens_spec]
u.check("%s: de manifestvelden staan in de voorgeschreven volgorde" % app,
volgens_spec == verwacht,
"gevonden %r, verwacht %r" % (volgens_spec, verwacht))
# Wat de spec niet noemt hoort erachter, niet ertussen. Anders is de kop niet
# letterlijk goed en moet er bij inlevering geschoven worden.
laatste_spec = -1
for i, naam in enumerate(aanwezig):
if naam in VOORGESCHREVEN:
laatste_spec = i
extra_ertussen = [n for n in aanwezig[:laatste_spec] if n not in VOORGESCHREVEN]
u.check("%s: velden buiten de spec staan achteraan" % app,
not extra_ertussen, "ertussen: %r" % extra_ertussen)
def recept_map(app):
"""Waar het bouwrecept van een app staat: tools/<app-id zonder store-id>/.
Dat is de afspraak uit Werkafspraken.md: een Dockerfile hoort niet in de app-map,
want die staat niet in de update-whitelist. De mapnaam is het app-id zonder
het store-voorvoegsel, dus whatsnext-evolu-relay bouwt in tools/evolu-relay/.
"""
kort = app.split("-", 1)[1] if "-" in app else app
return os.path.join(REPO, "tools", kort)
def test_image_tag_is_de_gebouwde(u, app):
"""De tag in de compose is VERSION uit build.sh, voor elke eigen image.
Een tag die in de compose staat maar nooit gebouwd is, faalt pas op het
apparaat met "pull access denied". En andersom, een VERSION die verhoogd is
zonder de compose mee te nemen, levert een nieuwe image en een oude
installatie zonder dat iets dat meldt. Beide staan als regel in Werkafspraken.md;
dit is de toets erop.
Alleen images uit het eigen recept: een vreemde image (postgres, nginx) heeft
geen build.sh en valt hier buiten.
"""
build = os.path.join(recept_map(app), "build.sh")
if not os.path.isfile(build):
return
versie = None
for regel in lees(build).splitlines():
if regel.startswith("VERSION="):
versie = regel.split("=", 1)[1].strip().strip('"')
u.check("%s: build.sh heeft een VERSION" % app, bool(versie))
if not versie:
return
kort = os.path.basename(recept_map(app))
compose = lees(os.path.join(REPO, app, "docker-compose.yml"))
eigen = []
for regel in compose.splitlines():
kaal = regel.strip()
if not kaal.startswith("image:"):
continue
image = kaal.split(":", 1)[1].strip()
repo_en_tag = image.split("@", 1)[0]
repo_naam = repo_en_tag.rsplit(":", 1)[0]
if repo_naam.endswith("/" + kort):
eigen.append(repo_en_tag)
u.check("%s: de compose gebruikt de eigen image" % app, bool(eigen),
"geen image op .../%s gevonden" % kort)
verkeerd = [i for i in eigen if not i.endswith(":" + versie)]
u.check("%s: elke eigen image heeft tag %s uit build.sh" % (app, versie),
not verkeerd, "gevonden: %r" % verkeerd)
def test_compose_bestaat_en_hangt_samen(u, app):
"""De app_proxy moet naar een service wijzen die bestaat.
Dit is een echte klasse fouten en niet een formaliteit: de hostnaam is
`<app-id>_<service>_1`, dus hij bevat het app-id én de servicenaam. Verandert
een van de twee, dan wijst de proxy naar niets en meldt umbrelOS alleen dat de
server onbereikbaar is. Precies het symptoom dat bij Electrum Gate 0.0.3 een
dag kostte.
"""
pad = os.path.join(REPO, app, "docker-compose.yml")
u.check("%s: er is een docker-compose.yml" % app, os.path.isfile(pad))
if not os.path.isfile(pad):
return
tekst = lees(pad)
# Servicenamen: twee spaties diep onder services:, eindigend op een dubbele
# punt. Geen YAML-lezer, zie veld().
services = []
in_services = False
for regel in tekst.splitlines():
if regel.startswith("services:"):
in_services = True
continue
if in_services and regel and not regel[0].isspace():
break
if in_services and regel.startswith(" ") and not regel.startswith(" "):
kaal = regel.strip()
if kaal.endswith(":") and not kaal.startswith("#"):
services.append(kaal[:-1])
u.check("%s: de compose heeft een app_proxy" % app,
"app_proxy" in services, "services: %r" % services)
app_host = None
for regel in tekst.splitlines():
if regel.strip().startswith("APP_HOST:"):
app_host = regel.split(":", 1)[1].strip()
break
u.check("%s: de app_proxy heeft een APP_HOST" % app, bool(app_host))
if not app_host:
return
u.check("%s: APP_HOST begint met het app-id" % app,
app_host.startswith(app + "_"),
"APP_HOST is %r maar de map heet %r" % (app_host, app))
# De vorm is <app-id>_<service>_1; haal de servicenaam eruit en kijk of die
# bestaat.
rest = app_host[len(app) + 1:]
u.check("%s: APP_HOST eindigt op _1" % app, rest.endswith("_1"),
"APP_HOST is %r" % app_host)
service = rest[:-2] if rest.endswith("_1") else rest
u.check("%s: APP_HOST wijst naar een service die bestaat" % app,
service in services,
"wijst naar %r, aanwezig: %r" % (service, services))
def test_data_onder_data(u, app):
"""Wat de app bewaart staat onder data/, en er is een .gitkeep per map.
De appstore-eis is dat gebruikersstaat onder ${APP_DATA_DIR}/data/... valt en
dat elke map die bij de eerste start moet bestaan in de repo staat. Een mount
naar een map die er niet is, maakt Docker aan als root, en dan kan de app er
niet in schrijven.
"""
pad = os.path.join(REPO, app, "docker-compose.yml")
if not os.path.isfile(pad):
return
tekst = lees(pad)
buiten = []
ontbreekt = []
for regel in tekst.splitlines():
kaal = regel.strip()
if not kaal.startswith("- ${APP_DATA_DIR}/"):
continue
pad_in_app = kaal[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]
# Losse bestanden die uit een *.template komen zijn geen gebruikersstaat.
if "/" not in pad_in_app:
continue
if not pad_in_app.startswith("data/"):
buiten.append(pad_in_app)
continue
op_schijf = os.path.join(REPO, app, pad_in_app)
if not os.path.exists(op_schijf):
ontbreekt.append(pad_in_app)
elif os.path.isdir(op_schijf) and not os.path.isfile(
os.path.join(op_schijf, ".gitkeep")):
ontbreekt.append(pad_in_app + "/.gitkeep")
u.check("%s: alle mounts met een pad staan onder data/" % app,
not buiten, "erbuiten: %r" % buiten)
u.check("%s: elke gemounte map bestaat in de repo" % app,
not ontbreekt, "ontbreekt: %r" % ontbreekt)
def test_containernamen_zijn_volledig(u, app):
"""Verwijzingen tussen containers gebruiken de volledige naam.
Alle apps van umbrelOS delen één Docker-netwerk, dus een servicenaam als
'agent' of 'server' is daar niet uniek. Heeft een tweede app dezelfde naam,
dan verdeelt Docker de naam over beide containers en komt ongeveer de helft
van het verkeer bij de verkeerde app uit.
Dat is geen theorie: op 28-08-2026 gaf de statuspagina van Evolu Relay
afwisselend goede gegevens en een 404, omdat Electrum Gate óók een 'agent' op
poort 8000 heeft. Van tien verzoeken kwamen er vijf verkeerd uit. Het maakte
bovendien de pagina van Electrum Gate kapot, een app die tot dat moment werkte.
De vorm <app-id>_<service>_1 is wél uniek. Deze toets kijkt naar de plekken
waar een naam als hostnaam wordt gebruikt: proxy_pass in een nginx-config, en
de omgevingsvariabelen die een hostnaam dragen. Een nginx-config kan in de
app-map staan (als *.template) of in het bouwrecept onder tools/ (als hij in
een eigen image zit); beide worden gelezen.
"""
fout = []
bestanden = []
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
if bestandsnaam.endswith(".template") or bestandsnaam == "docker-compose.yml":
bestanden.append(os.path.join(REPO, app, bestandsnaam))
recept = recept_map(app)
if os.path.isdir(recept):
for bestandsnaam in sorted(os.listdir(recept)):
if bestandsnaam.endswith(".conf"):
bestanden.append(os.path.join(recept, bestandsnaam))
for pad in bestanden:
bestandsnaam = os.path.relpath(pad, REPO).replace(os.sep, "/")
tekst = lees(pad)
for regel in tekst.splitlines():
kaal = regel.strip()
if kaal.startswith("#"):
continue
naam = None
if kaal.startswith("proxy_pass http://"):
naam = kaal[len("proxy_pass http://"):].split(":", 1)[0].split("/", 1)[0]
elif "_HOST:" in kaal and not kaal.startswith("- "):
waarde = kaal.split(":", 1)[1].strip().strip('"')
# Een variabele van umbrelOS of een IP-adres is geen containernaam.
if waarde and not waarde.startswith("${") and not waarde[0].isdigit():
naam = waarde
if naam is None or naam in ("localhost", "127.0.0.1"):
continue
if not naam.startswith(app + "_"):
fout.append("%s: %s" % (bestandsnaam, naam))
u.check("%s: verwijzingen naar containers gebruiken de volledige naam" % app,
not fout, "korte namen: %r" % fout)
def test_geen_werkbestanden_in_de_app_map(u, app):
"""In een app-map staat alleen wat umbreld nodig heeft.
umbreld kopieert bij een installatie de héle app-map naar
`~/umbrel/app-data/<app-id>/` met `rsync --archive`. Alles wat daar staat
belandt dus op het apparaat en in de back-up: een CLAUDE.md, een gelaagd
bewerkbestand van een icoon, een testscript, een aantekening.
Dat is geen theorie. Op 30-08-2026 stond er een `icon.pdn` van 175 kB in de map
van Evolu Relay, een Paint.NET-bestand van een icoon dat nog niet af was. Het
apparaat kan er niets mee en het maakt elke back-up groter. Verplaatst naar
`tools/icons/`; deze toets is wat voorkomt dat de volgende terugkomt.
De lijst hieronder is een whitelist en geen blacklist, en dat is met opzet: bij
een nieuw soort bestand hoort iemand na te denken of het daar hoort, en een
blacklist stelt die vraag nooit.
"""
TOEGESTAAN_EXACT = {
"umbrel-app.yml", "docker-compose.yml", "exports.sh", "torrc",
"icon.png", "icon.svg", ".gitkeep",
}
TOEGESTAANE_MAPPEN = {"data", "hooks"}
fout = []
for naam in sorted(os.listdir(os.path.join(REPO, app))):
pad = os.path.join(REPO, app, naam)
if os.path.isdir(pad):
# __pycache__ is niet gecommit (het staat in .gitignore) en komt dus
# nooit in de kloon die umbreld ophaalt. Het staat er in een werkboom
# zodra een test de agent-template importeert, en daarover klagen zou
# de toets rood zetten op iets dat het apparaat niet bereikt.
if naam == "__pycache__":
continue
if naam not in TOEGESTAANE_MAPPEN:
fout.append(naam + "/")
continue
if naam in TOEGESTAAN_EXACT:
continue
# Alles wat umbreld bij een update ververst is een template, en die horen
# er dus per definitie.
if naam.endswith(".template"):
continue
fout.append(naam)
u.check("%s: er staan geen werkbestanden in de app-map" % app,
not fout,
"deze horen buiten de app-map: %r" % fout)
# De variabelen die umbrelOS werkelijk invult. Alleen deze mogen in een template
# staan; zie test_templates_hebben_geen_losse_dollars. Overgenomen uit de tabel in
# HomeGit/Docs/Referenties/UmbrelApps/Umbrel-appstore-spec.md §3.
UMBREL_VARIABELEN = {
"APP_ID", "APP_VERSION", "APP_DATA_DIR", "APP_MANIFEST_FILE", "UMBREL_ROOT",
"DEVICE_HOSTNAME", "DEVICE_DOMAIN_NAME", "APP_DOMAIN",
"APP_PROXY_HOSTNAME", "APP_PROXY_PORT", "NETWORK_IP",
"TOR_PROXY_IP", "TOR_PROXY_PORT", "TOR_DATA_DIR",
"APP_HIDDEN_SERVICE", "APP_SEED", "APP_PASSWORD",
}
# Een app krijgt daarnaast de exports van zijn afhankelijkheden, en die heten
# APP_<AFHANKELIJKHEID>_<IETS>. Electrum Gate gebruikt APP_ELECTRS_NODE_IP en
# APP_ELECTRS_NODE_PORT. Die kunnen niet in een vaste lijst staan, want welke er
# zijn hangt af van de afhankelijkheid; vandaar een vorm in plaats van een naam.
EXPORT_VORM = r"^APP_[A-Z0-9]+_[A-Z0-9_]+$"
def test_templates_hebben_geen_losse_dollars(u, app):
"""In een *.template staat geen dollarteken dat umbrelOS niet invult.
Dit is de valstrik van dit hele project, en tot 30-08-2026 beweerde het
commentaar in drie bestanden dat déze test hem dichthield terwijl dat niet zo
was.
Wat er gebeurt: umbreld haalt elke `*.template` bij het starten door envsubst.
Dat vervangt élke accolade-vorm, ook een variabele die niet bestaat, en die
wordt dan leeg. Gevolgen per bestandstype:
- in een pagina sloopt het een JavaScript-template-literal, want die gebruikt
accolades achter een dollarteken. Vandaar dat de pagina's overal strings met
een plus aan elkaar plakken;
- in een nginx-config verdwijnen `host`, `log_format` en elke variabele in een
access_log;
- in Python-code verdwijnt stilzwijgend een stuk code.
Er komt geen foutmelding. Het bestand wordt gewoon anders dan je schreef.
Beide vormen die envsubst kent worden gevlagd: `${NAAM}` en het kale `$NAAM`.
Dat tweede is de vorm waarin een nginx-variabele als `$host` geschreven wordt,
en die is hier dus net zo fout als de eerste.
Wat NIET gevlagd wordt is een los dollarteken zonder naam erachter. Dat laat
envsubst staan, en het staat in dit project in de commentaarregels die deze
regel juist uitleggen: een toets die zijn eigen uitleg rood zet, wordt
uitgezet.
"""
import re
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
if not bestandsnaam.endswith(".template"):
continue
tekst = lees(os.path.join(REPO, app, bestandsnaam))
onbekend = []
for regelnummer, regel in enumerate(tekst.splitlines(), start=1):
for treffer in re.finditer(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", regel):
naam = treffer.group(1)
if naam in UMBREL_VARIABELEN:
continue
if re.match(EXPORT_VORM, naam):
continue
onbekend.append("regel %d: %r" % (regelnummer, treffer.group(0)))
u.check("%s/%s: geen dollartekens die umbrelOS leegmaakt" % (app, bestandsnaam),
not onbekend,
"gevonden: %r" % onbekend[:4])
def rapporteer_pinstatus(apps):
"""Afdrukken, niet toetsen. Zie de uitleg bovenaan dit bestand."""
print()
print("Pinstatus van de images (informatief, geen toets):")
for app in apps:
pad = os.path.join(REPO, app, "docker-compose.yml")
if not os.path.isfile(pad):
continue
for regel in lees(pad).splitlines():
kaal = regel.strip()
if not kaal.startswith("image:"):
continue
image = kaal.split(":", 1)[1].strip()
merk = "gepind" if "@sha256:" in image else "NIET GEPIND"
print(" %-24s %-12s %s" % (app, merk, image))
def main():
u = Uitslag()
store_id = veld(lees(STORE), "id")
apps = app_mappen()
test_er_is_een_store_met_apps(u, store_id, apps)
for app in apps:
test_id_en_mapnaam(u, store_id, app)
test_verplichte_velden(u, app)
test_manifest_volgorde(u, app)
test_compose_bestaat_en_hangt_samen(u, app)
test_image_tag_is_de_gebouwde(u, app)
test_data_onder_data(u, app)
test_containernamen_zijn_volledig(u, app)
test_geen_werkbestanden_in_de_app_map(u, app)
test_templates_hebben_geen_losse_dollars(u, app)
rapporteer_pinstatus(apps)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+201
View File
@@ -0,0 +1,201 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst de testcliënt zonder relay: tools/relay-client/src/client.js
//
// Draaien: node tests/test_client_lokaal.mjs
//
// Anders dan de rest van deze map heeft deze toets wél afhankelijkheden nodig,
// namelijk die van tools/relay-client. Staat `node_modules` daar niet, dan slaat
// hij zichzelf over en meldt dat, zoals de certificaattoetsen doen als de
// certificaatwinkel van het besturingssysteem ontbreekt. Installeren gaat met
// `npm install` in tools/relay-client.
//
// Waarom dit bestand er is: `@evolu/nodejs` bevat geen cliënt, dus de
// afhankelijkheden voor `createEvolu` worden in src/evolu-node.js zelf
// samengesteld. Dat is de plek die bij een versieverhoging van Evolu als eerste
// omvalt, en hij faalt stil: alle vier de fouten die dat bestand tijdens de bouw
// opleverde gaven een instantie die het lijkt te doen, tot de eerste
// schrijfactie. **Deze toets doet dus een schrijfactie**, en aanmaken alleen is
// geen bewijs.
//
// Geen relay: `openStore` zonder `relayUrl`. Wat hier getoetst wordt is de
// cliënt, en die is los van het netwerk te beproeven. Het toegangsbeleid van de
// relay staat in test_limiter.mjs.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Een wachthond, en die is hier niet voor de sier. Gaat er iets mis in de
// samenstelling, dan komt dat er meestal uit als een schrijfactie die nooit
// antwoordt in plaats van een fout: het defect zit in een worker en de
// `loadQuery` erna blijft hangen. Zonder deze wachthond blijft de suite dus
// staan in plaats van rood te worden, en dat is bij de mutatietoets van
// 09-09-2026 letterlijk gebeurd.
const WACHTHOND_SECONDEN = 30;
setTimeout(() => {
console.log(`FOUT: niets meer gehoord na ${WACHTHOND_SECONDEN} seconden.`);
console.log(' Dat wijst op een defect in een worker; zie tools/relay-client/src/evolu-node.js.');
console.log('');
console.log('afgebroken');
process.exit(1);
}, WACHTHOND_SECONDEN * 1000).unref();
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
// ── Zijn de afhankelijkheden er? ──────────────────────────────────────────────
let client;
try {
client = await import('../tools/relay-client/src/client.js');
} catch (oorzaak) {
console.log('OVERGESLAGEN: de pakketten van tools/relay-client ontbreken.');
console.log(` Draai daar eerst \`npm install\`. (${oorzaak.message})`);
console.log('');
console.log('0 goed, 0 fout');
process.exit(0);
}
const { eigenaarUitMnemonic, nieuweEigenaar, openStore } = client;
// ── Een mnemonic levert altijd hetzelfde OwnerId op ───────────────────────────
{
// Dit is de toets die omvalt als Evolu onder ons iets anders gaat doen met de
// afleiding van een eigenaar. Vaste mnemonic, vast antwoord. Het is geen
// geheim: deze eigenaar bestaat alleen in deze toets, en het is bovendien de
// bekendste testmnemonic die er is.
const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const eerste = eigenaarUitMnemonic(MNEMONIC);
const tweede = eigenaarUitMnemonic(MNEMONIC);
toets('dezelfde mnemonic geeft hetzelfde OwnerId', eerste.id === tweede.id);
toets('en een OwnerId is een niet-lege string', typeof eerste.id === 'string' && eerste.id.length > 0);
toets('de eigenaar houdt zijn mnemonic bij zich', eerste.mnemonic === MNEMONIC);
toets('een verse eigenaar krijgt een ander OwnerId', nieuweEigenaar().id !== eerste.id);
let geweigerd = false;
try {
eigenaarUitMnemonic('dit is geen mnemonic');
} catch {
geweigerd = true;
}
// Het niet-gelukkige pad, en dat is hier het belangrijkste: stil een andere
// eigenaar maken zou een proef opleveren die nergens over gaat.
toets('een onleesbare mnemonic wordt geweigerd', geweigerd);
}
// ── Schrijven en teruglezen, zonder relay ─────────────────────────────────────
const map = mkdtempSync(join(tmpdir(), 'relay-client-toets-'));
let store = null;
try {
const defecten = [];
// Vastgehouden, want verderop wordt met dezelfde eigenaar heropend.
const eigenaar = nieuweEigenaar();
store = await openStore({
directory: map,
owner: eigenaar,
onDefect: (defect) => {
defecten.push(defect);
},
});
toets('zonder relayUrl is er geen transport', store.transportUrl === null);
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
const geschreven = await store.schrijf('toets', bytes);
toets('een blob wegschrijven geeft een id terug', typeof geschreven?.id === 'string');
const rijen = await store.lees();
toets('en die blob komt er weer uit', rijen.length === 1);
toets('met hetzelfde label', rijen[0]?.label === 'toets');
toets(
'en met de bytes ongeschonden',
rijen[0]?.body?.length === bytes.length
&& [...(rijen[0]?.body ?? [])].every((waarde, plek) => waarde === bytes[plek]),
);
const sluiten = store.sluit;
store = null;
await sluiten();
// Opnieuw openen met dezelfde eigenaar en dezelfde map. Dit is de toets die
// een echte fout van 09-09-2026 vasthoudt: `evolu.insert` zet de schrijfactie
// in de wachtrij van de database-worker en geeft meteen een id terug, dus wie
// vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die "nog
// geen blobs" meldde over een blob die zojuist bevestigd was, en dát is precies
// het soort fout dat een toets binnen één instantie niet vindt.
store = await openStore({ directory: map, owner: eigenaar });
const naHeropenen = await store.lees();
toets('de blob overleeft het afsluiten en heropenen', naHeropenen.length === 1);
toets('met hetzelfde label', naHeropenen[0]?.label === 'toets');
const nogmaalsSluiten = store.sluit;
store = null;
await nogmaalsSluiten();
// ── Twee eigenaars tegelijk ────────────────────────────────────────────────
//
// Dit is de toets voor de fout die de gebruiker op 09-09-2026 vond, en het is
// een fout die met één eigenaar onzichtbaar is. `createEvoluDeps` vraagt het
// slot "tab" aan en kondigt de houder aan als tab leader; dat slot kan er maar
// één hebben. Werd dat per eigenaar aangeroepen, dan werd de tweede nooit
// leider en brak zijn database-worker af met "Expected tab leader port.".
const eersteVanTwee = await openStore({
directory: map,
owner: nieuweEigenaar(),
onDefect: (defect) => defecten.push(defect),
});
const tweedeVanTwee = await openStore({
directory: map,
owner: nieuweEigenaar(),
onDefect: (defect) => defecten.push(defect),
});
try {
await eersteVanTwee.schrijf('van de eerste', new Uint8Array([1]));
await tweedeVanTwee.schrijf('van de tweede', new Uint8Array([2, 2]));
const vanEerste = await eersteVanTwee.lees();
const vanTweede = await tweedeVanTwee.lees();
toets('twee eigenaars kunnen tegelijk schrijven', vanEerste.length === 1 && vanTweede.length === 1);
// En ze zien elkaars gegevens niet, want dat is de hele belofte van een
// eigenaar. Zou dat wél zo zijn, dan deelden ze een database.
toets('en ze zien elkaars blobs niet', vanEerste[0]?.label === 'van de eerste' && vanTweede[0]?.label === 'van de tweede');
// De eerste sluiten mag de tweede niet slopen: de afhankelijkheden zijn
// gedeeld, dus ze gaan pas weg als de laatste store dicht is.
await eersteVanTwee.sluit();
const naSluiten = await tweedeVanTwee.lees();
toets('de tweede blijft werken als de eerste sluit', naSluiten.length === 1);
} finally {
await tweedeVanTwee.sluit();
}
// Afsluiten hoort stil te zijn. Ging dat mis, dan bleef er een worker draaien
// die zijn eigen run al kwijt was, en dat merk je pas als een proces blijft
// hangen. Dat was de vierde valstrik uit PLAN.md §4e.
toets(`afsluiten levert geen defect op (${defecten.length} gezien)`, defecten.length === 0);
} finally {
if (store) await store.sluit();
rmSync(map, { recursive: true, force: true });
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+243
View File
@@ -0,0 +1,243 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het register, de opdrachtregel en de instellingen van de testclient.
//
// Draaien: node tests/test_client_register.mjs
//
// Géén afhankelijkheden nodig, anders dan test_client_lokaal.mjs: register.js,
// argumenten.js en config.js raken Evolu niet aan. Dat is met opzet zo gebouwd
// en het is dezelfde scheiding die policy.js aan de relay-kant heeft: alles wat
// te toetsen valt zonder netwerk, staat los van alles wat dat niet is.
//
// Waar dit op let: het register bevat mnemonics. De duurste fout zit hier niet
// in een berekening maar in stil overschrijven of stil vervangen, en dat is dus
// wat hieronder het meeste aandacht krijgt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isBruikbareNaam, normaliseer, openRegister } from '../tools/relay-client/src/register.js';
import { leesArgumenten, OPDRACHTEN } from '../tools/relay-client/src/argumenten.js';
import { leesConfig, leesEnvBestand, naarRelayUrl } from '../tools/relay-client/src/config.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const gooit = (aan) => {
try {
aan();
return false;
} catch {
return true;
}
};
const verseMap = () => mkdtempSync(join(tmpdir(), 'relay-client-register-'));
const mappen = [];
const map = () => {
const nieuw = verseMap();
mappen.push(nieuw);
return nieuw;
};
// ── Namen ─────────────────────────────────────────────────────────────────────
{
for (const naam of ['a', 'proef-1', 'trezor_mac', 'A'.repeat(40)]) {
toets(`"${naam}" mag als naam`, isBruikbareNaam(naam));
}
for (const naam of ['', ' ', 'met spatie', '../ontsnapt', 'pad/naam', 'A'.repeat(41), null, 42]) {
toets(`${JSON.stringify(naam)} mag niet als naam`, !isBruikbareNaam(naam));
}
}
// ── Aanmaken, teruglezen, weggooien ───────────────────────────────────────────
{
const waar = map();
const register = openRegister(waar);
toets('een vers register is leeg', register.lijst().length === 0);
toets('en een onbekende naam geeft null', register.zoek('piet') === null);
const toegevoegd = register.voegToe({ naam: 'piet', mnemonic: 'woord ' .repeat(11) + 'woord', ownerId: 'AAA' });
toets('toevoegen geeft de regel terug', toegevoegd.naam === 'piet' && toegevoegd.ownerId === 'AAA');
toets('met een tijdstip erbij', typeof toegevoegd.aangemaakt === 'string');
toets('en hij staat in de lijst', register.lijst().length === 1);
toets('en is op naam te vinden', register.zoek('piet')?.ownerId === 'AAA');
// Het niet-gelukkige pad, en het belangrijkste van dit bestand: overschrijven
// zou de mnemonic van de vorige eigenaar weggooien.
toets(
'dezelfde naam nog eens toevoegen wordt geweigerd',
gooit(() => register.voegToe({ naam: 'piet', mnemonic: 'iets anders', ownerId: 'BBB' })),
);
toets('en de eerste staat er nog', register.zoek('piet')?.ownerId === 'AAA');
toets(
'een onbruikbare naam wordt geweigerd',
gooit(() => register.voegToe({ naam: 'met spatie', mnemonic: 'x', ownerId: 'CCC' })),
);
toets('vergeten van een onbekende naam wordt geweigerd', gooit(() => register.vergeet('jan')));
const weg = register.vergeet('piet');
toets('vergeten geeft de regel terug', weg.ownerId === 'AAA');
toets('en daarna is het register leeg', register.lijst().length === 0);
}
// ── Wat er op schijf komt, komt er weer af ────────────────────────────────────
{
const waar = map();
const eerste = openRegister(waar);
eerste.voegToe({ naam: 'een', mnemonic: 'mnemonic een', ownerId: 'ID1' });
eerste.voegToe({ naam: 'twee', mnemonic: 'mnemonic twee', ownerId: 'ID2' });
const tweede = openRegister(waar);
toets('een tweede register leest hetzelfde bestand', tweede.lijst().length === 2);
toets('in dezelfde volgorde', tweede.lijst()[0].naam === 'een' && tweede.lijst()[1].naam === 'twee');
toets('met de mnemonic erbij', tweede.zoek('twee')?.mnemonic === 'mnemonic twee');
// Een register dat je aanpast mag geen lijst teruggeven die meeverandert:
// dan zou een aanroeper per ongeluk in de staat kunnen schrijven.
const kopie = tweede.lijst();
kopie[0].ownerId = 'GEHACKT';
toets('de lijst is een kopie', tweede.zoek('een')?.ownerId === 'ID1');
}
// ── Een onleesbaar bestand wordt niet stil vervangen ──────────────────────────
{
const waar = map();
const register = openRegister(waar);
register.voegToe({ naam: 'een', mnemonic: 'geheim', ownerId: 'ID1' });
const pad = join(waar, 'owners.json');
const origineel = readFileSync(pad, 'utf8');
writeFileSync(pad, '{ dit is geen json', 'utf8');
toets('onleesbare JSON wordt gemeld', gooit(() => openRegister(waar)));
toets('en het bestand blijft staan', readFileSync(pad, 'utf8') === '{ dit is geen json');
writeFileSync(pad, JSON.stringify({ version: 2, owners: [] }), 'utf8');
toets('een onbekende versie wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, JSON.stringify({ version: 1, owners: [{ naam: 'een' }] }), 'utf8');
toets('een halve regel wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, origineel, 'utf8');
toets('en het originele bestand leest weer goed', openRegister(waar).zoek('een')?.ownerId === 'ID1');
}
// ── normaliseer, los ──────────────────────────────────────────────────────────
{
toets('null is geen staat', normaliseer(null) === null);
toets('een array is geen staat', normaliseer([]) === null);
toets('zonder owners is het geen staat', normaliseer({ version: 1 }) === null);
toets(
'twee keer dezelfde naam is geen staat',
normaliseer({
version: 1,
owners: [
{ naam: 'a', mnemonic: 'x', ownerId: '1' },
{ naam: 'a', mnemonic: 'y', ownerId: '2' },
],
}) === null,
);
toets(
'een lege mnemonic is geen staat',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: '', ownerId: '1' }] }) === null,
);
toets(
'een geldige staat komt er heel uit',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: 'x', ownerId: '1' }] })?.owners[0]
?.naam === 'a',
);
}
// ── De opdrachtregel ──────────────────────────────────────────────────────────
{
toets('niets geeft geen opdracht en geen fout', (() => {
const uit = leesArgumenten([]);
return uit.fout === null && uit.opdracht === null;
})());
toets('een onbekende opdracht is een fout', leesArgumenten(['bestaatniet']).fout !== null);
const nieuw = leesArgumenten(['nieuw', 'piet']);
toets('nieuw piet leest goed', nieuw.fout === null && nieuw.waarden.naam === 'piet');
toets('nieuw zonder naam is een fout', leesArgumenten(['nieuw']).fout !== null);
// Streng, en dat is de bedoeling: `schrijf piet mijn label` zou anders stil
// "mijn" als label nemen en je naar een blob laten kijken die anders heet.
toets('een argument te veel is een fout', leesArgumenten(['nieuw', 'piet', 'jan']).fout !== null);
const schrijf = leesArgumenten(['schrijf', 'piet', 'mijn label', '--bytes', '512']);
toets('schrijf leest beide argumenten', schrijf.waarden.naam === 'piet' && schrijf.waarden.label === 'mijn label');
toets('en de vlag ernaast', schrijf.vlaggen.bytes === '512');
toets('--bytes=512 leest ook', leesArgumenten(['lijst', '--bytes=512']).vlaggen.bytes === '512');
toets('een losse vlag is true', leesArgumenten(['lijst', '--lokaal']).vlaggen.lokaal === true);
toets(
'een losse vlag gevolgd door een vlag blijft true',
leesArgumenten(['lijst', '--lokaal', '--bytes=8']).vlaggen.lokaal === true,
);
toets('elke opdracht heeft uitleg', Object.values(OPDRACHTEN).every((v) => typeof v.uitleg === 'string' && v.uitleg.length > 0));
}
// ── De instellingen ───────────────────────────────────────────────────────────
{
const gelezen = leesEnvBestand(
['# commentaar', '', 'RELAY_URL=ws://voorbeeld:3852', 'MET_QUOTES="waarde"', 'ROMMEL', 'LEEG='].join('\n'),
);
toets('een gewone regel leest', gelezen.RELAY_URL === 'ws://voorbeeld:3852');
toets('aanhalingstekens gaan eraf', gelezen.MET_QUOTES === 'waarde');
toets('een regel zonder = wordt overgeslagen', gelezen.ROMMEL === undefined);
toets('een lege waarde is een lege string', gelezen.LEEG === '');
toets('commentaar telt niet mee', Object.keys(gelezen).length === 3);
toets('ws:// blijft', naarRelayUrl('ws://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('wss:// blijft', naarRelayUrl('wss://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('http:// wordt ws://', naarRelayUrl('http://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('https:// wordt wss://', naarRelayUrl('https://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('zonder schema wordt ws://', naarRelayUrl('umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('spaties eromheen doen niets', naarRelayUrl(' umbrel.local:3852 ') === 'ws://umbrel.local:3852');
toets('een leeg adres wordt geweigerd', gooit(() => naarRelayUrl('')));
toets('een onbekend schema wordt geweigerd', gooit(() => naarRelayUrl('ftp://umbrel.local')));
// Een map zonder .env: dan is er geen relay-adres, en dat is geen fout maar een
// toestand die de UI moet kunnen tonen.
const zonder = leesConfig({ env: {}, wortel: map() });
toets('zonder .env is er geen relay-adres', zonder.relayUrl === null);
toets('en staat de UI-poort op de standaard', zonder.uiPoort === 4380);
const wortel = map();
writeFileSync(join(wortel, '.env'), 'RELAY_URL=umbrel.local:3852\nUI_PORT=5000\n', 'utf8');
const met = leesConfig({ env: {}, wortel });
toets('een .env wordt gelezen', met.relayUrl === 'ws://umbrel.local:3852');
toets('en de poort ook', met.uiPoort === 5000);
const overstemd = leesConfig({ env: { RELAY_URL: 'ws://anders:1234' }, wortel });
toets('de omgeving wint van het bestand', overstemd.relayUrl === 'ws://anders:1234');
}
for (const weg of mappen) rmSync(weg, { recursive: true, force: true });
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+267
View File
@@ -0,0 +1,267 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het toegangsbeleid van de relay: tools/evolu-relay/src/policy.js
//
// Draaien: node tests/test_limiter.mjs
//
// Geen testrunner en geen afhankelijkheden, net als de Python-tests in deze map.
// Geeft 0 terug als alles goed is en 1 bij de eerste fout in de telling.
//
// Waarom dit bestand er is: dit beleid is de enige plek die bepaalt wie er op de
// relay mag schrijven. De duurste fout zit hier niet in een berekening maar in
// een guard, en dat is precies wat hieronder de meeste aandacht krijgt.
//
// **Sinds 0.7.0 is er één weg naar binnen: de wachtlijst.** De getimede leerstand
// is eruit. Twee toetsen hieronder bewaken dat die niet stiekem terugkomt: een
// onbekende eigenaar wordt geweigerd ook als er `learning: true` in het
// staatsbestand staat, en `set-learning` is een onbekende actie.
// ═══════════════════════════════════════════════════════════════════════════════
import {
MAX_OWNER_ID_LENGTH,
MAX_REJECTED,
applyCommand,
createEmptyState,
decideOwner,
normalizeState,
} from '../tools/evolu-relay/src/policy.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const NU = '2026-09-09T12:00:00.000Z';
const LATER = '2026-09-09T13:00:00.000Z';
const EIGEN = 'owner-van-de-gebruiker';
const VREEMD = 'owner-van-een-ander';
/** Een staat met deze eigenaar erin, toegelaten of niet. */
const metEigenaar = (id, allowed, extra = {}) => ({
...createEmptyState(),
owners: [{ id, allowed, firstSeen: NU, lastSeen: NU, ...extra }],
});
// ── Een verse installatie laat niemand binnen ─────────────────────────────────
{
const leeg = createEmptyState();
toets('een verse staat heeft geen eigenaars', leeg.owners.length === 0);
toets('en een lege wachtlijst', leeg.rejected.length === 0);
toets('en geen leerstand meer', !('learning' in leeg) && !('learningUntil' in leeg));
const uitkomst = decideOwner(leeg, VREEMD, NU);
toets('een onbekende eigenaar wordt geweigerd', uitkomst.allowed === false);
toets('met reden "waiting"', uitkomst.reason === 'waiting');
toets('en belandt op de wachtlijst', uitkomst.state.rejected.length === 1);
toets('met zijn id erbij', uitkomst.state.rejected[0].id === VREEMD);
toets('en één poging', uitkomst.state.rejected[0].attempts === 1);
toets('en de staat is veranderd', uitkomst.changed === true);
toets('en hij staat niet stiekem bij de eigenaars', uitkomst.state.owners.length === 0);
}
// ── De leerstand is er echt uit ───────────────────────────────────────────────
{
// Dit is de toets die de verwijdering van 0.7.0 vasthoudt. Een staatsbestand
// van 0.6.0 heeft deze velden nog, en ze horen geen enkel effect te hebben.
const metOudeVelden = {
...createEmptyState(),
learning: true,
learningUntil: null,
};
const uitkomst = decideOwner(metOudeVelden, VREEMD, NU);
toets('`learning: true` in het bestand laat niemand meer binnen', uitkomst.allowed === false);
toets('en zet hem op de wachtlijst', uitkomst.state.rejected[0]?.id === VREEMD);
const metVenster = {
...createEmptyState(),
learning: true,
learningUntil: LATER,
};
toets(
'ook een venster dat nog niet verlopen is doet niets',
decideOwner(metVenster, VREEMD, NU).allowed === false,
);
const opdracht = applyCommand(createEmptyState(), { action: 'set-learning', value: true }, NU);
toets('set-learning is een onbekende actie geworden', opdracht.error === 'unknown-action');
toets('en verandert niets', opdracht.changed === false);
}
// ── De guard die er het meest toe doet ────────────────────────────────────────
{
// Een geblokkeerde eigenaar mag er nooit in. Dat was tot 0.6.0 het gevaar dat
// de leerstand hem alsnog toeliet; die is er nu uit, maar de guard blijft de
// belangrijkste regel van dit bestand.
const geblokkeerd = metEigenaar(EIGEN, false);
const uitkomst = decideOwner(geblokkeerd, EIGEN, LATER);
toets('een geblokkeerde eigenaar blijft buiten', uitkomst.allowed === false);
toets('met reden "known-blocked"', uitkomst.reason === 'known-blocked');
toets('en komt niet op de wachtlijst', uitkomst.state.rejected.length === 0);
toets('hij blijft geblokkeerd staan', uitkomst.state.owners[0].allowed === false);
toets('en "laatst gezien" wordt bijgewerkt', uitkomst.state.owners[0].lastSeen === LATER);
const toegelaten = decideOwner(metEigenaar(EIGEN, true), EIGEN, LATER);
toets('een toegelaten eigenaar mag erin', toegelaten.allowed === true);
toets('met reden "known-allowed"', toegelaten.reason === 'known-allowed');
toets('en "laatst gezien" wordt bijgewerkt', toegelaten.state.owners[0].lastSeen === LATER);
const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU);
toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false);
}
// ── Onbruikbare invoer ────────────────────────────────────────────────────────
{
const leeg = createEmptyState();
for (const onzin of ['', null, 42, {}, [], 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
const uitkomst = decideOwner(leeg, onzin, NU);
toets(`${JSON.stringify(onzin)} wordt geweigerd`, uitkomst.allowed === false);
// Onthouden zou hier juist de weg openzetten om het bestand vol te schrijven.
toets(`${JSON.stringify(onzin)} wordt niet onthouden`, uitkomst.changed === false);
toets(`${JSON.stringify(onzin)} geeft reden invalid-owner-id`, uitkomst.reason === 'invalid-owner-id');
}
toets(
`een id van precies ${MAX_OWNER_ID_LENGTH} tekens mag wel`,
decideOwner(leeg, 'x'.repeat(MAX_OWNER_ID_LENGTH), NU).changed === true,
);
}
// ── De wachtlijst loopt niet vol ──────────────────────────────────────────────
{
let staat = createEmptyState();
for (let i = 0; i < MAX_REJECTED + 10; i += 1) {
staat = decideOwner(staat, `vreemde-${i}`, NU).state;
}
toets('de wachtlijst wordt afgekapt', staat.rejected.length === MAX_REJECTED);
// Nieuwste vooraan: een aanvaller die blijft proberen duwt zijn eigen eerdere
// pogingen weg en niet de eigenaar die er net bij kwam.
toets('de nieuwste staat vooraan', staat.rejected[0].id === `vreemde-${MAX_REJECTED + 9}`);
const nogmaals = decideOwner(staat, `vreemde-${MAX_REJECTED + 9}`, LATER);
toets('een tweede poging telt op', nogmaals.state.rejected[0].attempts === 2);
toets('en zet geen tweede regel neer', nogmaals.state.rejected.length === MAX_REJECTED);
toets('en houdt het eerste moment vast', nogmaals.state.rejected[0].firstSeen === NU);
toets('en werkt het laatste bij', nogmaals.state.rejected[0].lastSeen === LATER);
}
// ── Toelaten vanaf de wachtlijst ──────────────────────────────────────────────
{
// De weg die er sinds 0.7.0 als enige is: iemand staat op de wachtlijst en jij
// laat hem toe.
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
const toegelaten = applyCommand(gewacht, { action: 'allow', ownerId: VREEMD }, LATER);
toets('toelaten lukt', toegelaten.error === null);
toets('en hij staat bij de eigenaars', toegelaten.state.owners[0]?.id === VREEMD);
toets('als toegelaten', toegelaten.state.owners[0]?.allowed === true);
// De geschiedenis gaat mee. Ging dat mis, dan meldt de pagina "first seen" op
// het moment van de klik terwijl die eigenaar al twintig minuten aan de deur
// stond, en dat is juist bij een eigenaar die je alsnog toelaat wat je wilt zien.
toets('met het eerste moment van de wachtlijst', toegelaten.state.owners[0]?.firstSeen === NU);
// En dit is de klacht van de gebruiker van 09-09-2026, die met de leerstand mee
// verdween: hij mag niet in beide lijsten tegelijk staan.
toets('en hij is van de wachtlijst af', toegelaten.state.rejected.length === 0);
toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true);
}
// ── De overige opdrachten van de pagina ───────────────────────────────────────
{
const staat = metEigenaar(EIGEN, true);
const geblokkeerd = applyCommand(staat, { action: 'block', ownerId: EIGEN }, LATER);
toets('blokkeren lukt', geblokkeerd.error === null);
toets('en zet allowed op false', geblokkeerd.state.owners[0].allowed === false);
toets(
'blokkeren van een onbekende wordt geweigerd',
applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner',
);
const vergeten = applyCommand(staat, { action: 'forget', ownerId: EIGEN }, LATER);
toets('vergeten lukt', vergeten.error === null);
toets('en de eigenaar is weg', vergeten.state.owners.length === 0);
toets(
'vergeten van een onbekende wordt geweigerd',
applyCommand(staat, { action: 'forget', ownerId: VREEMD }, LATER).error === 'unknown-owner',
);
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
const weg = applyCommand(gewacht, { action: 'forget', ownerId: VREEMD }, LATER);
toets('iemand van de wachtlijst vergeten lukt ook', weg.error === null);
toets('en dan is de wachtlijst leeg', weg.state.rejected.length === 0);
for (const onzin of [null, 42, 'tekst', {}, { action: 'iets' }]) {
const uitkomst = applyCommand(staat, onzin, NU);
toets(`${JSON.stringify(onzin)} als opdracht verandert niets`, uitkomst.changed === false);
toets(`${JSON.stringify(onzin)} als opdracht geeft een fout`, uitkomst.error !== null);
}
for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
toets(
`block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`,
applyCommand(staat, { action: 'block', ownerId: onzin }, NU).error === 'malformed-command',
);
}
}
// ── Wat er van schijf komt ────────────────────────────────────────────────────
{
toets('null is geen staat', normalizeState(null) === null);
toets('een array is geen staat', normalizeState([]) === null);
toets('een verkeerde versie is geen staat', normalizeState({ version: 2, owners: [], rejected: [] }) === null);
toets('zonder owners is het geen staat', normalizeState({ version: 1, rejected: [] }) === null);
toets('zonder rejected is het geen staat', normalizeState({ version: 1, owners: [] }) === null);
// Dit is de toets die de installatie van de gebruiker beschermt: een bestand
// van 0.6.0 heeft `learning` en `learningUntil` en moet gewoon lezen. Zou dat
// afgewezen worden, dan schuift store.js de allowlist opzij en gaat de deur
// dicht voor eigenaars die er al in stonden.
const oud = {
version: 1,
learning: true,
learningUntil: '2026-09-09T12:02:00.000Z',
owners: [{ id: EIGEN, allowed: true, firstSeen: NU, lastSeen: NU }],
rejected: [{ id: VREEMD, firstSeen: NU, lastSeen: NU, attempts: 3 }],
};
const gelezen = normalizeState(oud);
toets('een bestand van 0.6.0 leest gewoon', gelezen !== null);
toets('met de eigenaar erin', gelezen.owners[0].id === EIGEN);
toets('en de wachtlijst erin', gelezen.rejected[0].attempts === 3);
toets('en zonder de oude velden', !('learning' in gelezen) && !('learningUntil' in gelezen));
toets(
'een eigenaar zonder allowed is geen staat',
normalizeState({ version: 1, owners: [{ id: EIGEN }], rejected: [] }) === null,
);
toets(
'een eigenaar met een leeg id is geen staat',
normalizeState({ version: 1, owners: [{ id: '', allowed: true }], rejected: [] }) === null,
);
toets(
'een wachtende zonder id is geen staat',
normalizeState({ version: 1, owners: [], rejected: [{ attempts: 1 }] }) === null,
);
toets(
'een onleesbaar aantal pogingen wordt 1 en geen weigering',
normalizeState({ version: 1, owners: [], rejected: [{ id: VREEMD, attempts: -3 }] })
?.rejected[0].attempts === 1,
);
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+173
View File
@@ -0,0 +1,173 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst dat de JavaScript in de statuspagina's parseert, en dat hij dat ook nog
// doet nadat umbreld de template heeft ingevuld.
//
// Draaien: node tests/test_paginas_parsen.mjs
//
// Waarom dit bestand er is. De pagina's zijn met bijna drieduizend regels het
// grootste deel van deze repo en er staat geen enkele toets op: alles eraan is
// handwerk in een browser. Dat blijft zo voor wat het doet, maar één klasse fouten
// hoeft niet op het apparaat gevonden te worden: een tikfout waardoor het script
// helemaal niet loopt. Het gevolg daarvan is een pagina die stil op "unknown"
// blijft staan, en dat lijkt op een agent die niet antwoordt.
//
// Aanleiding: op 30-08-2026 is de pagina van Evolu Relay in zijn geheel opnieuw
// geschreven, negenhonderd regels in één keer. Zo'n wijziging hoort niet ongetoetst
// naar een apparaat te gaan.
//
// De invulling wordt hier nagedaan en niet alleen de bron gelezen, want umbreld
// haalt elke *.template door envsubst en wat er draait is dus niet wat er staat.
// Dat vindt de gevallen waar het weghalen van een accolade-vorm de syntaxis breekt.
//
// LET OP wat deze helft NIET vindt, want dat is precies de fout die dit project
// vreest: een template-literal als `iets ${x}` wordt na de invulling `iets ` en dat
// parseert prima. Hij is dan stil kapot en niet luidruchtig. Wat dát dichthoudt is
// de dollarteken-toets in tests/test_appstore_vorm.py, die élke accolade-vorm
// vlagt die umbrelOS niet zelf invult. Deze toets is de tweede zeef en niet de
// eerste.
//
// Wat dit verder NIET toetst: of het script het juiste doet. Alleen dat het loopt.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const HIER = dirname(fileURLToPath(import.meta.url));
const REPO = join(HIER, '..');
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde, uitleg) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}${uitleg ? ` - ${uitleg}` : ''}`);
};
/** Elke map in de repo-root met een umbrel-app.yml erin. Zelf gevonden, zoals de
* Python-toets over de store dat ook doet: een derde app valt er dan vanzelf
* onder. */
const appMappen = () =>
readdirSync(REPO, { withFileTypes: true })
.filter((regel) => regel.isDirectory())
.map((regel) => regel.name)
.filter((naam) => existsSync(join(REPO, naam, 'umbrel-app.yml')))
.sort();
/** De inhoud van elk <script>-blok zonder src-attribuut. */
const scriptBlokken = (html) => {
const blokken = [];
const patroon = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
let treffer = patroon.exec(html);
while (treffer !== null) {
blokken.push(treffer[1]);
treffer = patroon.exec(html);
}
return blokken;
};
/**
* Doet na wat umbreld met een template doet.
*
* envsubst vervangt `${NAAM}` en `$NAAM` door de waarde uit de omgeving, en door
* een lege string als die variabele niet bestaat. Wat wij hier invullen is een
* plausibele waarde voor de variabelen die de pagina's gebruiken; voor de rest de
* lege string, want dat is precies wat er op het apparaat zou gebeuren.
*/
const vulIn = (tekst) => {
const WAARDEN = { APP_VERSION: '0.5.0' };
return tekst.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
);
};
/** Parseert het script zonder het te draaien. Geeft de foutmelding of null. */
const parseert = (bron) => {
try {
// new vm.Script parseert bij het aanmaken en draait niets. Dat is precies wat
// hier nodig is: draaien zou een document en een window vragen die er niet
// zijn, en dan toets je je eigen namaakbrowser.
new vm.Script(bron);
return null;
} catch (error) {
return String(error && error.message ? error.message : error);
}
};
/**
* Waar de pagina van een app staat, en of umbreld hem invult.
*
* Twee plekken: als *.template in de app-map (dan gaat hij door envsubst), of als
* gewoon bestand in het bouwrecept onder tools/<app-id zonder store-id>/ (dan zit
* hij in een eigen image en wordt hij door niemand ingevuld). Electrum Gate is
* sinds 0.1.0 het tweede geval; zie het plan Eigenimage.
*/
const vindPagina = (app) => {
const template = join(REPO, app, 'index.html.template');
if (existsSync(template)) {
return { pad: template, template: true };
}
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
const inImage = join(REPO, 'tools', kort, 'index.html');
if (existsSync(inImage)) {
return { pad: inImage, template: false };
}
return null;
};
for (const app of appMappen()) {
const pagina = vindPagina(app);
if (pagina === null) {
// Geen pagina is geen fout: een app hoeft er geen te hebben.
continue;
}
const bron = readFileSync(pagina.pad, 'utf8');
const blokken = scriptBlokken(bron);
toets(`${app}: de pagina heeft een scriptblok`, blokken.length > 0);
blokken.forEach((blok, index) => {
const naam = `${app}: scriptblok ${index + 1}`;
const rauw = parseert(blok);
toets(`${naam} parseert zoals hij in de repo staat`, rauw === null, rauw);
if (pagina.template) {
const ingevuld = parseert(vulIn(blok));
toets(`${naam} parseert ook nadat umbreld hem invult`, ingevuld === null, ingevuld);
}
});
if (!pagina.template) {
// Een pagina in een image gaat door geen envsubst meer, dus wat er staat is
// wat de browser krijgt. Een accolade-variabele is dan een restant uit de
// template-tijd die letterlijk op de pagina zou belanden.
toets(`${app}: de pagina in de image bevat geen accolade-variabele`, !bron.includes('${'));
}
// De HTML zelf: geen parser, maar wel de fout die je in een bestand van
// tweeduizend regels echt maakt. Een dialog of een header die niet gesloten is,
// laat de rest van de pagina in dat element hangen.
for (const tag of ['html', 'head', 'body', 'header', 'script', 'style', 'dialog', 'details']) {
const open = (bron.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length;
const dicht = (bron.match(new RegExp(`</${tag}>`, 'gi')) || []).length;
if (open === 0 && dicht === 0) {
continue;
}
toets(
`${app}: elke <${tag}> is gesloten`,
open === dicht,
`${open} keer open, ${dicht} keer dicht`,
);
}
}
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+263
View File
@@ -0,0 +1,263 @@
"""Toetst de agent van Evolu Relay: de labels.
Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat
is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en
legde opdrachten in een postbus, en het beleid eromheen staat in `policy.js` met
`tests/test_limiter.mjs` erop. Met deze release doet hij twee dingen erbij die
eigen logica hebben:
- **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver
van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft,
en labelen is lezen-wijzigen-schrijven, dus er zit een slot om;
Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
met handmatig toelaten. De toetsen daarover zijn met de functie mee verdwenen.
Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een
BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder
hangt, de validatie en het schrijven, is hier wél getoetst. De pagina zelf blijft
handwerk in een browser.
Draaien:
python tests/test_relay_agent.py
De test laadt `tools/evolu-relay/agent.py` rechtstreeks: het bestand dat de
Dockerfile in de image zet. Tot 0.6.0 heette dat `agent.py.template` en stond het
in de app-map; de eerste toets hieronder is uit die tijd en blijft staan.
"""
import sys
# Vóór de imports, want anders is het te laat: Python legt bytecode naast agent.py
# zodra die geïmporteerd wordt, en die rommel hoort niet in de repo. Een keer is
# zo'n .pyc meegegaan in een commit.
sys.dont_write_bytecode = True
import importlib.machinery # noqa: E402
import importlib.util # noqa: E402
import json # noqa: E402
import os # noqa: E402
import tempfile # noqa: E402
from datetime import datetime, timedelta, timezone # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
AGENT = os.path.join(HERE, os.pardir, "tools", "evolu-relay", "agent.py")
def load_agent(state_dir):
"""Laadt agent.py als module, met zijn staat in een tijdelijke map.
De agent leest `RELAY_STATE_DIR` op moduleniveau, dus die moet vóór het laden
in de omgeving staan. Met een expliciete loader, buiten sys.path om.
"""
os.environ["RELAY_STATE_DIR"] = state_dir
loader = importlib.machinery.SourceFileLoader("relay_agent", AGENT)
spec = importlib.util.spec_from_file_location("relay_agent", AGENT, loader=loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def iso(seconden_vanaf_nu):
"""Een tijdstip in de vorm die het relay-proces schrijft: ISO, met een Z."""
when = datetime.now(timezone.utc) + timedelta(seconds=seconden_vanaf_nu)
return when.isoformat().replace("+00:00", "Z")
def schrijf_owners(state_dir, staat):
with open(os.path.join(state_dir, "owners.json"), "w", encoding="utf-8") as f:
json.dump(staat, f)
# ── De aanname waar deze hele test op rust ───────────────────────────────────
def test_template_is_invulbaar_zonder_schade(u):
"""Een restant uit de template-tijd dat blijft staan.
Het bestand zit sinds 0.6.0 in de image en gaat door geen envsubst meer. Maar
als iemand het ooit weer als template in een app-map zet, is dit de toets die
het meteen ziet.
"""
with open(AGENT, "r", encoding="utf-8") as f:
inhoud = f.read()
u.check("agent.py bevat geen dollartekens",
"$" not in inhoud,
"een envsubst-stap zou die invullen en de Python-code slopen")
# ── De labels ───────────────────────────────────────────────────────────────
def test_label_zetten_en_weghalen(agent, u):
u.check("een label zetten lukt", agent.apply_label("owner-a", "Laptop") is None)
u.check("en staat er daarna", agent.read_labels().get("owner-a") == "Laptop")
u.check("overschrijven lukt", agent.apply_label("owner-a", "Werklaptop") is None)
u.check("en vervangt de vorige", agent.read_labels().get("owner-a") == "Werklaptop")
u.check("een leeg label haalt hem weg", agent.apply_label("owner-a", "") is None)
u.check("en dan is hij er niet meer", "owner-a" not in agent.read_labels())
u.check("nog een keer weghalen is geen fout",
agent.apply_label("owner-a", "") is None)
def test_label_blijft_op_schijf(agent, u, state_dir):
"""Het bestand is JSON en te lezen zonder de agent.
Dat is geen formaliteit: dit bestand staat onder de app-datamap en gaat mee in
de back-up van umbrelOS. Een gebruiker die zijn labels kwijt is, moet ze daar
kunnen terugvinden.
"""
agent.apply_label("owner-b", "Telefoon")
with open(os.path.join(state_dir, "labels.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f)
u.check("het label staat in labels.json", op_schijf.get("owner-b") == "Telefoon")
def test_label_validatie(agent, u):
goed = agent.valid_label({"ownerId": "owner-c", "label": " Mijn laptop "})
u.check("witruimte wordt samengetrokken", goed[1] == "Mijn laptop")
u.check("en er is geen fout", goed[2] is None)
# Regeleindes eruit: dit is één regel naast een id, en een label met een nieuwe
# regel erin zou de lijst uit elkaar trekken.
plat = agent.valid_label({"ownerId": "owner-c", "label": "een\ntwee"})
u.check("een regeleinde wordt een spatie", plat[1] == "een twee")
leeg = agent.valid_label({"ownerId": "owner-c"})
u.check("een ontbrekend label is leeg en geen fout", leeg[1] == "" and leeg[2] is None)
for rommel, waarom in [
({}, "geen ownerId"),
({"ownerId": ""}, "leeg ownerId"),
({"ownerId": 42}, "ownerId is geen tekst"),
({"ownerId": "x" * (agent.MAX_OWNER_ID_LENGTH + 1)}, "ownerId te lang"),
({"ownerId": "owner-c", "label": 42}, "label is geen tekst"),
({"ownerId": "owner-c", "label": "x" * (agent.MAX_LABEL_LENGTH + 1)}, "label te lang"),
("geen object", "geen object"),
]:
u.check("een onbruikbaar label wordt geweigerd: %s" % waarom,
agent.valid_label(rommel)[2] is not None)
def test_labels_lopen_niet_vol(agent, u):
"""Er zit een plafond op. Niet tegen een aanvaller, want deze pagina zit achter
de inlog van umbrelOS, maar tegen een lus die per ongeluk blijft schrijven.
"""
for i in range(agent.MAX_LABELS):
agent.apply_label("bulk-%d" % i, "label %d" % i)
u.check("het plafond is bereikt", len(agent.read_labels()) == agent.MAX_LABELS)
u.check("en een label erboven wordt geweigerd",
agent.apply_label("een-te-veel", "nog een") is not None)
u.check("maar een bestaand label mag nog wél gewijzigd worden",
agent.apply_label("bulk-0", "gewijzigd") is None,
"anders kun je bij een vol bestand niets meer verbeteren")
def test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir):
"""Anders dan owners.json: daar hangt aan een half begrepen bestand de vraag wie
er binnen mag, en dan is weigeren het antwoord. Hier gaat het om een naam naast
een id, en het ergste gevolg is dat je de rauwe ids ziet.
"""
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
f.write("dit is geen json {{{")
u.check("een onleesbaar labelbestand leest als leeg", agent.read_labels() == {})
with open(os.path.join(state_dir, "labels.json"), "w", encoding="utf-8") as f:
json.dump({"owner-d": "Goed", "owner-e": 42, "": "geen id", "owner-f": " "}, f)
labels = agent.read_labels()
u.check("de goede regel blijft", labels.get("owner-d") == "Goed")
u.check("een label dat geen tekst is valt weg", "owner-e" not in labels)
u.check("een leeg id valt weg", "" not in labels)
u.check("een label van alleen witruimte valt weg", "owner-f" not in labels)
# ── De status ───────────────────────────────────────────────────────────────
def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
"""De pagina krijgt het label bij de eigenaar, en het bestand van de relay
blijft ongemoeid. Dat tweede is de reden dat `met_label` een kopie maakt.
"""
schrijf_owners(state_dir, {
"version": 1,
"learning": False,
"learningUntil": None,
"owners": [
{"id": "owner-g", "allowed": True, "firstSeen": iso(-600), "lastSeen": iso(-60)},
{"id": "owner-h", "allowed": False, "firstSeen": iso(-900), "lastSeen": None},
],
"rejected": [{"id": "owner-i", "firstSeen": iso(-300), "lastSeen": iso(-10),
"attempts": 3}],
})
agent.apply_label("owner-g", "Laptop")
agent.apply_label("owner-i", "Onbekend apparaat")
status = agent.build_status()
owners = status["owners"]
u.check("de toegelaten eigenaar krijgt zijn label",
owners["allowed"][0]["label"] == "Laptop")
u.check("de geblokkeerde staat in blocked", owners["blocked"][0]["id"] == "owner-h")
u.check("en heeft geen label", owners["blocked"][0]["label"] is None)
u.check("de geweigerde poging staat in rejected", owners["rejected"][0]["id"] == "owner-i")
u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat")
u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
# De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
# niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
# knop kunnen tonen voor iets dat niet meer bestaat.
u.check("de leerstand staat niet meer in de status",
"learning" not in owners and "learningSecondsLeft" not in owners)
with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f)
u.check("owners.json is niet aangeraakt",
all("label" not in regel for regel in op_schijf["owners"]),
"de agent hoort niet in het bestand van de relay te schrijven")
def main():
u = Uitslag()
test_template_is_invulbaar_zonder_schade(u)
with tempfile.TemporaryDirectory() as state_dir:
agent = load_agent(state_dir)
test_label_zetten_en_weghalen(agent, u)
test_label_blijft_op_schijf(agent, u, state_dir)
test_label_validatie(agent, u)
test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir)
test_status_hangt_labels_aan_de_regels(agent, u, state_dir)
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen
# bruikbare staat achter voor een toets erna.
test_labels_lopen_niet_vol(agent, u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het herschrijven in tools/release-bump.mjs.
//
// Draaien: node tests/test_release_bump.mjs
//
// WAAROM DIT BESTAND ER IS. Dat script bestaat om één stille fout onmogelijk te
// maken: een OUDE digest onder een NIEUWE tag levert de oude image en er is niets
// dat dat meldt. Een script dat die fout zelf maakt is erger dan overtypen, want
// dan vertrouw je erop. Vandaar dat de vervanging hier op de niet-gelukkige paden
// getoetst wordt en niet alleen op het geval waarin alles klopt.
//
// De netwerkcontrole (hoort die digest bij die tag) staat hier NIET in: die vraagt
// het register en dat is geen toets maar een meting. Wat hier staat is wat zonder
// netwerk te bewijzen valt.
// ═══════════════════════════════════════════════════════════════════════════════
import assert from 'node:assert/strict';
import {
herschrijfImage, herschrijfVersie, huidigeAppVersie, kiesAppVersie, isHoger,
} from '../tools/release-bump.mjs';
const DIGEST = `sha256:${'a'.repeat(64)}`;
const OUD = `sha256:${'b'.repeat(64)}`;
function test(naam, fn) {
try {
fn();
console.log(` ok ${naam}`);
} catch (fout) {
console.log(` FOUT ${naam}`);
console.log(` ${fout.message}`);
process.exitCode = 1;
}
}
console.log('herschrijfImage');
test('vervangt de hele regel, dus de oude digest kan niet blijven staan', () => {
const compose = `services:\n server:\n image: sc.kamenier-hamer.nl/sysop/atreides-assets:0.1.0@${OUD}\n restart: on-failure\n`;
const { tekst, naam, aantal } = herschrijfImage(compose, '0.1.3', DIGEST);
assert.equal(naam, 'sc.kamenier-hamer.nl/sysop/atreides-assets');
assert.equal(aantal, 1);
assert.ok(tekst.includes(`atreides-assets:0.1.3@${DIGEST}`));
assert.ok(!tekst.includes(OUD), 'de oude digest staat er nog');
assert.ok(tekst.includes(' restart: on-failure'), 'de rest van het bestand is geraakt');
});
test('en houdt de inspringing aan die er stond', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
assert.equal(herschrijfImage(compose, '0.2.0', DIGEST).tekst, ` image: sc.kamenier-hamer.nl/sysop/x:0.2.0@${DIGEST}\n`);
});
test('neemt twee containers uit hetzelfde image samen mee', () => {
const compose = ` a:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n b:\n image: sc.kamenier-hamer.nl/sysop/gate:0.1.0@${OUD}\n`;
const { aantal, tekst } = herschrijfImage(compose, '0.1.1', DIGEST);
assert.equal(aantal, 2);
assert.equal(tekst.match(/0\.1\.1/g).length, 2);
});
test('laat een image van een ANDERE host met rust', () => {
const compose = ` db:\n image: postgres:16-alpine\n app:\n image: sc.kamenier-hamer.nl/sysop/relay:0.7.1\n`;
const { tekst, aantal } = herschrijfImage(compose, '0.7.2', DIGEST);
assert.equal(aantal, 1);
assert.ok(tekst.includes('image: postgres:16-alpine'), 'de vreemde image is meeverschoven');
});
test('weigert een compose zonder eigen image in plaats van er een bij te zetten', () => {
assert.throws(() => herschrijfImage(' db:\n image: postgres:16\n', '1.0.0', DIGEST), /geen image-regel/);
});
test('weigert twee verschillende eigen images, want dan is de bedoeling niet af te leiden', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/a:0.1.0\n image: sc.kamenier-hamer.nl/sysop/b:0.1.0\n`;
assert.throws(() => herschrijfImage(compose, '0.2.0', DIGEST), /meerdere eigen images/);
});
test('weigert een digest die niet de vorm heeft, want dat is de plakfout die telt', () => {
const compose = ` image: sc.kamenier-hamer.nl/sysop/x:0.1.0\n`;
assert.throws(() => herschrijfImage(compose, '0.2.0', 'sha256:kort'), /digest heeft niet de vorm/);
assert.throws(() => herschrijfImage(compose, '0.2.0', 'a'.repeat(64)), /digest heeft niet de vorm/);
assert.throws(() => herschrijfImage(compose, 'v0.2.0', DIGEST), /versie heeft niet de vorm/);
});
console.log('de app-versie');
test('leest de huidige versie, met en zonder aanhalingstekens', () => {
assert.equal(huidigeAppVersie('id: x\nversion: "0.1.2"\nname: X\n'), '0.1.2');
assert.equal(huidigeAppVersie('version: 1.2.3\n'), '1.2.3');
});
test('vergelijkt per deel en niet als tekst', () => {
assert.equal(isHoger('0.10.0', '0.9.0'), true);
assert.equal(isHoger('0.9.0', '0.10.0'), false);
assert.equal(isHoger('1.0.0', '0.99.99'), true);
});
test('trekt gelijk als de image erboven ligt, en verhoogt anders de patch', () => {
assert.equal(kiesAppVersie('0.1.2', '0.1.3'), '0.1.3');
assert.equal(kiesAppVersie('0.2.0', '0.1.3'), '0.2.1');
// Gelijk is ook een verhoging waard: zonder verhoging rolt umbrelOS niets uit.
assert.equal(kiesAppVersie('0.1.3', '0.1.3'), '0.1.4');
});
test('schrijft de versie met aanhalingstekens terug en laat de rest staan', () => {
const manifest = 'id: x\nversion: "0.1.2"\nname: X\n';
assert.equal(herschrijfVersie(manifest, '0.1.3'), 'id: x\nversion: "0.1.3"\nname: X\n');
});
test('weigert een manifest zonder versie in plaats van er een bij te zetten', () => {
assert.throws(() => herschrijfVersie('id: x\nname: X\n', '0.1.3'), /geen `version:`/);
assert.throws(() => huidigeAppVersie('id: x\n'), /geen `version:`/);
});
if (process.exitCode) console.log('\nER ZIJN FOUTEN');
else console.log('\nalles goed');
@@ -0,0 +1,549 @@
"""Toetst dat de pagina omhoog komt zonder certificaat.
Waarom deze test bestaat. In 0.0.3 startte de app niet: het stream-blok van
nginx had een 'listen 50022 ssl' met een include naar de cert.conf van de agent,
en nginx weigert te starten als dat certificaat er niet is. Daardoor kwam ook de
web-UI niet omhoog, en dat is precies de pagina waarop je een certificaat kiest.
Wie geen reverse proxy draait, of twee kandidaten heeft, zag dus niets en kon
niets. De symptomen wezen ergens anders heen: de app_proxy meldde alleen dat de
server niet te bereiken was.
De reparatie is een verplaatsing, en die is niet aan de code af te lezen: het
stream-blok staat in stream.conf en wordt door entrypoint.sh pas in
/var/lib/gate/tls/ gezet als cert.conf bestaat. nginx.conf haalt die map op met
een jokerteken, en een jokerteken dat niets matcht is voor nginx geen fout. Eén
iemand die dat blok "netjes" terugzet in nginx.conf en de klem is terug, zonder
dat een van de andere toetsen iets merkt.
Sinds 0.1.0 zitten deze bestanden in een eigen image en staan ze in
tools/electrum-gate/; tot dan waren het *.template-bestanden in de app-map en
was het startscript een command-blok in de compose. De toetsen zijn mee
verhuisd en toetsen hetzelfde gedrag, plus wat er bij die verhuizing nieuw
kapot kan: de Dockerfile die een bestand niet meeneemt, en de plaatshouders in
stream.conf die entrypoint.sh moet invullen.
Draaien:
python tests/test_server_start_zonder_certificaat.py
Er staan een paar toetsen bij die niet over het starten gaan maar wel over
dezelfde soort fout: een die niets meldt en pas opvalt als iemand het toevallig
ziet. De verbindingsteller die stil nul telt, een mount die weer alleen-lezen wordt,
en twee taglines die uit elkaar lopen. Ze staan hier en niet in een derde bestand,
want de suite heeft geen runner en een bestand dat niemand aanroept toetst niets.
"""
import sys
sys.dont_write_bytecode = True
import os # noqa: E402
import re # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
APP = os.path.join(HERE, os.pardir, "whatsnext-electrum-gate")
RECEPT = os.path.join(HERE, os.pardir, "tools", "electrum-gate")
NGINX_CONF = os.path.join(RECEPT, "nginx.conf")
STREAM_CONF = os.path.join(RECEPT, "stream.conf")
ENTRYPOINT = os.path.join(RECEPT, "entrypoint.sh")
DOCKERFILE = os.path.join(RECEPT, "Dockerfile")
BUILD = os.path.join(RECEPT, "build.sh")
PAGINA = os.path.join(RECEPT, "index.html")
COMPOSE = os.path.join(APP, "docker-compose.yml")
MANIFEST = os.path.join(APP, "umbrel-app.yml")
class Uitslag:
def __init__(self):
self.goed = 0
self.fout = []
def check(self, naam, gelukt, uitleg=""):
if gelukt:
self.goed += 1
else:
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
def rapport(self):
print()
print("%d goed, %d fout" % (self.goed, len(self.fout)))
for f in self.fout:
print(" FOUT: " + f)
return 0 if not self.fout else 1
def lees(pad):
with open(pad, "r", encoding="utf-8") as f:
return f.read()
def zonder_commentaar(tekst):
"""De regels die nginx of de shell daadwerkelijk uitvoert.
Zonder dit zou elke toets hieronder afgaan op de uitleg erboven, en die
noemt juist de dingen die er niet meer mogen staan.
"""
regels = []
for regel in tekst.splitlines():
kaal = regel.strip()
if not kaal or kaal.startswith("#"):
continue
regels.append(kaal)
return regels
def test_pagina_hangt_niet_aan_een_certificaat(u):
"""Geen TLS-directive in nginx.conf, want die blokkeert de start."""
regels = zonder_commentaar(lees(NGINX_CONF))
u.check("nginx.conf heeft geen listen met ssl",
not any("listen" in r and "ssl" in r for r in regels),
"een 'listen ssl' zonder certificaat laat nginx niet starten, "
"en dan is er ook geen pagina om er een te kiezen")
u.check("nginx.conf includet cert.conf niet rechtstreeks",
not any("cert.conf" in r for r in regels),
"een include van een bestand dat er nog niet is, is een startfout")
u.check("nginx.conf haalt het stream-blok met een jokerteken op",
any(r.startswith("include") and "/var/lib/gate/tls/*" in r
for r in regels),
"zonder jokerteken is een lege map alsnog een startfout")
u.check("de pagina zelf luistert nog wel",
any(r.startswith("listen 80") for r in regels))
def test_stream_blok_staat_apart(u):
"""Het TLS-deel is compleet, en het backend-adres komt uit de omgeving."""
u.check("stream.conf bestaat", os.path.isfile(STREAM_CONF))
if not os.path.isfile(STREAM_CONF):
return
regels = zonder_commentaar(lees(STREAM_CONF))
u.check("stream.conf luistert op 50022 met ssl",
any("listen 50022 ssl" in r for r in regels))
u.check("stream.conf includet de cert.conf van de agent",
any("include /var/lib/gate/cert.conf" in r for r in regels))
u.check("stream.conf heeft zijn log_format zelf",
any(r.startswith("log_format gate") for r in regels),
"tot 0.0.29 schreef het command-blok dat in een los bestand, omdat "
"een dollarteken in een template leeggemaakt werd; dat hoeft niet meer")
u.check("en schrijft de sessielog waar de agent hem leest",
any(r.startswith("access_log /var/lib/gate/stream.log") for r in regels))
# Het adres van de backend staat niet in dit bestand maar komt uit de
# omgeving, en de weg daarheen is een plaatshouder die entrypoint.sh invult.
# Geen dollarteken in die plaatshouder: dan kan geen envsubst-stap, van
# umbreld of van wie dan ook, hem stil leegmaken.
proxy = [r for r in regels if r.startswith("proxy_pass")]
u.check("stream.conf heeft precies één proxy_pass", len(proxy) == 1,
"gevonden: %r" % proxy)
u.check("en die verwijst naar de plaatshouders voor host en poort",
proxy and "__GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__" in proxy[0],
"gevonden: %r" % proxy)
u.check("stream.conf bevat geen accolade-variabele meer",
"${" not in lees(STREAM_CONF),
"die wordt sinds de eigen image door niemand meer ingevuld en zou "
"letterlijk in de configuratie belanden")
def test_entrypoint_wacht_niet_op_de_agent(u):
"""entrypoint.sh start nginx altijd, en zet TLS erbij als het kan."""
u.check("entrypoint.sh bestaat", os.path.isfile(ENTRYPOINT))
if not os.path.isfile(ENTRYPOINT):
return
tekst = lees(ENTRYPOINT)
regels = zonder_commentaar(tekst)
u.check("geen wachtlus op cert.conf",
not any("! -f" in r and "cert.conf" in r and "while" in r
for r in regels),
"dat is precies de klem uit 0.0.3: geen certificaat, geen nginx, "
"geen pagina, dus geen manier om een certificaat te kiezen")
u.check("het stream-blok wordt ingevuld uit de omgeving",
any("__GATE_ELECTRUM_HOST__" in r and "GATE_ELECTRUM_HOST" in r
for r in regels)
and any("__GATE_ELECTRUM_PORT__" in r for r in regels),
"anders staat de plaatshouder letterlijk in de proxy_pass")
u.check("en wordt uit stream.conf.in gelezen, zoals de Dockerfile hem neerzet",
any("/etc/nginx/stream.conf.in" in r for r in regels))
u.check("het stream-blok wordt weggezet als cert.conf bestaat",
any(r.startswith("if cp /etc/nginx/stream.conf") and "/tls/" in r
for r in regels))
u.check("en weggehaald als cert.conf er niet is",
any(r.startswith("rm -f") and "/tls/stream.conf" in r for r in regels),
"een ingetrokken keuze moet de poort ook echt sluiten")
u.check("een mislukte herlading breekt de wachtlus niet af",
any("if ! nginx -s reload" in r for r in regels),
"onder 'set -e' zou de lus verdwijnen en daarna pikt niets meer "
"een wijziging op")
u.check("nginx wordt met exec het hoofdproces",
regels and regels[-1].startswith("exec nginx"),
"anders blijft de shell PID 1, geeft signalen niet door, en ziet "
"Docker een geslaagde afsluiting als nginx omvalt; laatste regel: %r"
% (regels[-1] if regels else None))
u.check("het script begint met een shebang",
tekst.startswith("#!/bin/sh"))
u.check("geen verdubbelde dollartekens meer",
"$$" not in "\n".join(regels),
"dat was de ontsnapping voor het command-blok in de compose; in een "
"gewoon script is $$ het proces-id")
def test_dockerfile_neemt_alles_mee(u):
"""Wat entrypoint.sh en nginx.conf verwachten, zet de Dockerfile ook neer.
Dit is de nieuwe klasse fouten van de eigen image: een bestand dat in de
repo staat maar niet in de image, en dan faalt de container pas op het
apparaat. Geen Docker nodig om dat te zien; de paden staan in de tekst.
"""
u.check("Dockerfile bestaat", os.path.isfile(DOCKERFILE))
if not os.path.isfile(DOCKERFILE):
return
regels = zonder_commentaar(lees(DOCKERFILE))
copies = [r for r in regels if r.startswith("COPY ")]
def kopieert(bron, doel):
return any(bron in r and r.endswith(" " + doel) for r in copies)
u.check("nginx.conf gaat naar /etc/nginx/nginx.conf",
kopieert("tools/electrum-gate/nginx.conf", "/etc/nginx/nginx.conf"))
u.check("stream.conf gaat naar /etc/nginx/stream.conf.in",
kopieert("tools/electrum-gate/stream.conf", "/etc/nginx/stream.conf.in"),
"entrypoint.sh leest dat pad en vult het in naar stream.conf")
u.check("de pagina gaat naar de webroot",
kopieert("tools/electrum-gate/index.html", "/usr/share/nginx/html/index.html"))
u.check("het icoon komt uit de app-map",
kopieert("whatsnext-electrum-gate/icon.png", "/usr/share/nginx/html/icon.png"),
"de app-map houdt het origineel omdat het manifest ernaar wijst; "
"de image kopieert het, zodat er niet twee exemplaren zijn")
u.check("de agent gaat naar /app/agent.py",
kopieert("tools/electrum-gate/agent.py", "/app/agent.py"),
"de compose start hem op dat pad")
u.check("entrypoint.sh gaat mee en wordt uitvoerbaar",
kopieert("tools/electrum-gate/entrypoint.sh", "/entrypoint.sh")
and any(r.startswith("RUN chmod") and "/entrypoint.sh" in r for r in regels),
"git op Windows kent geen uitvoerbit, dus die moet de Dockerfile zetten")
u.check("python3 wordt geïnstalleerd",
any(r.startswith("RUN apk add") and "python3" in r for r in regels))
u.check("de basisimage is nginx op een vaste tag",
any(re.match(r"FROM nginx:\d+\.\d+-alpine$", r) for r in regels),
"nginx:alpine beweegt mee en is ook mét digest niet toegestaan; "
"zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md")
u.check("het entrypoint van de nginx-image is uitgeschakeld",
"ENTRYPOINT []" in regels,
"anders draait het envsubst-script van die image mee, en dat is "
"precies het soort stille invulling dat dit project vreest")
# De compose noemt paden in de image; die moeten er zijn.
compose = zonder_commentaar(lees(COMPOSE))
u.check("de compose start de agent op het pad waar de Dockerfile hem neerzet",
any(r == "- /app/agent.py" for r in compose))
def test_compose_en_recept_lopen_gelijk(u):
"""Dezelfde image in beide services, met de tag uit build.sh.
Twee services die elk hun eigen tag hebben lopen uit elkaar bij de eerste
verhoging die iemand op één plek doet. En een tag die niet gelijk is aan
VERSION in build.sh betekent een image die nooit gebouwd is.
"""
compose = zonder_commentaar(lees(COMPOSE))
images = [r.split(":", 1)[1].strip() for r in compose if r.startswith("image:")]
u.check("de compose heeft twee images", len(images) == 2, "gevonden: %r" % images)
tags = set()
for image in images:
zonder_digest = image.split("@", 1)[0]
tags.add(zonder_digest)
u.check("en het is twee keer dezelfde", len(tags) == 1, "gevonden: %r" % sorted(tags))
versie = None
for regel in zonder_commentaar(lees(BUILD)):
if regel.startswith("VERSION="):
versie = regel.split("=", 1)[1].strip().strip('"')
u.check("build.sh heeft een VERSION", bool(versie))
if versie and tags:
tag = sorted(tags)[0]
u.check("de tag in de compose is VERSION uit build.sh",
tag.endswith(":" + versie),
"compose %r, build.sh %r" % (tag, versie))
# Wat er niet meer gemount mag worden: de programmabestanden. Zit er weer
# een mount van een los bestand uit de app-map in, dan is dat een terugval
# naar de template-tijd en bereikt een wijziging het apparaat via twee wegen.
losse = [r for r in compose if r.startswith("- ${APP_DATA_DIR}/")
and "/" not in r[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]]
u.check("geen losse bestanden uit de app-map meer gemount",
not losse, "gevonden: %r" % losse)
u.check("de server krijgt het backend-adres uit de omgeving",
compose.count("GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}") == 2
and compose.count("GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}") == 2,
"agent én server hebben het nodig; entrypoint.sh vult er het "
"stream-blok mee in")
u.check("de agent krijgt de versie van het manifest door",
"GATE_APP_VERSION: ${APP_VERSION}" in compose,
"anders toont de kop van de pagina geen versie")
def test_uploadpad(u):
"""De drie dingen buiten de agent die uploaden mogelijk maken.
Alle drie kunnen ze stil wegvallen: een mount die weer `:ro` wordt geeft een
foutmelding pas als iemand iets uploadt, en een te kleine limiet in nginx
geeft een 413 die niets uitlegt.
"""
compose = zonder_commentaar(lees(COMPOSE))
nginx = zonder_commentaar(lees(NGINX_CONF))
eigen = [r for r in compose if "/certs/own" in r and r.startswith("-")]
u.check("de eigen certificaatmap is twee keer gemount",
len(eigen) == 2, "gevonden: %r" % eigen)
u.check("en precies één daarvan is beschrijfbaar",
len([r for r in eigen if not r.endswith(":ro")]) == 1,
"zonder :ro kan de agent niets neerzetten, met :ro overal ook niet: %r"
% eigen)
u.check("GATE_UPLOAD_DIR staat in de omgeving van de agent",
any(r.startswith("GATE_UPLOAD_DIR:") for r in compose),
"leeg of afwezig betekent: uploaden staat uit")
u.check("nginx heeft een eigen locatie voor de upload",
any("location = /api/certificate/upload" in r for r in nginx))
# De limiet moet groter zijn dan die van /api/, anders kapt nginx het verzoek
# af voordat de agent er iets over kan zeggen.
limieten = []
for regel in nginx:
if regel.startswith("client_max_body_size"):
limieten.append(regel.rstrip(";").split()[-1])
u.check("en een grotere limiet dan het gewone api-pad",
len(limieten) == 2 and limieten[0] != limieten[1],
"gevonden limieten: %r" % limieten)
def test_verbindingsteller(u):
"""De teller in de nginx-container, en de poort in hex.
Het gevaarlijke deel is de hex: de teller zoekt in /proc/net/tcp op de poort
in hexadecimale vorm, en verandert de TLS-poort ooit, dan telt hij stil nul.
Een teller die altijd nul zegt, ziet eruit als "geen wallet verbonden" en
niet als een fout.
"""
regels = zonder_commentaar(lees(ENTRYPOINT))
teller = [r for r in regels if "/proc/net/tcp" in r]
u.check("de teller leest /proc/net/tcp", bool(teller),
"dat is de enige plek waar de open verbindingen van deze container "
"staan; de agent zit in een andere netwerk-namespace")
u.check("en schrijft het aantal weg voor de agent",
any("/sessions" in r for r in regels))
# De poort uit de omgeving van de agent is de waarheid; de teller moet
# dezelfde poort in hex zoeken.
compose = zonder_commentaar(lees(COMPOSE))
poorten = [r for r in compose if r.startswith("GATE_TLS_PORT:")]
u.check("GATE_TLS_PORT staat in de compose", len(poorten) == 1,
"gevonden: %r" % poorten)
if not poorten or not teller:
return
poort = int(poorten[0].split(":", 1)[1].strip().strip('"'))
hexpoort = "%X" % poort
# Over de regels heen zoeken, want de awk-regel staat achter een
# backslash-vervolg en dan valt de hex op een eigen regel.
u.check("de teller zoekt de TLS-poort in hex (%d is %s)" % (poort, hexpoort),
(":" + hexpoort) in " ".join(regels),
"nergens in entrypoint.sh staat :%s, dus telt de teller nul"
% hexpoort)
stream = zonder_commentaar(lees(STREAM_CONF))
u.check("en stream.conf luistert op diezelfde poort",
any(r == "listen %d ssl;" % poort for r in stream))
def test_data_onder_data(u):
"""Alles wat de app zelf schrijft staat onder `data/`.
De conventie van de officiele appstore, afgelezen aan echte apps: electrs
mount `${APP_DATA_DIR}/data/electrs`, mempool `${APP_DATA_DIR}/data`. Deze app
had `runtime/` en `certs/` naast de templates staan. Verplaatst op 20-08-2026
met het oog op publicatie.
Waarom er een toets op staat: bij de volgende mount die iemand toevoegt is dit
precies het detail dat je vergeet, en niets gaat er stuk van. Het valt pas op
bij het inleveren.
"""
regels = zonder_commentaar(lees(COMPOSE))
mounts = [r[1:].strip() for r in regels
if r.startswith("-") and "${APP_DATA_DIR}" in r]
u.check("er zijn mounts uit de app-datamap", bool(mounts))
fout = []
for mount in mounts:
host = mount.strip('"').split(":")[0].replace("${APP_DATA_DIR}/", "")
if not host.startswith("data/"):
fout.append(host)
u.check("elke mount uit de app-map staat onder data/",
not fout, "deze niet: %r" % fout)
u.check("de gedeelde toestand staat onder data/",
any("${APP_DATA_DIR}/data/runtime:" in m for m in mounts))
u.check("de eigen certificaatmap staat onder data/",
any("${APP_DATA_DIR}/data/certs:" in m for m in mounts))
# En het manifest moet die paden ook noemen, anders wijst backupIgnore naar
# iets wat niet bestaat en gaat de sessielog alsnog mee in elke back-up.
manifest = lees(MANIFEST)
u.check("backupIgnore staat in het manifest", "backupIgnore:" in manifest)
for pad in ("data/runtime/stream.log", "data/runtime/status.json"):
u.check("backupIgnore noemt %s" % pad, pad in manifest)
# De volgorde die de packaging-documentatie van de officiele appstore voorschrijft.
# Wat de spec niet noemt, zoals icon en backupIgnore, hoort daarachter en niet
# ertussen: dan is de kop van het manifest letterlijk goed.
MANIFEST_ORDE = [
"manifestVersion", "id", "category", "name", "version", "tagline",
"description", "releaseNotes", "developer", "website", "dependencies",
"repo", "support", "port", "gallery", "path",
]
def manifest_velden():
"""De veldnamen op het hoogste niveau, in de volgorde van het bestand."""
velden = []
for regel in lees(MANIFEST).splitlines():
if not regel or regel[0] in " #-":
continue
if ":" in regel:
velden.append(regel.split(":", 1)[0])
return velden
def test_manifest_volgorde(u):
"""De velden staan in de voorgeschreven volgorde.
Geen smaak: de officiele appstore schrijft deze volgorde voor. Een toets
hierop omdat een nieuw veld standaard onderaan of middenin belandt, en er
niets van stukgaat; het valt pas op bij het inleveren.
"""
velden = manifest_velden()
kern = [v for v in velden if v in MANIFEST_ORDE]
ontbreekt = [v for v in MANIFEST_ORDE if v not in velden]
u.check("alle voorgeschreven velden staan in het manifest",
not ontbreekt, "mist: %r" % ontbreekt)
u.check("en in de voorgeschreven volgorde",
kern == MANIFEST_ORDE, "de volgorde is nu: %r" % kern)
# De extra's horen achter de voorgeschreven reeks. Zo is de kop van het
# bestand letterlijk op orde en hoeft er bij inlevering alleen iets weg.
if kern == MANIFEST_ORDE and velden:
laatste_kern = velden.index(MANIFEST_ORDE[-1])
extra_ervoor = [v for v in velden[:laatste_kern] if v not in MANIFEST_ORDE]
u.check("en wat de spec niet noemt staat erachter",
not extra_ervoor, "deze staan ertussen: %r" % extra_ervoor)
u.check("de lege standaard-inloggegevens zijn eruit",
"defaultUsername" not in velden and "defaultPassword" not in velden,
"deze app heeft geen eigen inlog, dus een leeg veld zegt niets")
def test_releasenotes_hebben_een_geschiedenis(u):
"""Eén verhaal over deze versie, dan één regel per eerdere versie.
Deze toets bestaat om een echte fout: drie versies achter elkaar kwam er een
nieuwe kop bovenop de release notes terwijl de oude tekst eronder bleef staan.
In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer dezelfde
regel over 0.0.4. Dat is zichtbare tekst in de appstore, en niemand leest zijn
eigen release notes nog een keer na.
"""
tekst = lees(MANIFEST)
u.check("er staat precies een keer een geschiedenis-kop",
tekst.count("Earlier releases:") == 1,
"gevonden: %d keer" % tekst.count("Earlier releases:"))
# Elke eerdere versie mag maar een keer genoemd worden.
dubbel = []
for versie in ("0.0.4", "0.0.5", "0.0.6", "0.0.7", "0.0.8"):
aantal = tekst.count("\n " + versie + " ")
if aantal > 1:
dubbel.append((versie, aantal))
u.check("en geen versie wordt twee keer beschreven",
not dubbel, "dubbel: %r" % dubbel)
def test_tagline_is_overal_dezelfde(u):
"""De regel onder de app-naam staat op twee plekken en moet gelijk zijn.
Besloten door de gebruiker op 20-08-2026, nadat hij zag dat de winkel en de
pagina iets anders zeiden. Eén app, één regel. Deze toets bestaat omdat de
twee plekken ver uit elkaar staan: niemand die de tagline in het manifest
aanpast, opent daarna de pagina.
"""
tagline = None
for regel in lees(MANIFEST).splitlines():
if regel.startswith("tagline:"):
tagline = regel.split(":", 1)[1].strip().strip('"')
break
u.check("het manifest heeft een tagline", bool(tagline))
if not tagline:
return
u.check("en de pagina gebruikt woordelijk dezelfde regel",
tagline in lees(PAGINA),
"de pagina noemt hem niet: %r" % tagline)
def test_pagina_zonder_invulling(u):
"""De pagina zit in de image en wordt door niemand meer ingevuld.
Een ${...} die blijft staan komt letterlijk in de browser terecht. En de twee
waarden die umbreld vroeger invulde, de versie en het backend-adres, moeten
nu uit status.json komen; anders is de kop leeg en het adres ook.
"""
tekst = lees(PAGINA)
u.check("de pagina bevat geen accolade-variabele", "${" not in tekst)
u.check("de kop haalt de versie uit status.json",
"s.version" in tekst and 'id="head-version"' in tekst)
u.check("het backend-adres komt uit status.json",
"backend.address" in tekst)
def main():
u = Uitslag()
test_pagina_hangt_niet_aan_een_certificaat(u)
test_stream_blok_staat_apart(u)
test_entrypoint_wacht_niet_op_de_agent(u)
test_dockerfile_neemt_alles_mee(u)
test_compose_en_recept_lopen_gelijk(u)
test_verbindingsteller(u)
test_uploadpad(u)
test_data_onder_data(u)
test_manifest_volgorde(u)
test_releasenotes_hebben_een_geschiedenis(u)
test_tagline_is_overal_dezelfde(u)
test_pagina_zonder_invulling(u)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())