hivemind-sqlite-database 0.2.1__tar.gz → 0.3.0a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (16) hide show
  1. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/PKG-INFO +4 -2
  2. hivemind_sqlite_database-0.3.0a4/README.md +76 -0
  3. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database/__init__.py +96 -25
  4. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database/version.py +3 -3
  5. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database.egg-info/PKG-INFO +4 -2
  6. hivemind_sqlite_database-0.3.0a4/hivemind_sqlite_database.egg-info/requires.txt +6 -0
  7. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/pyproject.toml +4 -1
  8. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/tests/test_sqlitedb.py +231 -1
  9. hivemind_sqlite_database-0.2.1/README.md +0 -1
  10. hivemind_sqlite_database-0.2.1/hivemind_sqlite_database.egg-info/requires.txt +0 -3
  11. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/LICENSE.md +0 -0
  12. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database.egg-info/SOURCES.txt +0 -0
  13. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database.egg-info/dependency_links.txt +0 -0
  14. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database.egg-info/entry_points.txt +0 -0
  15. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/hivemind_sqlite_database.egg-info/top_level.txt +0 -0
  16. {hivemind_sqlite_database-0.2.1 → hivemind_sqlite_database-0.3.0a4}/setup.cfg +0 -0
@@ -1,13 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.2.1
3
+ Version: 0.3.0a4
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-sqlite-database
8
8
  Requires-Python: >=3.9
9
9
  License-File: LICENSE.md
10
- Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.1.0
10
+ Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
11
11
  Requires-Dist: hivemind-bus-client
12
12
  Requires-Dist: ovos-utils
13
+ Provides-Extra: cipher
14
+ Requires-Dist: sqlcipher3; extra == "cipher"
13
15
  Dynamic: license-file
