- 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
90 lines
2.5 KiB
Python
90 lines
2.5 KiB
Python
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
_TEST_ROOT = tempfile.mkdtemp(prefix="pinvault-test-")
|
|
|
|
|
|
def _install_sqlcipher_shim():
|
|
"""Fall back to plain sqlite3 when pysqlcipher3 is not installed.
|
|
|
|
The SQLCipher-specific `PRAGMA key` statement is swallowed so the app's
|
|
full endpoint logic can be exercised without the native library.
|
|
"""
|
|
try:
|
|
import pysqlcipher3.dbapi2 # noqa: F401
|
|
return
|
|
except ImportError:
|
|
pass
|
|
|
|
class _Connection:
|
|
def __init__(self, raw):
|
|
self._raw = raw
|
|
|
|
@property
|
|
def row_factory(self):
|
|
return self._raw.row_factory
|
|
|
|
@row_factory.setter
|
|
def row_factory(self, value):
|
|
self._raw.row_factory = value
|
|
|
|
def execute(self, sql, *args):
|
|
if sql.strip().upper().startswith("PRAGMA KEY"):
|
|
return self._raw.execute("SELECT 1", *args)
|
|
return self._raw.execute(sql, *args)
|
|
|
|
def commit(self):
|
|
self._raw.commit()
|
|
|
|
def close(self):
|
|
self._raw.close()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self._raw.close()
|
|
|
|
def connect(path, *args, **kwargs):
|
|
raw = sqlite3.connect(path, *args, **kwargs)
|
|
return _Connection(raw)
|
|
|
|
dbapi = types.ModuleType("pysqlcipher3.dbapi2")
|
|
dbapi.connect = connect
|
|
pkg = types.ModuleType("pysqlcipher3")
|
|
pkg.dbapi2 = dbapi
|
|
sys.modules["pysqlcipher3"] = pkg
|
|
sys.modules["pysqlcipher3.dbapi2"] = dbapi
|
|
|
|
|
|
_install_sqlcipher_shim()
|
|
|
|
os.environ.setdefault("PINVAULT_MASTER_HASH", "$2b$12$" + "a" * 53)
|
|
os.environ.setdefault("PINVAULT_DB", os.path.join(_TEST_ROOT, "pinvault.db"))
|
|
os.environ.setdefault("PINVAULT_LOCAL_BACKUP_DIR", os.path.join(_TEST_ROOT, "backups"))
|
|
os.environ.setdefault("PINVAULT_NAS_BACKUP_DIR", "")
|
|
os.environ.setdefault("PINVAULT_API_KEY", "test-api-key-0123456789abcdef0123456789abcd")
|
|
|
|
import app as pinvault # noqa: E402
|
|
|
|
import pytest # noqa: E402
|
|
|
|
AUTH = {"Authorization": f"Bearer {os.environ['PINVAULT_API_KEY']}"}
|
|
|
|
|
|
def create_pin(client, label="test", lock_days=30):
|
|
resp = client.post("/api/pins", json={"label": label, "lock_days": lock_days}, headers=AUTH)
|
|
assert resp.status_code == 201, resp.get_json()
|
|
return resp.get_json()
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
pinvault._pin_attempt_locks.clear()
|
|
return pinvault.app.test_client()
|