nixos/scripts/guildhall-join-check.py
rope 8fe9e98300 authelia: allow password reset writes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:19:58 +01:00

112 lines
4.2 KiB
Python

"""Self-check for guildhall-join.py: invites, the append-only users file (also
after Authelia rewrote it for a password reset), and that Authelia accepts the
password hashes it writes.
Run it against the same python the service uses, with authelia on PATH:
py=$(nix build --no-link --print-out-paths --impure --expr \
'with import <nixpkgs> {}; python3.withPackages (ps: [ ps.pyyaml ps.argon2-cffi ])')
nix shell nixpkgs#authelia -c $py/bin/python3 scripts/guildhall-join-check.py
"""
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
HERE = Path(__file__).resolve().parent
TMP = Path(tempfile.mkdtemp())
os.environ["GUILDHALL_JOIN_STATE"] = str(TMP / "state")
os.environ["GUILDHALL_JOIN_USERS"] = str(TMP / "users_database.yml")
spec = importlib.util.spec_from_file_location("join", HERE / "guildhall-join.py")
j = importlib.util.module_from_spec(spec)
spec.loader.exec_module(j)
ORIGINAL = """users:
fred:
password: "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"
displayname: Fred
email: fred@example.org
groups: [household]
kayla:
password: "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"
displayname: Kayla
groups: [household]
"""
j.USERS_FILE.write_text(ORIGINAL)
failures = 0
def check(label, ok):
global failures
print(("ok " if ok else "FAIL ") + label)
failures += not ok
def refused(fn, *args):
try:
fn(*args)
except j.Refused as reason:
return str(reason)
return None
token = j.mint("for alex")
check("fresh invite is open", j.open_invite(token) is not None)
check("state holds digests, not tokens", token not in (TMP / "state" / "invites.json").read_text())
check("unknown token is closed", j.open_invite("x" * 24) is None)
check("bad username refused", refused(j.sign_up, token, "Al", "", "longpassword", "longpassword"))
check("existing username refused", refused(j.sign_up, token, "Kayla", "", "longpassword", "longpassword"))
check("short password refused", refused(j.sign_up, token, "alex", "", "short", "short"))
check("mismatched passwords refused", refused(j.sign_up, token, "alex", "", "longpassword", "longpassword2"))
check("refusals leave the file alone", j.USERS_FILE.read_text() == ORIGINAL)
check("refusals leave the invite open", j.open_invite(token) is not None)
name = j.sign_up(token, " Alex ", 'Alex "the Bold" Æ', "correct horse battery", "correct horse battery")
users = yaml.safe_load(j.USERS_FILE.read_text())["users"]
check("username normalised", name == "alex")
check("old users unchanged", {k: users[k] for k in ("fred", "kayla")} == yaml.safe_load(ORIGINAL)["users"])
check("original text kept byte for byte", j.USERS_FILE.read_text().startswith(ORIGINAL.rstrip("\n")))
check("new user only in guildhall group", users["alex"]["groups"] == ["guildhall"])
check("display name survives quoting", users["alex"]["displayname"] == 'Alex "the Bold" Æ')
check("invite is spent", j.open_invite(token) is None)
check("spent invite refused", refused(j.sign_up, token, "sam", "", "longpassword", "longpassword"))
# What Authelia's yaml writer leaves behind after a password reset.
REWRITTEN = """users:
fred:
password: $argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA
displayname: Fred
email: fred@example.org
groups:
- household
address: null
extra: {}
"""
j.USERS_FILE.write_text(REWRITTEN)
second = j.mint("after a reset")
j.sign_up(second, "sam", "", "correct horse battery", "correct horse battery")
rewritten = yaml.safe_load(j.USERS_FILE.read_text())["users"]
check("signs up after authelia rewrote the file", set(rewritten) == {"fred", "sam"} and rewritten["sam"]["groups"] == ["guildhall"])
expired = j.mint("old", now=1.0)
check("expired invite is closed", j.open_invite(expired) is None)
if shutil.which("authelia"):
result = subprocess.run(
["authelia", "crypto", "hash", "validate", "--password", "correct horse battery", "--", users["alex"]["password"]],
capture_output=True, text=True,
)
check("authelia accepts the hash", result.returncode == 0 and "matches" in result.stdout.lower())
else:
print("skip authelia hash check (authelia not on PATH)")
shutil.rmtree(TMP)
sys.exit(1 if failures else 0)