- 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
158 lines
5.2 KiB
Python
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
|