"""Self-check for guildhall-join.py: invites, the append-only users file, 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 {}; 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")) 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)