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:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user