Files
pulse/scripts/tests/test_password_recovery_docs.py
T
pulse-triage[bot] 462cdfb170 Recover local logins without deleting authentication state
Route Docker and FAQ readers through one credential-source-aware recovery guide. Replace only the active password, retain unrelated settings and agent tokens, and account for systemd drop-ins and container environment application. Check private prompt output, failure stops, bcrypt loading, precedence and rendered help links.

Contract-Neutral: Documentation and test coverage only; existing authentication, setup and deployment contracts are unchanged.
Change-source: pulse-maintainer
2026-10-03 09:25:52 +01:00

132 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Check recovery guidance and execute its private hash recipe with a recorder.
The recorder proves shell disclosure/permission/failure behaviour, not Apache
hash generation or native Docker/systemd password recovery. The connected Go
config controls exercise bcrypt acceptance, source precedence and token retention.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
GUIDES = ("TROUBLESHOOTING.md", "FAQ.md", "DOCKER.md")
RECORD = "pulse-recovery:$2y$12$" + "a" * 53 + "\n" # Synthetic disclosure sentinel.
def recovery() -> str:
return (ROOT / "docs/TROUBLESHOOTING.md").read_text().split(
"### I forgot my password\n", 1)[1].split("### Port change", 1)[0]
def recipe() -> str:
matches = re.findall(r"```bash\n(.*?)```", recovery(), re.S)
if len(matches) != 1:
raise AssertionError("expected one private hash preparation recipe")
return matches[0]
HTPASSWD = '''#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
assert sys.argv[1:] == ['-nB', '-C', '12', 'pulse-recovery']
Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))
if os.environ.get('FAIL_HASH'):
print('hash preparation failed', file=sys.stderr)
sys.exit(23)
sys.stdout.write(os.environ['FIXTURE_RECORD'])
'''
class PasswordRecoveryDocsTest(unittest.TestCase):
def test_entry_points_do_not_delete_auth_or_restart_setup(self):
for name in GUIDES:
with self.subTest(guide=name):
text = (ROOT / "docs" / name).read_text()
self.assertNotRegex(text, r"(?:rm\s+/(?:data|etc/pulse)/\.env|Delete `/etc/pulse/\.env`)")
if name != "TROUBLESHOOTING.md":
self.assertIn("TROUBLESHOOTING.md#i-forgot-my-password", text)
text = recovery()
self.assertIn("Replace only the password", text)
self.assertIn("setup replaces the primary API token", text)
self.assertIn("same\n local administrator username", text)
def test_precedence_apply_and_failure_guidance_matches_current_sources(self):
text = " ".join(recovery().split())
for expected in ("take precedence", "pulse.service.d/override.conf",
"pulse-backend.service.d", "PULSE_DATA_DIR",
"inside the Pulse container", "owner-only backup",
"complete 60-character hash", "literal `$`",
"not just `docker restart`", "same image, mounted data",
"reload the service manager", "If it still fails, stop",
"existing sessions and API tokens need separate review",
"If no local password has ever been set"):
with self.subTest(expected=expected):
self.assertIn(expected, text)
setup = (ROOT / "internal/api/security_setup_fix.go").read_text()
self.assertIn('/etc/systemd/system/%s.service.d/override.conf', setup)
self.assertIn('r.config.APITokens = []config.APITokenRecord{*tokenRecord}', setup)
loader = (ROOT / "internal/config/config.go").read_text()
self.assertIn('godotenv.Load(envFile)', loader)
self.assertIn('normalizeEnvAuthPassword(authPass)', loader)
def run_recipe(self, directory: Path, *, fail: bool = False):
tools = directory / "tools"
tools.mkdir()
tool = tools / "htpasswd"
tool.write_text(HTPASSWD)
tool.chmod(0o700)
env = {**os.environ, "TMPDIR": str(directory),
"PATH": f"{tools}:{os.environ['PATH']}",
"ARGV_RECEIPT": str(directory / "argv.json"),
"FIXTURE_RECORD": RECORD, "FAIL_HASH": "yes" if fail else ""}
result = subprocess.run(["bash", "-c", recipe()], env=env,
capture_output=True, text=True, timeout=10)
self.assertNotIn(RECORD.strip(), result.stdout + result.stderr)
self.assertNotIn(RECORD.split(":", 1)[1].strip(), result.stdout + result.stderr)
self.assertEqual(json.loads((directory / "argv.json").read_text()),
['-nB', '-C', '12', 'pulse-recovery'])
records = list(directory.glob("pulse-password.*/password-record"))
self.assertEqual(len(records), 1)
record = records[0]
self.assertEqual(stat.S_IMODE(record.parent.stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600)
return result, record
def test_copied_recipe_keeps_hash_out_of_output_and_arguments(self):
# Include spaces in the directory to exercise the copied quoting.
with tempfile.TemporaryDirectory(prefix="password docs ") as temporary:
result, record = self.run_recipe(Path(temporary))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(record.read_text(), RECORD)
self.assertEqual(result.stdout,
f"Private password record saved in {record}\n")
def test_hash_failure_stops_before_success_and_retains_private_partial_file(self):
with tempfile.TemporaryDirectory() as temporary:
result, record = self.run_recipe(Path(temporary), fail=True)
self.assertEqual(result.returncode, 23)
self.assertEqual(result.stdout, "")
self.assertEqual(record.read_text(), "")
self.assertIn("hash preparation failed", result.stderr)
def test_recovery_guides_match_shipped_copies(self):
for name in GUIDES:
with self.subTest(guide=name):
self.assertEqual((ROOT / "docs" / name).read_bytes(),
(ROOT / "frontend-modern/public/docs" / name).read_bytes())
subprocess.run(["bash", "-n", "-c", recipe()], check=True, capture_output=True)
if __name__ == "__main__":
unittest.main()