PinVault/tests/test_api.py
Jarian Cottingham a69c40bc84 Fix startup crashes and harden crypto
- Define missing dict_factory (orphaned fragment left app unable to
  return DB rows; every endpoint crashed on first query)
- Skip backup when PINVAULT_NAS_BACKUP_DIR is empty (os.makedirs('')
  raised in bootstrap and killed gunicorn at import; compose default)
- Generate PINs with secrets.randbelow instead of random.randint
- Per-row random HMAC salts for PINs and recovery codes (single shared
  hardcoded salt defeated the precomputation protection); constant-time
  comparisons; legacy rows keep the historic salt
- Atomic backup restore (temp file + os.replace) to avoid torn DB
- Validate lock_days type, guard backup loop, move re import to module
  level
- Replace weak unittest stubs with 34-test pytest suite (sqlcipher
  shimmed with sqlite3 so tests run without the native lib)
- Add README, LICENSE, pyproject, .env.example; ruff clean
2026-08-20 22:52:28 +00:00

158 lines
5.2 KiB
Python

import os
import re
from conftest import AUTH, create_pin
BACKUP_RE = re.compile(r"^pinvault-backup-\d{8}-\d{6}\.db$")
def test_bootstrap_ok(client):
import app
assert app._bootstrap_ok is True
def test_backup_ran_with_empty_nas_dir(client):
"""Regression: bootstrap must not crash when PINVAULT_NAS_BACKUP_DIR is empty."""
import app
assert app.NAS_BACKUP_DIR == ""
files = os.listdir(app.LOCAL_BACKUP_DIR)
assert any(BACKUP_RE.match(f) for f in files)
def test_missing_api_key_rejected(client):
assert client.get("/api/pins").status_code == 401
assert client.post("/api/pins", json={}).status_code == 401
def test_status(client):
resp = client.get("/api/status", headers=AUTH)
assert resp.status_code == 200
assert resp.get_json()["locked"] is False
def test_list_pins(client):
resp = client.get("/api/pins", headers=AUTH)
assert resp.status_code == 200
assert isinstance(resp.get_json(), list)
def test_create_pin(client):
data = create_pin(client, label="home", lock_days=14)
assert data["label"] == "home"
assert len(data["pin"]) == 4 and data["pin"].isdigit()
assert len(data["recovery_codes"]) == 4
assert all(len(c) == 64 for c in data["recovery_codes"])
assert data["id"] > 0
def test_create_pin_lock_days_validation(client):
for bad in ("-1", "abc", 1.5, True, None):
resp = client.post("/api/pins", json={"lock_days": bad}, headers=AUTH)
assert resp.status_code == 400, f"lock_days={bad!r} should be rejected"
def test_access_locked_pin_denied(client):
data = create_pin(client, lock_days=30)
resp = client.post(f"/api/pins/{data['id']}/access", json={}, headers=AUTH)
assert resp.status_code == 423
def test_access_unlocked_pin(client):
data = create_pin(client, lock_days=0)
resp = client.post(f"/api/pins/{data['id']}/access", json={}, headers=AUTH)
assert resp.status_code == 200
assert resp.get_json()["pin"] == data["pin"]
def test_access_nonexistent_pin(client):
resp = client.post("/api/pins/999999/access", json={}, headers=AUTH)
assert resp.status_code == 404
def test_recovery_code_unlocks_pin(client):
data = create_pin(client, lock_days=30)
code = data["recovery_codes"][0]
resp = client.post(
f"/api/pins/{data['id']}/access", json={"bypass_code": code}, headers=AUTH
)
assert resp.status_code == 200
assert resp.get_json()["pin"] == data["pin"]
def test_recovery_code_single_use(client):
data = create_pin(client, lock_days=30)
code = data["recovery_codes"][0]
url = f"/api/pins/{data['id']}/access"
first = client.post(url, json={"bypass_code": code}, headers=AUTH)
assert first.status_code == 200
second = client.post(url, json={"bypass_code": code}, headers=AUTH)
assert second.status_code == 403
def test_invalid_recovery_code(client):
data = create_pin(client, lock_days=30)
resp = client.post(
f"/api/pins/{data['id']}/access", json={"bypass_code": "x" * 64}, headers=AUTH
)
assert resp.status_code == 403
def test_delete_unrevealed_pin_refused(client):
data = create_pin(client, lock_days=30)
resp = client.delete(f"/api/pins/{data['id']}", headers=AUTH)
assert resp.status_code == 409
def test_delete_revealed_pin(client):
data = create_pin(client, lock_days=0)
assert client.post(f"/api/pins/{data['id']}/access", json={}, headers=AUTH).status_code == 200
resp = client.delete(f"/api/pins/{data['id']}", headers=AUTH)
assert resp.status_code == 200
ids = [p["id"] for p in client.get("/api/pins", headers=AUTH).get_json()]
assert data["id"] not in ids
def test_backups_listed(client):
resp = client.get("/api/backups", headers=AUTH)
assert resp.status_code == 200
items = resp.get_json()
assert items, "expected at least the bootstrap backup"
for item in items:
assert BACKUP_RE.match(item["filename"])
assert item["location"] in ("local", "nas")
assert item["size"] > 0
def test_trigger_backup(client):
resp = client.post("/api/backups", headers=AUTH)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
def test_restore_invalid_filename(client):
resp = client.post("/api/backups/evil.db/restore", headers=AUTH)
assert resp.status_code == 400
def test_restore_missing_backup(client):
resp = client.post("/api/backups/pinvault-backup-99999999-999999.db/restore", headers=AUTH)
assert resp.status_code == 404
def test_restore_valid_backup(client):
filename = client.get("/api/backups", headers=AUTH).get_json()[0]["filename"]
resp = client.post(f"/api/backups/{filename}/restore", headers=AUTH)
assert resp.status_code == 200
assert resp.get_json()["restored"] == filename
def test_list_pins_shape(client):
data = create_pin(client, label="shape", lock_days=10)
entry = next(p for p in client.get("/api/pins", headers=AUTH).get_json() if p["id"] == data["id"])
for key in ("id", "label", "created_at", "lock_until", "locked", "days_remaining",
"revealed", "bypassed_count", "remaining_codes"):
assert key in entry, f"missing key {key}"
assert entry["locked"] is True
assert entry["remaining_codes"] == 4
assert "pin" not in entry and "recovery_codes" not in entry