From 30a854b9aef84dc5029beb4e2a40eee6ad7316f0 Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:53:29 -0500 Subject: [PATCH 1/2] test: cover manage.py, the offline recovery CLI (0% -> 59%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool you reach for when the web UI cannot help — forgotten password, deactivated sole admin, a lost 2FA device — had no tests at all. That is a bad place for a gap: its whole job is to work on the day nothing else does. 21 checks, four properties, each mutation-verified: the lock-out guard deactivating or demoting the LAST active superadmin is refused AND rolled back, an inactive admin does not count as cover, and a second admin makes it allowed. Removing the guard fails 5 checks. session revocation a password reset bumps auth_epoch, or a stolen cookie outlives the reset meant to kill it. Fails 1. 2fa disable-2fa wipes the SECRET, not just the flag — otherwise re-enabling silently restores the old device. Fails 1. no guessing with no terminal it defaults to the sole superadmin and otherwise refuses; disable-2fa never defaults at all. Fails 1. Plus create-admin (refuses to clobber), weak --password rejected before anything is written, unknown username refused, and the interactive menu accepting a number, a name, or a retry. The harness reports a mid-run crash instead of hiding it. Writing this, three of my own bugs — a wrong helper name, assigning to the read-only sys.stdin.isatty, and calling a DB function with no app context — each just made the suite print fewer checks and still say "all passed". A crash is a failure and prints its traceback now. Wired into tools/run-tests.sh (so CI runs it) and into the coverage job. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- tests/manage_test.py | 236 +++++++++++++++++++++++++++++++++++++++ tools/run-tests.sh | 3 + 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 tests/manage_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74125c7..aa191f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: # --parallel-mode + combine: each suite is its own process, and smoke/rbac fork threads. # || true on the suites themselves — the gate job decides pass/fail, this one only measures. run: | - for suite in unit template_actions smoke rbac; do + for suite in unit template_actions smoke rbac manage; do rm -f data/panel.db data/panel.db-shm data/panel.db-wal data/panel.db.backup python -m coverage run --parallel-mode --source=. \ --omit="./tests/*,./tools/*,./.venv/*" "tests/${suite}_test.py" >/dev/null 2>&1 || true diff --git a/tests/manage_test.py b/tests/manage_test.py new file mode 100644 index 0000000..0a1d41f --- /dev/null +++ b/tests/manage_test.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Tests for manage.py — the offline recovery CLI. + +This is the tool you reach for when the web UI can't help: a forgotten password, a deactivated +sole admin, a 2FA device that's gone. It had no tests at all (0% coverage), which is a bad place +for a gap — its whole job is to work on the day everything else doesn't. + +The properties that matter, in order: + + 1. deactivating or demoting the LAST active superadmin is refused, and rolled back. A recovery + tool that can brick the panel is worse than no recovery tool. + 2. a password reset revokes existing sessions (auth_epoch), or a stolen cookie survives the reset + that was meant to lock the thief out. + 3. disable-2fa clears the SECRET, not just the flag — leaving the secret behind means re-enabling + silently restores the old device. + 4. with no terminal, the CLI never guesses which user you meant unless there is exactly one + superadmin to default to. + +No network, no SSH; it runs against a throwaway database like the other suites. + + python tests/manage_test.py # exits 0 if all checks pass, 1 otherwise +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import DB_PATH, SECRET_FILE, CRED_KEY_FILE, CONFIG_FILE # noqa: E402 + +if DB_PATH.exists(): + print("SKIP: %s already exists — this only runs against a throwaway DB." % DB_PATH) + sys.exit(0) + +_PREEXISTING = {p for p in (SECRET_FILE, CRED_KEY_FILE, CONFIG_FILE) if p.exists()} +_CFG_BACKUP = CONFIG_FILE.read_bytes() if CONFIG_FILE in _PREEXISTING else None + +from config import load_config, save_config # noqa: E402 +_cfg = load_config() +_cfg["setup_complete"] = True +save_config(_cfg) + +import system_ops as _so # noqa: E402 +_so._check_sudo = lambda force=False: False # never probe real sudo (pam_faillock) + +import manage # noqa: E402 (creates its own app at import, exactly as the CLI does) +from models import db, User # noqa: E402 +import auth # noqa: E402 + +results = [] + + +def check(name, cond, detail=""): + results.append((bool(cond), name, detail)) + + +def raises_exit(fn, *a, **kw): + """(did_it_exit, message) — the CLI signals refusal with sys.exit('reason').""" + try: + fn(*a, **kw) + return False, "" + except SystemExit as e: + return True, str(e.code) + + +class Args(object): + def __init__(self, **kw): + self.username = kw.pop("username", None) + self.password = kw.pop("password", None) + for k, v in kw.items(): + setattr(self, k, v) + + +def seed(**kw): + with manage.app.app_context(): + u = User(username=kw["username"], password_hash=auth.hash_password("Str0ng!passw0rd"), + display_name=kw["username"], is_superadmin=kw.get("admin", False), + is_active=kw.get("active", True)) + u.totp_enabled = kw.get("totp", False) + if kw.get("totp"): + u.totp_secret = "SEEDSECRET" + db.session.add(u) + db.session.commit() + return u.id + + +def cleanup(): + try: + with manage.app.app_context(): + db.session.remove() + db.engine.dispose() + except Exception: # nosec B110 + pass + if _CFG_BACKUP is not None: + CONFIG_FILE.write_bytes(_CFG_BACKUP) + for p in (DB_PATH, SECRET_FILE, CRED_KEY_FILE, CONFIG_FILE, + DB_PATH.with_name("panel.db-wal"), DB_PATH.with_name("panel.db-shm"), + DB_PATH.with_name("panel.db.backup")): + if p not in _PREEXISTING and p.exists(): + try: + p.unlink() + except OSError: + pass + + +try: + admin_id = seed(username="cli_admin", admin=True) + seed(username="cli_user", admin=False) + + # ── 1. The lock-out guard ───────────────────────────────────────────────────────────────── + # cli_admin is the only active superadmin. Every way of removing that must be refused, and the + # refusal must leave the row untouched — a half-applied change is the same brick. + for field, value, label in (("is_active", False, "deactivate"), ("is_superadmin", False, "demote")): + exited, msg = raises_exit(manage._set_flag, "cli_admin", field, value, label) + with manage.app.app_context(): + still = db.session.get(User, admin_id) + intact = still.is_active and still.is_superadmin + check("lockout: %s of the last active superadmin is refused" % label, + exited and "no active superadmin" in msg, msg[:70]) + check("lockout: ...and the account is left untouched, not half-changed" , intact) + + # With a second admin present the same operation is allowed. + second_id = seed(username="cli_admin2", admin=True) + exited, msg = raises_exit(manage._set_flag, "cli_admin2", "is_superadmin", False, "demoted") + with manage.app.app_context(): + demoted = not db.session.get(User, second_id).is_superadmin + check("lockout: demoting a SECOND admin is allowed", not exited and demoted, msg[:70]) + + # An inactive superadmin does not count as cover — the guard checks active ones. + with manage.app.app_context(): + u2 = db.session.get(User, second_id) + u2.is_superadmin, u2.is_active = True, False + db.session.commit() + exited, msg = raises_exit(manage._set_flag, "cli_admin", "is_active", False, "deactivate") + check("lockout: an INACTIVE superadmin does not count as cover", exited, msg[:70]) + + # ── 2. A password reset must revoke existing sessions ───────────────────────────────────── + with manage.app.app_context(): + before = db.session.get(User, admin_id) + old_hash, old_epoch = before.password_hash, (before.auth_epoch or 0) + manage.cmd_reset_password(Args(username="cli_admin", password="An0ther!Str0ng1")) + with manage.app.app_context(): + after = db.session.get(User, admin_id) + check("reset: the password actually changes", after.password_hash != old_hash) + check("reset: the new password verifies", + auth.check_password("An0ther!Str0ng1", after.password_hash)) + check("reset: auth_epoch is bumped, so existing sessions die", + (after.auth_epoch or 0) > old_epoch, + "%s -> %s" % (old_epoch, after.auth_epoch)) + + exited, msg = raises_exit(manage.cmd_reset_password, Args(username="cli_admin", password="weak")) + check("reset: a weak --password is refused before anything is written", + exited and "Weak password" in msg, msg[:60]) + exited, msg = raises_exit(manage.cmd_reset_password, Args(username="nobody_here", password="An0ther!Str0ng1")) + check("reset: an unknown username is refused", exited and "No such user" in msg, msg[:60]) + + # ── 3. disable-2fa must clear the SECRET, not just the flag ─────────────────────────────── + tot_id = seed(username="cli_2fa", totp=True) + manage.cmd_disable_2fa(Args(username="cli_2fa")) + with manage.app.app_context(): + t = db.session.get(User, tot_id) + check("2fa: the flag is cleared", t.totp_enabled is False) + check("2fa: and the SECRET is wiped, so re-enabling cannot restore the old device", + not t.totp_secret, repr(t.totp_secret)) + + # ── 4. Without a terminal the CLI must not guess ────────────────────────────────────────── + # sys.stdin.isatty is read-only on a real file object, so swap the whole stream for a stub. + _real_stdin = sys.stdin + try: + sys.stdin = type("_NoTTY", (), {"isatty": staticmethod(lambda: False)})() + with manage.app.app_context(): + # Two active superadmins → ambiguous → refuse rather than pick. + for uid in (admin_id, second_id): + u = db.session.get(User, uid) + u.is_superadmin, u.is_active = True, True + db.session.commit() + with manage.app.app_context(): + exited, msg = raises_exit(manage._resolve_username, None, True) + check("no tty: with two superadmins it refuses to guess", + exited and "pass a username" in msg, msg[:70]) + with manage.app.app_context(): + db.session.get(User, second_id).is_superadmin = False + db.session.commit() + with manage.app.app_context(): + picked = manage._resolve_username(None, True) + check("no tty: with exactly one superadmin it defaults to them", picked == "cli_admin", picked) + # disable-2fa passes default_sole_admin=False — it must never pick for you. + with manage.app.app_context(): + exited, msg = raises_exit(manage._resolve_username, None, False) + check("no tty: disable-2fa still refuses, even with one admin", exited, msg[:70]) + finally: + sys.stdin = _real_stdin + + # ── 5. create-admin ─────────────────────────────────────────────────────────────────────── + manage.cmd_create_admin(Args(username="cli_new", password="Br@ndNew1pass")) + with manage.app.app_context(): + n = User.query.filter_by(username="cli_new").first() + check("create-admin: the account exists, superadmin and active", + n is not None and n.is_superadmin and n.is_active) + exited, msg = raises_exit(manage.cmd_create_admin, Args(username="cli_new", password="Br@ndNew1pass")) + check("create-admin: refuses to clobber an existing user", + exited and "already exists" in msg, msg[:60]) + + # ── 6. The interactive menu accepts a number or a name ──────────────────────────────────── + _real_input = manage.input if hasattr(manage, "input") else None + import builtins + _saved_input = builtins.input + try: + with manage.app.app_context(): + names = [u.username for u in User.query.order_by(User.username).all()] + builtins.input = lambda *a: "2" + check("menu: a number selects the matching row", manage._pick_user_interactive() == names[1]) + builtins.input = lambda *a: names[0] + check("menu: a typed username is accepted", manage._pick_user_interactive() == names[0]) + _tries = iter(["nope", "1"]) + builtins.input = lambda *a: next(_tries) + check("menu: a bad choice re-prompts rather than exiting", + manage._pick_user_interactive() == names[0]) + finally: + builtins.input = _saved_input + +except Exception: + # Without this the suite just reports fewer checks than it has and looks green-ish. A crash + # part-way through is a FAILURE, and the traceback is the whole point of running it. + import traceback + traceback.print_exc() + results.append((False, "suite crashed before finishing — see the traceback above", "")) +finally: + passed = sum(1 for ok, _, _ in results if ok) + for ok, name, detail in results: + line = ("PASS" if ok else "FAIL") + " " + name + if detail and not ok: + line += " [%s]" % detail + print(line) + print("\n%d / %d checks passed" % (passed, len(results))) + cleanup() + sys.exit(0 if results and passed == len(results) else 1) diff --git a/tools/run-tests.sh b/tools/run-tests.sh index af9b4c7..f312b3a 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -41,6 +41,9 @@ echo "== smoke test (boots the app; routes must not 5xx) ==" echo "== rbac test (permissions/IDOR enforced server-side; self-seeds on an empty DB) ==" "$PY" tests/rbac_test.py +echo "== manage.py (the offline recovery CLI: lock-out guard, session revocation) ==" +"$PY" tests/manage_test.py + if command -v shellcheck >/dev/null 2>&1; then echo "== shellcheck (shell scripts) ==" shellcheck -S warning install.sh uninstall.sh tools/run-tests.sh reset-password.sh recover.sh From 3bafea5dc8496a6b0ab2f302abd22a6272acf4c3 Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:54:42 -0500 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20batch=20fuzzing,=20corpus=20pruning?= =?UTF-8?q?=20and=20fuzz=20coverage=20=E2=80=94=20dormant=20until=20a=20co?= =?UTF-8?q?rpus=20repo=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cflite_pr.yml fuzzes what a PR changed, for three minutes, starting from the committed seeds every time. These add the other half: nightly across all five targets for 15 minutes, and a weekly prune plus a coverage report showing which lines the fuzzers actually reach. The reason those modes were left out is that they need somewhere to keep the corpus between runs, which is a separate repo and a token. So every job is guarded by `if: env.CFL_STORAGE_REPO != ''` — with no secret they are skipped and green, and they start working by themselves the moment one is added. Same shape as the Codacy upload: nothing to remember, nothing red in the meantime. Also `if: github.repository == ...` so a fork never burns its own minutes on this, and language: python on run_fuzzers, which defaults to c++ and does not inherit it from the build step. Co-Authored-By: Claude Opus 5 --- .clusterfuzzlite/README.md | 20 ++++++--- .github/workflows/cflite_batch.yml | 57 ++++++++++++++++++++++++ .github/workflows/cflite_cron.yml | 69 ++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/cflite_batch.yml create mode 100644 .github/workflows/cflite_cron.yml diff --git a/.clusterfuzzlite/README.md b/.clusterfuzzlite/README.md index 9e029de..0301281 100644 --- a/.clusterfuzzlite/README.md +++ b/.clusterfuzzlite/README.md @@ -33,13 +33,21 @@ cleanly and still die on its first import if a dynamically-imported module was n why `build.sh` names `paramiko`, `eventlet`, `eventlet.tpool` and `config` as `--hidden-import` — the harnesses pull them in via `importlib.import_module("...")`, a string PyInstaller cannot see. -## Not enabled: batch fuzzing, corpus pruning, coverage +## Batch fuzzing, pruning and coverage — written, dormant -Those three modes want somewhere to keep a corpus between runs, which means a **separate storage -repo** and a personal access token. Without one, every run starts from the seeds in -`tests/fuzz/corpus/` and learns nothing from the last run. To turn them on: create an empty repo, -add a PAT with write access to it as a secret, then pass `storage-repo` to both actions and add -workflows with `mode: batch`, `mode: prune` and `mode: coverage`. See +`cflite_batch.yml` (nightly, all targets, 15 min) and `cflite_cron.yml` (weekly prune + coverage) +are committed and wired, but every job is guarded by `if: env.CFL_STORAGE_REPO != ''` and so does +nothing until that secret exists. They stay dormant rather than red. + +They need somewhere to keep the corpus between runs — without it each run restarts from the seeds +in `tests/fuzz/corpus/` and learns nothing from the last one. To switch them on: + +1. create an empty repo, e.g. `linuxgsm-panel-fuzz-corpus` +2. create a PAT that can write to it +3. add `CFL_STORAGE_REPO` as a repository secret here (the https URL with the token in it, per the + ClusterFuzzLite docs) + +Nothing else changes; the next scheduled run picks it up. See . ## Upstream OSS-Fuzz diff --git a/.github/workflows/cflite_batch.yml b/.github/workflows/cflite_batch.yml new file mode 100644 index 0000000..a542ed9 --- /dev/null +++ b/.github/workflows/cflite_batch.yml @@ -0,0 +1,57 @@ +name: ClusterFuzzLite batch + +# The long-running counterpart to cflite_pr.yml. That one fuzzes what a PR changed, for three +# minutes; this fuzzes EVERYTHING on a schedule and — the point of it — keeps the corpus it builds, +# so each run starts smarter than the last instead of from the committed seeds. +# +# Keeping a corpus needs somewhere to put it: a separate git repo plus a token that can write to it. +# Both jobs below self-disable when the CFL_STORAGE_REPO secret is absent, so this file is inert +# until you set one up and starts working by itself the moment you do: +# +# 1. create an empty repo, e.g. FMSMITH91/linuxgsm-panel-fuzz-corpus +# 2. create a PAT with write access to it +# 3. add two repository secrets here: CFL_STORAGE_REPO (the https URL with the token in it, per +# the ClusterFuzzLite docs) and nothing else — the actions read the rest from the workflow. +# +# See https://google.github.io/clusterfuzzlite/running-clusterfuzzlite/github-actions/ +on: + schedule: + - cron: '17 3 * * *' # daily, off the hour so it does not collide with everything else + workflow_dispatch: + +permissions: + contents: read + +env: + CFL_STORAGE_REPO: ${{ secrets.CFL_STORAGE_REPO }} + +jobs: + batch: + name: batch fuzz (${{ matrix.sanitizer }}) + runs-on: ubuntu-latest + if: github.repository == 'FMSMITH91/linuxgsm-panel' # never run in a fork + strategy: + fail-fast: false + matrix: + sanitizer: [ address ] + steps: + - name: Build fuzzers (${{ matrix.sanitizer }}) + id: build + if: env.CFL_STORAGE_REPO != '' + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + sanitizer: ${{ matrix.sanitizer }} + + - name: Fuzz everything (${{ matrix.sanitizer }}) + if: env.CFL_STORAGE_REPO != '' && steps.build.outcome == 'success' + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python # run_fuzzers defaults to c++ and does not inherit the build's setting + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 900 + mode: batch + sanitizer: ${{ matrix.sanitizer }} + storage-repo: ${{ secrets.CFL_STORAGE_REPO }} + storage-repo-branch: main + storage-repo-branch-coverage: gh-pages diff --git a/.github/workflows/cflite_cron.yml b/.github/workflows/cflite_cron.yml new file mode 100644 index 0000000..e1853d9 --- /dev/null +++ b/.github/workflows/cflite_cron.yml @@ -0,0 +1,69 @@ +name: ClusterFuzzLite prune and coverage + +# Corpus pruning and a coverage report for the fuzz targets. Both need the corpus that +# cflite_batch.yml accumulates, so both self-disable without CFL_STORAGE_REPO — see that file for +# what to create. Inert until then; live the moment the secret exists. +on: + schedule: + - cron: '43 4 * * 0' # weekly, after a few nightly batch runs have fed the corpus + workflow_dispatch: + +permissions: + contents: read + +env: + CFL_STORAGE_REPO: ${{ secrets.CFL_STORAGE_REPO }} + +jobs: + prune: + name: prune the corpus + runs-on: ubuntu-latest + if: github.repository == 'FMSMITH91/linuxgsm-panel' + steps: + - name: Build fuzzers + id: build + if: env.CFL_STORAGE_REPO != '' + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + + - name: Prune + # Drops inputs that no longer reach anything new, so the corpus stays fast to replay. + if: env.CFL_STORAGE_REPO != '' && steps.build.outcome == 'success' + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 600 + mode: prune + storage-repo: ${{ secrets.CFL_STORAGE_REPO }} + storage-repo-branch: main + storage-repo-branch-coverage: gh-pages + + coverage: + name: fuzzing coverage report + runs-on: ubuntu-latest + if: github.repository == 'FMSMITH91/linuxgsm-panel' + steps: + - name: Build fuzzers (coverage) + id: build + if: env.CFL_STORAGE_REPO != '' + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + sanitizer: coverage + + - name: Report + # Which lines the fuzzers actually reach — a different question from tests/ coverage, and + # the honest way to tell whether a target is exercising the parser or bouncing off a guard. + if: env.CFL_STORAGE_REPO != '' && steps.build.outcome == 'success' + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 600 + mode: coverage + sanitizer: coverage + storage-repo: ${{ secrets.CFL_STORAGE_REPO }} + storage-repo-branch: main + storage-repo-branch-coverage: gh-pages