@@ -0,0 +1,76 @@
1
+ # HiveMind SQLite Database
2
+
3
+ SQLite database plugin for [hivemind-core](https://github.com/JarbasHiveMind/HiveMind-core).
4
+
5
+ Implements the `AbstractDB` interface via `hivemind-plugin-manager` and stores HiveMind
6
+ client records (API keys, crypto keys, access-control lists) in a local SQLite file.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install hivemind-sqlite-database
12
+ ```
13
+
14
+ ### With encryption support (SQLCipher)
15
+
16
+ ```bash
17
+ # 1. Install the SQLCipher system library
18
+ # Debian/Ubuntu:
19
+ sudo apt install libsqlcipher0
20
+
21
+ # 2. Install the Python binding via the optional extra
22
+ pip install "hivemind-sqlite-database[cipher]"
23
+ ```
24
+
25
+ > The `sqlcipher3` wheel on PyPI ships its own libsqlcipher for x86_64 Linux, so the
26
+ > `apt` step may be optional on that platform. On ARM or Alpine you must build from
27
+ > source and will need the system library.
28
+
29
+ ## Usage
30
+
31
+ ### Plain (unencrypted) database — default
32
+
33
+ ```python
34
+ from hivemind_sqlite_database import SQLiteDB
35
+
36
+ db = SQLiteDB() # stores data in XDG_DATA_HOME/hivemind-core/clients.db
37
+ ```
38
+
39
+ ### Encrypted database (SQLCipher / AES-256)
40
+
41
+ ```python
42
+ from hivemind_sqlite_database import SQLiteDB
43
+
44
+ db = SQLiteDB(password="your-strong-passphrase")
45
+ ```
46
+
47
+ Pass the same `password` every time you open the database. The encryption is
48
+ transparent — all existing methods (`add_item`, `search_by_value`, etc.) work
49
+ identically.
50
+
51
+ > **Data-loss warning**: There is no password recovery. If you lose the passphrase
52
+ > the database is permanently unrecoverable. Back up your passphrase securely.
53
+
54
+ ### hivemind-core configuration
55
+
56
+ ```json
57
+ {
58
+ "database": {
59
+ "module": "hivemind-sqlite-db-plugin",
60
+ "hivemind-sqlite-db-plugin": {
61
+ "name": "clients",
62
+ "subfolder": "hivemind-core",
63
+ "password": "your-strong-passphrase"
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Leave `"password"` out (or set it to `null`) to use an unencrypted database.
70
+
71
+ ## Notes
72
+
73
+ - An encrypted database cannot be opened by the plain `sqlite3` CLI or stdlib module.
74
+ - A plaintext database cannot be opened as encrypted. There is no automatic migration.
75
+ - The `password` field maps directly to SQLCipher's `PRAGMA key`.
76
+ - WAL journal mode is enabled for both encrypted and unencrypted databases.
@@ -2,7 +2,7 @@ import json
2
2
  import os.path
3
3
  import sqlite3
4
4
  import threading
5
- from typing import List, Union, Iterable
5
+ from typing import List, Optional, Union, Iterable
6
6
 
7
7
  from ovos_utils.log import LOG
8
8
  from ovos_utils.xdg_utils import xdg_data_home
@@ -11,11 +11,12 @@ from hivemind_plugin_manager.database import Client, AbstractDB
11
11
 
12
12
  from dataclasses import dataclass
13
13
 
14
+
14
15
  _VALID_COLUMNS = frozenset({
15
16
  "client_id", "api_key", "name", "description", "is_admin",
16
17
  "last_seen", "intent_blacklist", "skill_blacklist", "message_blacklist",
17
18
  "allowed_types", "crypto_key", "password",
18
- "can_broadcast", "can_escalate", "can_propagate",
19
+ "can_broadcast", "can_escalate", "can_propagate", "metadata",
19
20
  })
20
21
 
21
22
 
@@ -24,17 +25,43 @@ class SQLiteDB(AbstractDB):
24
25
  """Database implementation using SQLite."""
25
26
  name: str = "clients"
26
27
  subfolder: str = "hivemind-core"
28
+ password: Optional[str] = None
27
29
 
28
30
  def __post_init__(self):
29
31
  """
30
32
  Initialize the SQLiteDB connection.
33
+
34
+ When *password* is set the database is opened via ``sqlcipher3`` and
35
+ encrypted with AES-256 (SQLCipher). The system library
36
+ ``libsqlcipher0`` must be installed and ``sqlcipher3`` must be
37
+ available (``pip install hivemind-sqlite-database[cipher]``).
38
+
39
+ When *password* is ``None`` (default) the standard ``sqlite3`` module
40
+ is used and the database file is unencrypted.
31
41
  """
32
42
  db_path = os.path.join(xdg_data_home(), self.subfolder, self.name + ".db")
33
43
  LOG.debug(f"sqlite database path: {db_path}")
34
44
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
35
45
 
36
- self.conn = sqlite3.connect(db_path, check_same_thread=False)
37
- self.conn.row_factory = sqlite3.Row
46
+ if self.password is not None:
47
+ if self.password == "":
48
+ raise ValueError("password must be non-empty when encryption is enabled")
49
+ try:
50
+ import sqlcipher3 as _sqlcipher
51
+ except ImportError:
52
+ raise ImportError(
53
+ "sqlcipher3 is required to open an encrypted SQLite database. "
54
+ "Install the system library (e.g. 'apt install libsqlcipher-dev') "
55
+ "then: pip install hivemind-sqlite-database[cipher]"
56
+ )
57
+ self.conn = _sqlcipher.connect(db_path, check_same_thread=False)
58
+ self.conn.row_factory = _sqlcipher.Row
59
+ escaped_password = self.password.replace("'", "''")
60
+ self.conn.execute(f"PRAGMA key='{escaped_password}'")
61
+ else:
62
+ self.conn = sqlite3.connect(db_path, check_same_thread=False)
63
+ self.conn.row_factory = sqlite3.Row
64
+
38
65
  self.conn.execute("PRAGMA journal_mode=WAL")
39
66
  self._write_lock = threading.Lock()
40
67
  self._initialize_database()
@@ -60,9 +87,16 @@ class SQLiteDB(AbstractDB):
60
87
  password TEXT,
61
88
  can_broadcast BOOLEAN DEFAULT TRUE,
62
89
  can_escalate BOOLEAN DEFAULT TRUE,
63
- can_propagate BOOLEAN DEFAULT TRUE
90
+ can_propagate BOOLEAN DEFAULT TRUE,
91
+ metadata TEXT
64
92
  )
65
93
  """)
94
+ columns = {
95
+ row["name"]
96
+ for row in self.conn.execute("PRAGMA table_info(clients)").fetchall()
97
+ }
98
+ if "metadata" not in columns:
99
+ self.conn.execute("ALTER TABLE clients ADD COLUMN metadata TEXT")
66
100
 
67
101
  def add_item(self, client: Client) -> bool:
68
102
  """
@@ -75,14 +109,15 @@ class SQLiteDB(AbstractDB):
75
109
  True if the addition was successful, False otherwise.
76
110
  """
77
111
  try:
112
+ metadata_json = self._metadata_to_json(client.metadata or {})
78
113
  with self._write_lock, self.conn:
79
114
  self.conn.execute("""
80
115
  INSERT OR REPLACE INTO clients (
81
116
  client_id, api_key, name, description, is_admin,
82
117
  last_seen, intent_blacklist, skill_blacklist,
83
118
  message_blacklist, allowed_types, crypto_key, password,
84
- can_broadcast, can_escalate, can_propagate
85
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
119
+ can_broadcast, can_escalate, can_propagate, metadata
120
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
121
  """, (
87
122
  client.client_id, client.api_key, client.name, client.description,
88
123
  client.is_admin, client.last_seen,
@@ -91,7 +126,8 @@ class SQLiteDB(AbstractDB):
91
126
  json.dumps(client.message_blacklist),
92
127
  json.dumps(client.allowed_types),
93
128
  client.crypto_key, client.password,
94
- client.can_broadcast, client.can_escalate, client.can_propagate
129
+ client.can_broadcast, client.can_escalate, client.can_propagate,
130
+ metadata_json,
95
131
  ))
96
132
  return True
97
133
  except sqlite3.Error as e:
@@ -154,20 +190,55 @@ class SQLiteDB(AbstractDB):
154
190
  @staticmethod
155
191
  def _row_to_client(row: sqlite3.Row) -> Client:
156
192
  """Convert a database row to a Client instance."""
157
- return Client(
158
- client_id=int(row["client_id"]),
159
- api_key=row["api_key"],
160
- name=row["name"],
161
- description=row["description"],
162
- is_admin=bool(row["is_admin"]),
163
- last_seen=row["last_seen"],
164
- intent_blacklist=json.loads(row["intent_blacklist"] or "[]"),
165
- skill_blacklist=json.loads(row["skill_blacklist"] or "[]"),
166
- message_blacklist=json.loads(row["message_blacklist"] or "[]"),
167
- allowed_types=json.loads(row["allowed_types"] or "[]"),
168
- crypto_key=row["crypto_key"],
169
- password=row["password"],
170
- can_broadcast=bool(row["can_broadcast"]),
171
- can_escalate=bool(row["can_escalate"]),
172
- can_propagate=bool(row["can_propagate"])
173
- )
193
+ kwargs = {
194
+ "client_id": int(row["client_id"]),
195
+ "api_key": row["api_key"],
196
+ "name": row["name"],
197
+ "description": row["description"],
198
+ "is_admin": bool(row["is_admin"]),
199
+ "last_seen": row["last_seen"],
200
+ "intent_blacklist": json.loads(row["intent_blacklist"] or "[]"),
201
+ "skill_blacklist": json.loads(row["skill_blacklist"] or "[]"),
202
+ "message_blacklist": json.loads(row["message_blacklist"] or "[]"),
203
+ "allowed_types": json.loads(row["allowed_types"] or "[]"),
204
+ "crypto_key": row["crypto_key"],
205
+ "password": row["password"],
206
+ "can_broadcast": bool(row["can_broadcast"]),
207
+ "can_escalate": bool(row["can_escalate"]),
208
+ "can_propagate": bool(row["can_propagate"]),
209
+ "metadata": SQLiteDB._metadata_from_row(row),
210
+ }
211
+ return Client(**kwargs)
212
+
213
+ @staticmethod
214
+ def _metadata_to_json(metadata: object) -> str:
215
+ """Serialize ``Client.metadata`` for storage in the ``metadata`` column.
216
+
217
+ ``metadata`` is documented as a free-form dict; we use ``default=str``
218
+ so callers can stash convenience values like ``datetime`` or ``UUID``
219
+ without crashing on insert. Note that these come back as strings on
220
+ read — the column is opaque JSON, not a typed map. Returns ``"{}"``
221
+ for non-dict input and on any (unexpected) serialisation failure
222
+ rather than corrupting the row.
223
+ """
224
+ if not isinstance(metadata, dict):
225
+ return "{}"
226
+ try:
227
+ return json.dumps(metadata, default=str)
228
+ except (TypeError, ValueError):
229
+ return "{}"
230
+
231
+ @staticmethod
232
+ def _metadata_from_row(row: sqlite3.Row) -> dict:
233
+ """Decode the ``metadata`` column into a dict, swallowing garbage.
234
+
235
+ Returns ``{}`` for NULL, malformed JSON, or valid-JSON-that-isn't-an-object.
236
+ Lets a single bad row not poison iteration over the table.
237
+ """
238
+ if "metadata" not in row.keys() or not row["metadata"]:
239
+ return {}
240
+ try:
241
+ metadata = json.loads(row["metadata"])
242
+ except (TypeError, ValueError):
243
+ return {}
244
+ return metadata if isinstance(metadata, dict) else {}
@@ -1,8 +1,8 @@
1
1
  # START_VERSION_BLOCK
2
2
  VERSION_MAJOR = 0
3
- VERSION_MINOR = 2
4
- VERSION_BUILD = 1
5
- VERSION_ALPHA = 0
3
+ VERSION_MINOR = 3
4
+ VERSION_BUILD = 0
5
+ VERSION_ALPHA = 4
6
6
  # END_VERSION_BLOCK
7
7
 
8
8
  __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -1,13 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-sqlite-database
3
- Version: 0.2.1
3
+ Version: 0.3.0a4
4
4
  Summary: sqlite database plugin for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-sqlite-database
8
8
  Requires-Python: >=3.9
9
9
  License-File: LICENSE.md
10
- Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.1.0
10
+ Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
11
11
  Requires-Dist: hivemind-bus-client
12
12
  Requires-Dist: ovos-utils
13
+ Provides-Extra: cipher
14
+ Requires-Dist: sqlcipher3; extra == "cipher"
13
15
  Dynamic: license-file
@@ -0,0 +1,6 @@
1
+ hivemind-plugin-manager<1.0.0,>=0.5.0
2
+ hivemind-bus-client
3
+ ovos-utils
4
+
5
+ [cipher]
6
+ sqlcipher3
@@ -10,12 +10,15 @@ license = { text = "Apache-2.0" }
10
10
  authors = [{ name = "jarbasAi", email = "jarbasai@mailfence.com" }]
11
11
  requires-python = ">=3.9"
12
12
  dependencies = [
13
- "hivemind-plugin-manager>=0.1.0,<1.0.0",
13
+ "hivemind-plugin-manager>=0.5.0,<1.0.0",
14
14
  "hivemind-bus-client",
15
15
  "ovos-utils",
16
16
  ]
17
17
 
18
18
 
19
+ [project.optional-dependencies]
20
+ cipher = ["sqlcipher3"]
21
+
19
22
  [project.urls]
20
23
  Homepage = "https://github.com/JarbasHiveMind/hivemind-sqlite-database"
21
24
 
@@ -236,9 +236,150 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
236
236
  self.assertIs(type(results[0].can_broadcast), bool)
237
237
  self.assertFalse(results[0].can_broadcast)
238
238
 
239
+ def test_metadata_survives_round_trip(self):
240
+ db = make_db()
241
+ db.add_item(
242
+ make_client(
243
+ 1,
244
+ "k1",
245
+ metadata={"owner_id": "owner-123"},
246
+ )
247
+ )
248
+
249
+ results = db.search_by_value("api_key", "k1")
250
+
251
+ self.assertEqual(len(results), 1)
252
+ self.assertEqual(results[0].metadata, {"owner_id": "owner-123"})
253
+
254
+ def test_metadata_can_be_searched(self):
255
+ db = make_db()
256
+ db.add_item(
257
+ make_client(
258
+ 1,
259
+ "k1",
260
+ metadata={"owner_id": "owner-123"},
261
+ )
262
+ )
263
+
264
+ results = db.search_by_value("metadata", '{"owner_id": "owner-123"}')
265
+
266
+ self.assertEqual(len(results), 1)
267
+ self.assertEqual(results[0].api_key, "k1")
268
+
269
+ def test_initialize_database_migrates_legacy_clients_table(self):
270
+ db = object.__new__(SQLiteDB)
271
+ db.name = "clients"
272
+ db.subfolder = "hivemind-core"
273
+ db.conn = sqlite3.connect(":memory:", check_same_thread=False)
274
+ db.conn.row_factory = sqlite3.Row
275
+ db._write_lock = threading.Lock()
276
+ with db.conn:
277
+ db.conn.execute("""
278
+ CREATE TABLE clients (
279
+ client_id INTEGER PRIMARY KEY,
280
+ api_key VARCHAR(255) NOT NULL,
281
+ name VARCHAR(255),
282
+ description VARCHAR(255),
283
+ is_admin BOOLEAN DEFAULT FALSE,
284
+ last_seen REAL DEFAULT -1,
285
+ intent_blacklist TEXT,
286
+ skill_blacklist TEXT,
287
+ message_blacklist TEXT,
288
+ allowed_types TEXT,
289
+ crypto_key VARCHAR(16),
290
+ password TEXT,
291
+ can_broadcast BOOLEAN DEFAULT TRUE,
292
+ can_escalate BOOLEAN DEFAULT TRUE,
293
+ can_propagate BOOLEAN DEFAULT TRUE
294
+ )
295
+ """)
296
+ db.conn.execute("INSERT INTO clients (client_id, api_key) VALUES (1, 'k1')")
297
+
298
+ db._initialize_database()
299
+
300
+ columns = {
301
+ row["name"]
302
+ for row in db.conn.execute("PRAGMA table_info(clients)").fetchall()
303
+ }
304
+ self.assertIn("metadata", columns)
305
+ client = db.search_by_value("api_key", "k1")[0]
306
+ self.assertEqual(client.metadata, {})
307
+
308
+ def test_metadata_nested_dict_round_trip(self):
309
+ db = make_db()
310
+ meta = {
311
+ "owner": {"id": "owner-1", "tags": ["a", "b"]},
312
+ "counts": {"x": 1, "y": 2},
313
+ }
314
+ db.add_item(make_client(1, "k1", metadata=meta))
315
+ results = db.search_by_value("api_key", "k1")
316
+ self.assertEqual(len(results), 1)
317
+ self.assertEqual(results[0].metadata, meta)
318
+
319
+ def test_metadata_non_ascii_round_trip(self):
320
+ db = make_db()
321
+ meta = {"name": "Zé Ninguém", "emoji": "🚀", "ru": "Привет"}
322
+ db.add_item(make_client(1, "k1", metadata=meta))
323
+ results = db.search_by_value("api_key", "k1")
324
+ self.assertEqual(len(results), 1)
325
+ self.assertEqual(results[0].metadata, meta)
326
+
327
+ def test_metadata_survives_iteration(self):
328
+ db = make_db()
329
+ db.add_item(
330
+ make_client(
331
+ 1,
332
+ "k1",
333
+ metadata={"owner_id": "owner-123"},
334
+ )
335
+ )
336
+
337
+ clients = list(db)
338
+
339
+ self.assertEqual(len(clients), 1)
340
+ self.assertEqual(clients[0].metadata, {"owner_id": "owner-123"})
341
+
342
+ def test_metadata_defaults_to_empty_dict_when_not_provided(self):
343
+ db = make_db()
344
+ db.add_item(make_client(1, "k1"))
345
+ results = db.search_by_value("api_key", "k1")
346
+ self.assertEqual(results[0].metadata, {})
347
+
348
+ def test_metadata_overwritten_on_reinsert_with_same_client_id(self):
349
+ db = make_db()
350
+ db.add_item(make_client(1, "k1", metadata={"v": 1}))
351
+ db.add_item(make_client(1, "k1", metadata={"v": 2, "extra": "x"}))
352
+ results = db.search_by_value("api_key", "k1")
353
+ self.assertEqual(len(results), 1)
354
+ self.assertEqual(results[0].metadata, {"v": 2, "extra": "x"})
355
+
356
+ def test_metadata_to_json_returns_empty_for_non_dict(self):
357
+ self.assertEqual(SQLiteDB._metadata_to_json("not a dict"), "{}")
358
+ self.assertEqual(SQLiteDB._metadata_to_json(None), "{}")
359
+ self.assertEqual(SQLiteDB._metadata_to_json(42), "{}")
360
+
361
+ def test_metadata_from_row_returns_empty_for_garbage_or_missing(self):
362
+ db = make_db()
363
+ # legacy-style row with explicit NULL metadata
364
+ db.add_item(make_client(1, "k1"))
365
+ with db.conn:
366
+ db.conn.execute("UPDATE clients SET metadata = NULL WHERE client_id = 1")
367
+ results = db.search_by_value("api_key", "k1")
368
+ self.assertEqual(results[0].metadata, {})
369
+ # garbage JSON in the metadata column → coerce to {}
370
+ with db.conn:
371
+ db.conn.execute("UPDATE clients SET metadata = 'not json{' WHERE client_id = 1")
372
+ results = db.search_by_value("api_key", "k1")
373
+ self.assertEqual(results[0].metadata, {})
374
+ # valid JSON but not an object → coerce to {}
375
+ with db.conn:
376
+ db.conn.execute("UPDATE clients SET metadata = '[1,2,3]' WHERE client_id = 1")
377
+ results = db.search_by_value("api_key", "k1")
378
+ self.assertEqual(results[0].metadata, {})
379
+
239
380
  def test_full_client_fields_preserved(self):
240
381
  db = make_db()
241
- c = Client(
382
+ c = make_client(
242
383
  client_id=42,
243
384
  api_key="full-key",
244
385
  name="test-client",
@@ -254,6 +395,7 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
254
395
  can_broadcast=True,
255
396
  can_escalate=False,
256
397
  can_propagate=True,
398
+ metadata={"owner_id": "owner-123"},
257
399
  )
258
400
  db.add_item(c)
259
401
  results = db.search_by_value("api_key", "full-key")
@@ -267,6 +409,7 @@ class TestSQLiteDBRoundTrip(unittest.TestCase):
267
409
  self.assertEqual(r.crypto_key, "1234567890123456")
268
410
  self.assertEqual(r.password, "secret")
269
411
  self.assertFalse(r.can_escalate)
412
+ self.assertEqual(r.metadata, {"owner_id": "owner-123"})
270
413
 
271
414
 
272
415
  class TestSQLiteDBCommit(unittest.TestCase):
@@ -302,5 +445,92 @@ class TestSQLiteDBUpdateAndReplace(unittest.TestCase):
302
445
  self.assertEqual(db.search_by_value("api_key", "revoked")[0].client_id, 1)
303
446
 
304
447
 
448
+ try:
449
+ import sqlcipher3 as _sqlcipher3 # noqa: F401
450
+ _SQLCIPHER_AVAILABLE = True
451
+ except ImportError:
452
+ _SQLCIPHER_AVAILABLE = False
453
+
454
+
455
+ @unittest.skipUnless(_SQLCIPHER_AVAILABLE, "sqlcipher3 not installed")
456
+ class TestSQLiteDBEncrypted(unittest.TestCase):
457
+ """Tests for the SQLCipher-encrypted path. Skipped when sqlcipher3 is absent."""
458
+
459
+ def _make_encrypted_db(self, path: str, password: str = "hunter2") -> SQLiteDB:
460
+ import unittest.mock as mock
461
+ db = SQLiteDB.__new__(SQLiteDB)
462
+ db.name = os.path.splitext(os.path.basename(path))[0]
463
+ db.subfolder = ""
464
+ db.password = password
465
+ with mock.patch(
466
+ "hivemind_sqlite_database.xdg_data_home",
467
+ return_value=os.path.dirname(path),
468
+ ):
469
+ db.__post_init__()
470
+ return db
471
+
472
+ def test_encrypted_file_unreadable_by_stdlib_sqlite3(self):
473
+ """A file created with a password must be opaque to plain sqlite3."""
474
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
475
+ path = f.name
476
+ os.unlink(path)
477
+ try:
478
+ db = self._make_encrypted_db(path, password="secret123")
479
+ db.add_item(make_client(1, "enc-key"))
480
+ db.commit()
481
+ # stdlib sqlite3 should not be able to read it
482
+ plain_conn = sqlite3.connect(path)
483
+ with self.assertRaises(sqlite3.DatabaseError):
484
+ plain_conn.execute("SELECT * FROM clients").fetchall()
485
+ plain_conn.close()
486
+ finally:
487
+ if os.path.exists(path):
488
+ os.unlink(path)
489
+
490
+ def test_encrypted_round_trip(self):
491
+ """add_item then search_by_value works through the encryption layer."""
492
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
493
+ path = f.name
494
+ os.unlink(path)
495
+ try:
496
+ db = self._make_encrypted_db(path, password="roundtrip")
497
+ db.add_item(make_client(1, "enc-api-key", name="alice"))
498
+ db.commit()
499
+ # Reopen with same password
500
+ db2 = self._make_encrypted_db(path, password="roundtrip")
501
+ results = db2.search_by_value("api_key", "enc-api-key")
502
+ self.assertEqual(len(results), 1)
503
+ self.assertEqual(results[0].name, "alice")
504
+ finally:
505
+ if os.path.exists(path):
506
+ os.unlink(path)
507
+
508
+ def test_sqlitedb_password_kwarg_raises_importerror_without_sqlcipher3(self):
509
+ """Confirmed separately in test_sqlitedb_no_sqlcipher.py; skip here."""
510
+ pass
511
+
512
+
513
+ class TestSQLiteDBMissingCipher(unittest.TestCase):
514
+ """Verify ImportError is raised when sqlcipher3 is absent and password is given."""
515
+
516
+ def test_importerror_when_sqlcipher3_missing(self):
517
+ import sys
518
+ import unittest.mock as mock
519
+
520
+ # Simulate sqlcipher3 not being installed
521
+ with mock.patch.dict(sys.modules, {"sqlcipher3": None}):
522
+ with tempfile.TemporaryDirectory() as tmpdir:
523
+ with self.assertRaises(ImportError) as ctx:
524
+ db = SQLiteDB.__new__(SQLiteDB)
525
+ db.name = "test"
526
+ db.subfolder = tmpdir
527
+ db.password = "secret"
528
+ # Patch xdg_data_home so db_path resolves inside tmpdir
529
+ with mock.patch("hivemind_sqlite_database.xdg_data_home",
530
+ return_value=tmpdir):
531
+ db.__post_init__()
532
+ self.assertIn("sqlcipher3", str(ctx.exception))
533
+
534
+
305
535
  if __name__ == "__main__":
306
536
  unittest.main()
@@ -1 +0,0 @@
1
- # HiveMind SQLite Database
@@ -1,3 +0,0 @@
1
- hivemind-plugin-manager<1.0.0,>=0.1.0
2
- hivemind-bus-client
3
- ovos-utils