diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml index 5c7e53f..966c5d6 100644 --- a/.github/workflows/build-tests.yml +++ b/.github/workflows/build-tests.yml @@ -13,3 +13,4 @@ jobs: python_versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]' install_extras: '' test_path: 'tests' + pr_comment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1b0b9e3..9c3e05a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -15,3 +15,4 @@ jobs: test_path: 'tests/' install_extras: '' min_coverage: 0 + pr_comment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml index 8757eee..185e09c 100644 --- a/.github/workflows/license_check.yml +++ b/.github/workflows/license_check.yml @@ -9,3 +9,5 @@ jobs: license_check: uses: OpenVoiceOS/gh-automations/.github/workflows/license-check.yml@dev secrets: inherit + with: + pr_comment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9a6b7a5..331fdef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,3 +12,4 @@ jobs: with: ruff: true pre_commit: false # set true if .pre-commit-config.yaml exists + pr_comment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/pip_audit.yml b/.github/workflows/pip_audit.yml index bb3ca4d..cf9ef22 100644 --- a/.github/workflows/pip_audit.yml +++ b/.github/workflows/pip_audit.yml @@ -9,3 +9,5 @@ jobs: pip_audit: uses: OpenVoiceOS/gh-automations/.github/workflows/pip-audit.yml@dev secrets: inherit + with: + pr_comment: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 3404ca7..9b5b5d7 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -7,6 +7,7 @@ on: jobs: release_preview: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: OpenVoiceOS/gh-automations/.github/workflows/release-preview.yml@dev secrets: inherit with: diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml index 96f690d..7487046 100644 --- a/.github/workflows/repo-health.yml +++ b/.github/workflows/repo-health.yml @@ -7,6 +7,7 @@ on: jobs: repo_health: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: OpenVoiceOS/gh-automations/.github/workflows/repo-health.yml@dev secrets: inherit with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3a7e54b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test-plain: + name: "SQLite (no encryption)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -e ".[dev]" || pip install -e . + - run: pip install pytest + - run: pytest tests/test_sqlitedb.py -q -p no:ovoscope + + test-cipher: + name: "SQLite + SQLCipher" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install sqlcipher system library + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + - run: pip install -e ".[cipher]" + - run: pip install pytest + - run: pytest tests/test_sqlitedb.py -q -p no:ovoscope diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d004c0..3c659e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,24 @@ # Changelog -## [0.2.1a4](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.2.1a4) (2026-04-15) +## [0.3.0a2](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.3.0a2) (2026-05-18) -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.2.1a3...0.2.1a4) +[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.3.0a1...0.3.0a2) -**Merged pull requests:** - -- ci: sync publish workflows from shared templates [\#22](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/22) ([JarbasAl](https://github.com/JarbasAl)) - -## [0.2.1a3](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.2.1a3) (2026-04-15) - -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.2.1a2...0.2.1a3) - -## [0.2.1a2](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.2.1a2) (2026-04-15) - -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.2.1a1...0.2.1a2) - -**Merged pull requests:** - -- Update actions/setup-python action to v6 [\#15](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/15) ([renovate[bot]](https://github.com/apps/renovate)) -- Update actions/checkout action to v6 [\#9](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/9) ([renovate[bot]](https://github.com/apps/renovate)) - -## [0.2.1a1](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.2.1a1) (2026-04-15) - -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.2.0a1...0.2.1a1) - -**Merged pull requests:** - -- fix: move pytest\_plugins to top-level conftest for pytest 9 compatibility [\#18](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/18) ([JarbasAl](https://github.com/JarbasAl)) - -## [0.2.0a1](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.2.0a1) (2026-04-15) - -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.0.4a2...0.2.0a1) - -**Merged pull requests:** - -- feat: release 0.1.0 — tests, thread-safety, SQL injection fix [\#14](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/14) ([JarbasAl](https://github.com/JarbasAl)) - -## [0.0.4a2](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.0.4a2) (2025-12-19) +**Closed issues:** -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.0.4a1...0.0.4a2) +- security: encrypted db [\#2](https://github.com/JarbasHiveMind/hivemind-sqlite-database/issues/2) **Merged pull requests:** -- chore\(deps\): update actions/setup-python action to v6 [\#12](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/12) ([renovate[bot]](https://github.com/apps/renovate)) +- Preserve client metadata \(supersedes \#29\) [\#30](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/30) ([JarbasAl](https://github.com/JarbasAl)) -## [0.0.4a1](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.0.4a1) (2025-12-18) +## [0.3.0a1](https://github.com/JarbasHiveMind/hivemind-sqlite-database/tree/0.3.0a1) (2026-04-15) -[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.0.3...0.0.4a1) +[Full Changelog](https://github.com/JarbasHiveMind/hivemind-sqlite-database/compare/0.2.1...0.3.0a1) **Merged pull requests:** -- chore: Configure Renovate [\#7](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/7) ([renovate[bot]](https://github.com/apps/renovate)) +- feat: add SQLCipher encryption support \(password kwarg\) [\#24](https://github.com/JarbasHiveMind/hivemind-sqlite-database/pull/24) ([JarbasAl](https://github.com/JarbasAl)) diff --git a/README.md b/README.md index d8a0498..3a420b5 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ -# HiveMind SQLite Database \ No newline at end of file +# HiveMind SQLite Database + +SQLite database plugin for [hivemind-core](https://github.com/JarbasHiveMind/HiveMind-core). + +Implements the `AbstractDB` interface via `hivemind-plugin-manager` and stores HiveMind +client records (API keys, crypto keys, access-control lists) in a local SQLite file. + +## Installation + +```bash +pip install hivemind-sqlite-database +``` + +### With encryption support (SQLCipher) + +```bash +# 1. Install the SQLCipher system library +# Debian/Ubuntu: +sudo apt install libsqlcipher0 + +# 2. Install the Python binding via the optional extra +pip install "hivemind-sqlite-database[cipher]" +``` + +> The `sqlcipher3` wheel on PyPI ships its own libsqlcipher for x86_64 Linux, so the +> `apt` step may be optional on that platform. On ARM or Alpine you must build from +> source and will need the system library. + +## Usage + +### Plain (unencrypted) database — default + +```python +from hivemind_sqlite_database import SQLiteDB + +db = SQLiteDB() # stores data in XDG_DATA_HOME/hivemind-core/clients.db +``` + +### Encrypted database (SQLCipher / AES-256) + +```python +from hivemind_sqlite_database import SQLiteDB + +db = SQLiteDB(password="your-strong-passphrase") +``` + +Pass the same `password` every time you open the database. The encryption is +transparent — all existing methods (`add_item`, `search_by_value`, etc.) work +identically. + +> **Data-loss warning**: There is no password recovery. If you lose the passphrase +> the database is permanently unrecoverable. Back up your passphrase securely. + +### hivemind-core configuration + +```json +{ + "database": { + "module": "hivemind-sqlite-db-plugin", + "hivemind-sqlite-db-plugin": { + "name": "clients", + "subfolder": "hivemind-core", + "password": "your-strong-passphrase" + } + } +} +``` + +Leave `"password"` out (or set it to `null`) to use an unencrypted database. + +## Notes + +- An encrypted database cannot be opened by the plain `sqlite3` CLI or stdlib module. +- A plaintext database cannot be opened as encrypted. There is no automatic migration. +- The `password` field maps directly to SQLCipher's `PRAGMA key`. +- WAL journal mode is enabled for both encrypted and unencrypted databases. diff --git a/hivemind_sqlite_database/__init__.py b/hivemind_sqlite_database/__init__.py index 2700b90..9642b21 100644 --- a/hivemind_sqlite_database/__init__.py +++ b/hivemind_sqlite_database/__init__.py @@ -2,7 +2,7 @@ import os.path import sqlite3 import threading -from typing import List, Union, Iterable +from typing import List, Optional, Union, Iterable from ovos_utils.log import LOG from ovos_utils.xdg_utils import xdg_data_home @@ -11,11 +11,12 @@ from dataclasses import dataclass + _VALID_COLUMNS = frozenset({ "client_id", "api_key", "name", "description", "is_admin", "last_seen", "intent_blacklist", "skill_blacklist", "message_blacklist", "allowed_types", "crypto_key", "password", - "can_broadcast", "can_escalate", "can_propagate", + "can_broadcast", "can_escalate", "can_propagate", "metadata", }) @@ -24,17 +25,43 @@ class SQLiteDB(AbstractDB): """Database implementation using SQLite.""" name: str = "clients" subfolder: str = "hivemind-core" + password: Optional[str] = None def __post_init__(self): """ Initialize the SQLiteDB connection. + + When *password* is set the database is opened via ``sqlcipher3`` and + encrypted with AES-256 (SQLCipher). The system library + ``libsqlcipher0`` must be installed and ``sqlcipher3`` must be + available (``pip install hivemind-sqlite-database[cipher]``). + + When *password* is ``None`` (default) the standard ``sqlite3`` module + is used and the database file is unencrypted. """ db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db") LOG.debug(f"sqlite database path: {db_path}") os.makedirs(os.path.dirname(db_path), exist_ok=True) - self.conn = sqlite3.connect(db_path, check_same_thread=False) - self.conn.row_factory = sqlite3.Row + if self.password is not None: + if self.password == "": + raise ValueError("password must be non-empty when encryption is enabled") + try: + import sqlcipher3 as _sqlcipher + except ImportError: + raise ImportError( + "sqlcipher3 is required to open an encrypted SQLite database. " + "Install the system library (e.g. 'apt install libsqlcipher-dev') " + "then: pip install hivemind-sqlite-database[cipher]" + ) + self.conn = _sqlcipher.connect(db_path, check_same_thread=False) + self.conn.row_factory = _sqlcipher.Row + escaped_password = self.password.replace("'", "''") + self.conn.execute(f"PRAGMA key='{escaped_password}'") + else: + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") self._write_lock = threading.Lock() self._initialize_database() @@ -60,9 +87,16 @@ def _initialize_database(self): password TEXT, can_broadcast BOOLEAN DEFAULT TRUE, can_escalate BOOLEAN DEFAULT TRUE, - can_propagate BOOLEAN DEFAULT TRUE + can_propagate BOOLEAN DEFAULT TRUE, + metadata TEXT ) """) + columns = { + row["name"] + for row in self.conn.execute("PRAGMA table_info(clients)").fetchall() + } + if "metadata" not in columns: + self.conn.execute("ALTER TABLE clients ADD COLUMN metadata TEXT") def add_item(self, client: Client) -> bool: """ @@ -75,14 +109,15 @@ def add_item(self, client: Client) -> bool: True if the addition was successful, False otherwise. """ try: + metadata_json = self._metadata_to_json(client.metadata or {}) with self._write_lock, self.conn: self.conn.execute(""" INSERT OR REPLACE INTO clients ( client_id, api_key, name, description, is_admin, last_seen, intent_blacklist, skill_blacklist, message_blacklist, allowed_types, crypto_key, password, - can_broadcast, can_escalate, can_propagate - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + can_broadcast, can_escalate, can_propagate, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( client.client_id, client.api_key, client.name, client.description, client.is_admin, client.last_seen, @@ -91,7 +126,8 @@ def add_item(self, client: Client) -> bool: json.dumps(client.message_blacklist), json.dumps(client.allowed_types), client.crypto_key, client.password, - client.can_broadcast, client.can_escalate, client.can_propagate + client.can_broadcast, client.can_escalate, client.can_propagate, + metadata_json, )) return True except sqlite3.Error as e: @@ -154,20 +190,55 @@ def commit(self) -> bool: @staticmethod def _row_to_client(row: sqlite3.Row) -> Client: """Convert a database row to a Client instance.""" - return Client( - client_id=int(row["client_id"]), - api_key=row["api_key"], - name=row["name"], - description=row["description"], - is_admin=bool(row["is_admin"]), - last_seen=row["last_seen"], - intent_blacklist=json.loads(row["intent_blacklist"] or "[]"), - skill_blacklist=json.loads(row["skill_blacklist"] or "[]"), - message_blacklist=json.loads(row["message_blacklist"] or "[]"), - allowed_types=json.loads(row["allowed_types"] or "[]"), - crypto_key=row["crypto_key"], - password=row["password"], - can_broadcast=bool(row["can_broadcast"]), - can_escalate=bool(row["can_escalate"]), - can_propagate=bool(row["can_propagate"]) - ) + kwargs = { + "client_id": int(row["client_id"]), + "api_key": row["api_key"], + "name": row["name"], + "description": row["description"], + "is_admin": bool(row["is_admin"]), + "last_seen": row["last_seen"], + "intent_blacklist": json.loads(row["intent_blacklist"] or "[]"), + "skill_blacklist": json.loads(row["skill_blacklist"] or "[]"), + "message_blacklist": json.loads(row["message_blacklist"] or "[]"), + "allowed_types": json.loads(row["allowed_types"] or "[]"), + "crypto_key": row["crypto_key"], + "password": row["password"], + "can_broadcast": bool(row["can_broadcast"]), + "can_escalate": bool(row["can_escalate"]), + "can_propagate": bool(row["can_propagate"]), + "metadata": SQLiteDB._metadata_from_row(row), + } + return Client(**kwargs) + + @staticmethod + def _metadata_to_json(metadata: object) -> str: + """Serialize ``Client.metadata`` for storage in the ``metadata`` column. + + ``metadata`` is documented as a free-form dict; we use ``default=str`` + so callers can stash convenience values like ``datetime`` or ``UUID`` + without crashing on insert. Note that these come back as strings on + read — the column is opaque JSON, not a typed map. Returns ``"{}"`` + for non-dict input and on any (unexpected) serialisation failure + rather than corrupting the row. + """ + if not isinstance(metadata, dict): + return "{}" + try: + return json.dumps(metadata, default=str) + except (TypeError, ValueError): + return "{}" + + @staticmethod + def _metadata_from_row(row: sqlite3.Row) -> dict: + """Decode the ``metadata`` column into a dict, swallowing garbage. + + Returns ``{}`` for NULL, malformed JSON, or valid-JSON-that-isn't-an-object. + Lets a single bad row not poison iteration over the table. + """ + if "metadata" not in row.keys() or not row["metadata"]: + return {} + try: + metadata = json.loads(row["metadata"]) + except (TypeError, ValueError): + return {} + return metadata if isinstance(metadata, dict) else {} diff --git a/hivemind_sqlite_database/version.py b/hivemind_sqlite_database/version.py index 58d7648..af8c8b5 100644 --- a/hivemind_sqlite_database/version.py +++ b/hivemind_sqlite_database/version.py @@ -1,8 +1,8 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 -VERSION_MINOR = 2 -VERSION_BUILD = 1 -VERSION_ALPHA = 4 +VERSION_MINOR = 3 +VERSION_BUILD = 0 +VERSION_ALPHA = 2 # END_VERSION_BLOCK __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") diff --git a/pyproject.toml b/pyproject.toml index 1833c0a..db99161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,15 @@ license = { text = "Apache-2.0" } authors = [{ name = "jarbasAi", email = "jarbasai@mailfence.com" }] requires-python = ">=3.9" dependencies = [ - "hivemind-plugin-manager>=0.1.0,<1.0.0", + "hivemind-plugin-manager>=0.5.0,<1.0.0", "hivemind-bus-client", "ovos-utils", ] +[project.optional-dependencies] +cipher = ["sqlcipher3"] + [project.urls] Homepage = "https://github.com/JarbasHiveMind/hivemind-sqlite-database" diff --git a/tests/test_sqlitedb.py b/tests/test_sqlitedb.py index 1b414bd..9477283 100644 --- a/tests/test_sqlitedb.py +++ b/tests/test_sqlitedb.py @@ -236,9 +236,150 @@ def test_boolean_fields_remain_bool_after_round_trip(self): self.assertIs(type(results[0].can_broadcast), bool) self.assertFalse(results[0].can_broadcast) + def test_metadata_survives_round_trip(self): + db = make_db() + db.add_item( + make_client( + 1, + "k1", + metadata={"owner_id": "owner-123"}, + ) + ) + + results = db.search_by_value("api_key", "k1") + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].metadata, {"owner_id": "owner-123"}) + + def test_metadata_can_be_searched(self): + db = make_db() + db.add_item( + make_client( + 1, + "k1", + metadata={"owner_id": "owner-123"}, + ) + ) + + results = db.search_by_value("metadata", '{"owner_id": "owner-123"}') + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].api_key, "k1") + + def test_initialize_database_migrates_legacy_clients_table(self): + db = object.__new__(SQLiteDB) + db.name = "clients" + db.subfolder = "hivemind-core" + db.conn = sqlite3.connect(":memory:", check_same_thread=False) + db.conn.row_factory = sqlite3.Row + db._write_lock = threading.Lock() + with db.conn: + db.conn.execute(""" + CREATE TABLE clients ( + client_id INTEGER PRIMARY KEY, + api_key VARCHAR(255) NOT NULL, + name VARCHAR(255), + description VARCHAR(255), + is_admin BOOLEAN DEFAULT FALSE, + last_seen REAL DEFAULT -1, + intent_blacklist TEXT, + skill_blacklist TEXT, + message_blacklist TEXT, + allowed_types TEXT, + crypto_key VARCHAR(16), + password TEXT, + can_broadcast BOOLEAN DEFAULT TRUE, + can_escalate BOOLEAN DEFAULT TRUE, + can_propagate BOOLEAN DEFAULT TRUE + ) + """) + db.conn.execute("INSERT INTO clients (client_id, api_key) VALUES (1, 'k1')") + + db._initialize_database() + + columns = { + row["name"] + for row in db.conn.execute("PRAGMA table_info(clients)").fetchall() + } + self.assertIn("metadata", columns) + client = db.search_by_value("api_key", "k1")[0] + self.assertEqual(client.metadata, {}) + + def test_metadata_nested_dict_round_trip(self): + db = make_db() + meta = { + "owner": {"id": "owner-1", "tags": ["a", "b"]}, + "counts": {"x": 1, "y": 2}, + } + db.add_item(make_client(1, "k1", metadata=meta)) + results = db.search_by_value("api_key", "k1") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].metadata, meta) + + def test_metadata_non_ascii_round_trip(self): + db = make_db() + meta = {"name": "Zé Ninguém", "emoji": "🚀", "ru": "Привет"} + db.add_item(make_client(1, "k1", metadata=meta)) + results = db.search_by_value("api_key", "k1") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].metadata, meta) + + def test_metadata_survives_iteration(self): + db = make_db() + db.add_item( + make_client( + 1, + "k1", + metadata={"owner_id": "owner-123"}, + ) + ) + + clients = list(db) + + self.assertEqual(len(clients), 1) + self.assertEqual(clients[0].metadata, {"owner_id": "owner-123"}) + + def test_metadata_defaults_to_empty_dict_when_not_provided(self): + db = make_db() + db.add_item(make_client(1, "k1")) + results = db.search_by_value("api_key", "k1") + self.assertEqual(results[0].metadata, {}) + + def test_metadata_overwritten_on_reinsert_with_same_client_id(self): + db = make_db() + db.add_item(make_client(1, "k1", metadata={"v": 1})) + db.add_item(make_client(1, "k1", metadata={"v": 2, "extra": "x"})) + results = db.search_by_value("api_key", "k1") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].metadata, {"v": 2, "extra": "x"}) + + def test_metadata_to_json_returns_empty_for_non_dict(self): + self.assertEqual(SQLiteDB._metadata_to_json("not a dict"), "{}") + self.assertEqual(SQLiteDB._metadata_to_json(None), "{}") + self.assertEqual(SQLiteDB._metadata_to_json(42), "{}") + + def test_metadata_from_row_returns_empty_for_garbage_or_missing(self): + db = make_db() + # legacy-style row with explicit NULL metadata + db.add_item(make_client(1, "k1")) + with db.conn: + db.conn.execute("UPDATE clients SET metadata = NULL WHERE client_id = 1") + results = db.search_by_value("api_key", "k1") + self.assertEqual(results[0].metadata, {}) + # garbage JSON in the metadata column → coerce to {} + with db.conn: + db.conn.execute("UPDATE clients SET metadata = 'not json{' WHERE client_id = 1") + results = db.search_by_value("api_key", "k1") + self.assertEqual(results[0].metadata, {}) + # valid JSON but not an object → coerce to {} + with db.conn: + db.conn.execute("UPDATE clients SET metadata = '[1,2,3]' WHERE client_id = 1") + results = db.search_by_value("api_key", "k1") + self.assertEqual(results[0].metadata, {}) + def test_full_client_fields_preserved(self): db = make_db() - c = Client( + c = make_client( client_id=42, api_key="full-key", name="test-client", @@ -254,6 +395,7 @@ def test_full_client_fields_preserved(self): can_broadcast=True, can_escalate=False, can_propagate=True, + metadata={"owner_id": "owner-123"}, ) db.add_item(c) results = db.search_by_value("api_key", "full-key") @@ -267,6 +409,7 @@ def test_full_client_fields_preserved(self): self.assertEqual(r.crypto_key, "1234567890123456") self.assertEqual(r.password, "secret") self.assertFalse(r.can_escalate) + self.assertEqual(r.metadata, {"owner_id": "owner-123"}) class TestSQLiteDBCommit(unittest.TestCase): @@ -302,5 +445,92 @@ def test_replace_item(self): self.assertEqual(db.search_by_value("api_key", "revoked")[0].client_id, 1) +try: + import sqlcipher3 as _sqlcipher3 # noqa: F401 + _SQLCIPHER_AVAILABLE = True +except ImportError: + _SQLCIPHER_AVAILABLE = False + + +@unittest.skipUnless(_SQLCIPHER_AVAILABLE, "sqlcipher3 not installed") +class TestSQLiteDBEncrypted(unittest.TestCase): + """Tests for the SQLCipher-encrypted path. Skipped when sqlcipher3 is absent.""" + + def _make_encrypted_db(self, path: str, password: str = "hunter2") -> SQLiteDB: + import unittest.mock as mock + db = SQLiteDB.__new__(SQLiteDB) + db.name = os.path.splitext(os.path.basename(path))[0] + db.subfolder = "" + db.password = password + with mock.patch( + "hivemind_sqlite_database.xdg_data_home", + return_value=os.path.dirname(path), + ): + db.__post_init__() + return db + + def test_encrypted_file_unreadable_by_stdlib_sqlite3(self): + """A file created with a password must be opaque to plain sqlite3.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + os.unlink(path) + try: + db = self._make_encrypted_db(path, password="secret123") + db.add_item(make_client(1, "enc-key")) + db.commit() + # stdlib sqlite3 should not be able to read it + plain_conn = sqlite3.connect(path) + with self.assertRaises(sqlite3.DatabaseError): + plain_conn.execute("SELECT * FROM clients").fetchall() + plain_conn.close() + finally: + if os.path.exists(path): + os.unlink(path) + + def test_encrypted_round_trip(self): + """add_item then search_by_value works through the encryption layer.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + os.unlink(path) + try: + db = self._make_encrypted_db(path, password="roundtrip") + db.add_item(make_client(1, "enc-api-key", name="alice")) + db.commit() + # Reopen with same password + db2 = self._make_encrypted_db(path, password="roundtrip") + results = db2.search_by_value("api_key", "enc-api-key") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].name, "alice") + finally: + if os.path.exists(path): + os.unlink(path) + + def test_sqlitedb_password_kwarg_raises_importerror_without_sqlcipher3(self): + """Confirmed separately in test_sqlitedb_no_sqlcipher.py; skip here.""" + pass + + +class TestSQLiteDBMissingCipher(unittest.TestCase): + """Verify ImportError is raised when sqlcipher3 is absent and password is given.""" + + def test_importerror_when_sqlcipher3_missing(self): + import sys + import unittest.mock as mock + + # Simulate sqlcipher3 not being installed + with mock.patch.dict(sys.modules, {"sqlcipher3": None}): + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(ImportError) as ctx: + db = SQLiteDB.__new__(SQLiteDB) + db.name = "test" + db.subfolder = tmpdir + db.password = "secret" + # Patch xdg_data_home so db_path resolves inside tmpdir + with mock.patch("hivemind_sqlite_database.xdg_data_home", + return_value=tmpdir): + db.__post_init__() + self.assertIn("sqlcipher3", str(ctx.exception)) + + if __name__ == "__main__": unittest.main()