mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
462cdfb170
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
132 lines
5.9 KiB
Python
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()
|