836 lines
29 KiB
Rust
836 lines
29 KiB
Rust
//! SQLCipher database initializer and connection manager.
|
|
//!
|
|
//! Provides [`Database`], the primary entry-point for encrypted local storage
|
|
//! in ChronoGrid P2P. Every database is encrypted at rest using SQLCipher 4.x
|
|
//! (AES-256-CBC) as mandated by `docs/ARCHITECTURE/02-security-and-crypto.md`.
|
|
//!
|
|
//! ## Key Handling
|
|
//!
|
|
//! The 32-byte master key produced by `chronogrid_crypto::kdf::derive_master_key`
|
|
//! is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, which instructs
|
|
//! SQLCipher to use the bytes directly as the encryption key, bypassing its
|
|
//! internal PBKDF2 derivation (the Argon2id KDF already provides equivalent
|
|
//! or stronger protection).
|
|
//!
|
|
//! ## Schema
|
|
//!
|
|
//! The canonical schema is embedded at compile time from
|
|
//! `docs/API_CONTRACTS/schema.sql` via [`include_str!`], ensuring this crate
|
|
//! always stays in sync with the single source of truth.
|
|
|
|
use std::path::Path;
|
|
|
|
use rusqlite::Connection;
|
|
|
|
use crate::error::{CoreError, Result};
|
|
|
|
/// Length of the raw encryption key expected by SQLCipher (256-bit / 32 bytes).
|
|
pub const KEY_LEN: usize = 32;
|
|
|
|
/// Canonical schema SQL, embedded at compile-time from the single source of truth.
|
|
const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql");
|
|
|
|
/// Expected tables that must exist after schema initialization.
|
|
///
|
|
/// Includes the 7 original Phase 1 tables plus the 6 Phase 2 extension tables
|
|
/// added in Task 2.1. The total is 13 tables as specified in the Phase 2
|
|
/// completion criteria.
|
|
const EXPECTED_TABLES: &[&str] = &[
|
|
// Phase 1 — original tables
|
|
"namespaces",
|
|
"projects",
|
|
"tasks",
|
|
"notes",
|
|
"task_note_links",
|
|
"ingestion_logs",
|
|
"crdt_docs",
|
|
// Phase 2 — extension tables (Task 2.1)
|
|
"time_blocks",
|
|
"attachments",
|
|
"calendar_feeds",
|
|
"node_peers",
|
|
"automation_rules",
|
|
"ingestion_configs",
|
|
];
|
|
|
|
// ── Database struct ──────────────────────────────────────────────────────────
|
|
|
|
/// An encrypted SQLCipher database connection with the ChronoGrid schema applied.
|
|
///
|
|
/// Wraps a [`rusqlite::Connection`] that has been keyed with a 256-bit raw key
|
|
/// and initialized with all required tables and indexes.
|
|
pub struct Database {
|
|
conn: Connection,
|
|
}
|
|
|
|
impl std::fmt::Debug for Database {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Database")
|
|
.field("conn", &"<rusqlite::Connection>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Database {
|
|
/// Opens (or creates) an encrypted database file at `path`.
|
|
///
|
|
/// * Applies the raw 256-bit key via `PRAGMA key`.
|
|
/// * Enables foreign-key enforcement.
|
|
/// * Runs the canonical schema DDL (idempotent `CREATE … IF NOT EXISTS`).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`CoreError::InvalidKeyLength`] if `key` is not exactly 32 bytes.
|
|
/// Returns [`CoreError::Database`] on connection or PRAGMA failures.
|
|
/// Returns [`CoreError::SchemaInit`] if any expected table is missing after DDL.
|
|
pub fn open(path: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
|
|
if key.len() != KEY_LEN {
|
|
return Err(CoreError::InvalidKeyLength(key.len()));
|
|
}
|
|
|
|
let conn = Connection::open(path)?;
|
|
Self::configure_and_init(conn, key)
|
|
}
|
|
|
|
/// Opens an **in-memory** encrypted database (useful for testing).
|
|
///
|
|
/// Semantics are identical to [`Database::open`] except no file is created.
|
|
pub fn open_in_memory(key: &[u8]) -> Result<Self> {
|
|
if key.len() != KEY_LEN {
|
|
return Err(CoreError::InvalidKeyLength(key.len()));
|
|
}
|
|
|
|
let conn = Connection::open_in_memory()?;
|
|
Self::configure_and_init(conn, key)
|
|
}
|
|
|
|
/// Returns a shared reference to the underlying [`rusqlite::Connection`].
|
|
#[inline]
|
|
pub fn connection(&self) -> &Connection {
|
|
&self.conn
|
|
}
|
|
|
|
/// Returns a mutable reference to the underlying [`rusqlite::Connection`].
|
|
#[inline]
|
|
pub fn connection_mut(&mut self) -> &mut Connection {
|
|
&mut self.conn
|
|
}
|
|
|
|
// ── CRDT document persistence ────────────────────────────────────────
|
|
|
|
/// Saves (upserts) an Automerge CRDT document's serialized bytes into the
|
|
/// `crdt_docs` table.
|
|
///
|
|
/// If a row for `entity_id` already exists it is replaced.
|
|
pub fn save_crdt_doc(
|
|
&self,
|
|
entity_id: &str,
|
|
entity_type: &str,
|
|
doc_bytes: &[u8],
|
|
updated_at: u64,
|
|
) -> Result<()> {
|
|
self.conn.execute(
|
|
"INSERT OR REPLACE INTO crdt_docs (entity_id, entity_type, doc_bytes, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4);",
|
|
rusqlite::params![entity_id, entity_type, doc_bytes, updated_at as i64],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Loads the serialized Automerge CRDT document bytes for a given entity.
|
|
///
|
|
/// Returns `None` if no CRDT document has been stored for `entity_id`.
|
|
pub fn load_crdt_doc(&self, entity_id: &str) -> Result<Option<Vec<u8>>> {
|
|
let mut stmt = self
|
|
.conn
|
|
.prepare("SELECT doc_bytes FROM crdt_docs WHERE entity_id = ?1;")?;
|
|
|
|
let result = stmt.query_row([entity_id], |row| row.get::<_, Vec<u8>>(0));
|
|
|
|
match result {
|
|
Ok(bytes) => Ok(Some(bytes)),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(CoreError::Database(e)),
|
|
}
|
|
}
|
|
|
|
/// Deletes the CRDT document for a given entity.
|
|
///
|
|
/// Returns `true` if a row was deleted, `false` if none existed.
|
|
pub fn delete_crdt_doc(&self, entity_id: &str) -> Result<bool> {
|
|
let affected = self.conn.execute(
|
|
"DELETE FROM crdt_docs WHERE entity_id = ?1;",
|
|
[entity_id],
|
|
)?;
|
|
Ok(affected > 0)
|
|
}
|
|
|
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
|
|
/// Applies encryption key, PRAGMAs, schema DDL, and validates table presence.
|
|
fn configure_and_init(conn: Connection, key: &[u8]) -> Result<Self> {
|
|
// 1. Apply the raw hex key to encrypt / decrypt the database.
|
|
let hex_key = bytes_to_hex(key);
|
|
conn.execute_batch(&format!("PRAGMA key = \"x'{hex_key}'\";"))?;
|
|
|
|
// 2. Enforce SQLCipher 4 compatibility defaults.
|
|
conn.execute_batch(
|
|
"PRAGMA cipher_compatibility = 4;
|
|
PRAGMA foreign_keys = ON;",
|
|
)?;
|
|
|
|
// 3. Quick-check: verify the database is actually readable with this key.
|
|
// `PRAGMA cipher_integrity_check` is expensive; a lightweight SELECT on
|
|
// sqlite_master suffices to prove the key is correct.
|
|
conn.query_row(
|
|
"SELECT count(*) FROM sqlite_master;",
|
|
[],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.map_err(|e| {
|
|
CoreError::SchemaInit(format!(
|
|
"database key verification failed (wrong key or corrupt file): {e}"
|
|
))
|
|
})?;
|
|
|
|
// 4. Apply the canonical schema (all DDL uses IF NOT EXISTS).
|
|
conn.execute_batch(SCHEMA_SQL)?;
|
|
|
|
// 5. Validate that every expected table was created.
|
|
Self::validate_schema(&conn)?;
|
|
|
|
Ok(Self { conn })
|
|
}
|
|
|
|
/// Checks that all tables declared in `EXPECTED_TABLES` exist in `sqlite_master`.
|
|
fn validate_schema(conn: &Connection) -> Result<()> {
|
|
let mut stmt =
|
|
conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table';")?;
|
|
let existing: Vec<String> = stmt
|
|
.query_map([], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
for &table in EXPECTED_TABLES {
|
|
if !existing.iter().any(|t| t == table) {
|
|
return Err(CoreError::SchemaInit(format!(
|
|
"expected table '{table}' was not created"
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ── Utility ──────────────────────────────────────────────────────────────────
|
|
|
|
/// Converts a byte slice to a lowercase hex string (no allocations beyond the
|
|
/// output `String` itself).
|
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
|
let mut hex = String::with_capacity(bytes.len() * 2);
|
|
for &b in bytes {
|
|
use std::fmt::Write;
|
|
let _ = write!(hex, "{b:02x}");
|
|
}
|
|
hex
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A deterministic 32-byte key used exclusively in tests.
|
|
const TEST_KEY: [u8; KEY_LEN] = [0xAA; KEY_LEN];
|
|
|
|
// ── open_in_memory ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn open_in_memory_creates_all_tables() {
|
|
let db = Database::open_in_memory(&TEST_KEY).expect("open_in_memory should succeed");
|
|
|
|
for &table in EXPECTED_TABLES {
|
|
let exists: bool = db
|
|
.connection()
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1);",
|
|
[table],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(exists, "table '{table}' must exist after initialization");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_in_memory_creates_indexes() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
|
|
let expected_indexes = [
|
|
// Phase 1 indexes
|
|
"idx_tasks_namespace",
|
|
"idx_tasks_eisenhower",
|
|
"idx_tasks_due_date",
|
|
"idx_projects_parent",
|
|
"idx_crdt_docs_type",
|
|
// Phase 2 indexes (Task 2.1)
|
|
"idx_time_blocks_namespace",
|
|
"idx_time_blocks_task",
|
|
"idx_time_blocks_start",
|
|
"idx_attachments_note",
|
|
"idx_attachments_ingestion",
|
|
"idx_calendar_feeds_namespace",
|
|
"idx_node_peers_swarm",
|
|
"idx_node_peers_last_seen",
|
|
"idx_automation_rules_namespace",
|
|
"idx_automation_rules_trigger",
|
|
"idx_ingestion_configs_namespace",
|
|
"idx_ingestion_configs_type",
|
|
];
|
|
|
|
for &idx in &expected_indexes {
|
|
let exists: bool = db
|
|
.connection()
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1);",
|
|
[idx],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(exists, "index '{idx}' must exist after initialization");
|
|
}
|
|
}
|
|
|
|
/// Verifies the total table count matches the Phase 2 completion criterion
|
|
/// of exactly 13 tables (7 original + 6 Phase 2 extension tables).
|
|
#[test]
|
|
fn schema_has_exactly_13_tables() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let count: i64 = db
|
|
.connection()
|
|
.query_row(
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 13, "schema must contain exactly 13 tables (7 Phase 1 + 6 Phase 2)");
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_keys_are_enabled() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let fk_status: i64 = db
|
|
.connection()
|
|
.query_row("PRAGMA foreign_keys;", [], |row| row.get(0))
|
|
.unwrap();
|
|
assert_eq!(fk_status, 1, "foreign_keys PRAGMA must be ON");
|
|
}
|
|
|
|
// ── File-backed database ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn open_file_creates_and_reopens() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db_path = dir.path().join("test.db");
|
|
|
|
// First open: creates the database and schema.
|
|
{
|
|
let db = Database::open(&db_path, &TEST_KEY).expect("first open should succeed");
|
|
db.connection()
|
|
.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4);",
|
|
rusqlite::params!["ns-1", "Personal", 1000, 1000],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// Second open: data must persist.
|
|
{
|
|
let db = Database::open(&db_path, &TEST_KEY).expect("reopen should succeed");
|
|
let name: String = db
|
|
.connection()
|
|
.query_row(
|
|
"SELECT name FROM namespaces WHERE id = ?1;",
|
|
["ns-1"],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(name, "Personal");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_fails() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db_path = dir.path().join("encrypted.db");
|
|
|
|
// Create with one key.
|
|
{
|
|
Database::open(&db_path, &TEST_KEY).unwrap();
|
|
}
|
|
|
|
// Attempt reopen with a different key.
|
|
let bad_key = [0xBB; KEY_LEN];
|
|
let result = Database::open(&db_path, &bad_key);
|
|
assert!(result.is_err(), "opening with a wrong key must fail");
|
|
}
|
|
|
|
// ── Key validation ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn reject_short_key() {
|
|
let short = [0u8; 16];
|
|
let result = Database::open_in_memory(&short);
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
CoreError::InvalidKeyLength(len) => assert_eq!(len, 16),
|
|
other => panic!("expected InvalidKeyLength, got: {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reject_empty_key() {
|
|
let result = Database::open_in_memory(&[]);
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
CoreError::InvalidKeyLength(0) => {}
|
|
other => panic!("expected InvalidKeyLength(0), got: {other}"),
|
|
}
|
|
}
|
|
|
|
// ── Schema DDL is idempotent ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn schema_init_is_idempotent() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
// Running the schema DDL a second time must not fail.
|
|
db.connection().execute_batch(SCHEMA_SQL).unwrap();
|
|
}
|
|
|
|
// ── Insert/query round-trip for every table ──────────────────────────
|
|
|
|
#[test]
|
|
fn crud_round_trip_all_tables() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
|
|
// namespaces
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Work',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// projects
|
|
c.execute(
|
|
"INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp)
|
|
VALUES ('p1','ns1','Project Alpha',1.0,1,'hlc-1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// tasks
|
|
c.execute(
|
|
"INSERT INTO tasks (id, namespace_id, project_id, title, created_at, hlc_timestamp)
|
|
VALUES ('t1','ns1','p1','My Task',1,'hlc-2');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// notes
|
|
c.execute(
|
|
"INSERT INTO notes (id, namespace_id, project_id, title, content_markdown, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('n1','ns1','p1','Note','# Hello',1,1,'hlc-3');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// task_note_links
|
|
c.execute(
|
|
"INSERT INTO task_note_links (task_id, note_id, created_at)
|
|
VALUES ('t1','n1',1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// ingestion_logs
|
|
c.execute(
|
|
"INSERT INTO ingestion_logs (id, source_type, ingested_at)
|
|
VALUES ('il1','jot_hotkey',1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Verify counts
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
// ── Foreign key enforcement ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn foreign_key_violation_is_rejected() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let result = db.connection().execute(
|
|
"INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp)
|
|
VALUES ('p1','nonexistent_ns','Bad',1.0,1,'hlc-1');",
|
|
[],
|
|
);
|
|
assert!(result.is_err(), "FK violation must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn cascade_delete_removes_children() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','X',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
c.execute(
|
|
"INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp)
|
|
VALUES ('t1','ns1','Task',1,'hlc');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Deleting the namespace must cascade-delete the task.
|
|
c.execute("DELETE FROM namespaces WHERE id = 'ns1';", [])
|
|
.unwrap();
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "cascade delete must remove child tasks");
|
|
}
|
|
|
|
// ── CRDT document persistence ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn save_and_load_crdt_doc() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let blob = b"fake-automerge-bytes";
|
|
|
|
db.save_crdt_doc("t-001", "task", blob, 1000).unwrap();
|
|
|
|
let loaded = db.load_crdt_doc("t-001").unwrap();
|
|
assert_eq!(loaded, Some(blob.to_vec()));
|
|
}
|
|
|
|
#[test]
|
|
fn load_missing_crdt_doc_returns_none() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
assert_eq!(db.load_crdt_doc("nonexistent").unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn save_crdt_doc_upserts() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
|
|
db.save_crdt_doc("t-001", "task", b"v1", 1000).unwrap();
|
|
db.save_crdt_doc("t-001", "task", b"v2", 2000).unwrap();
|
|
|
|
let loaded = db.load_crdt_doc("t-001").unwrap().unwrap();
|
|
assert_eq!(loaded, b"v2");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_crdt_doc_removes_row() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
|
|
db.save_crdt_doc("t-001", "task", b"data", 1000).unwrap();
|
|
assert!(db.delete_crdt_doc("t-001").unwrap());
|
|
assert_eq!(db.load_crdt_doc("t-001").unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_missing_crdt_doc_returns_false() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
assert!(!db.delete_crdt_doc("nonexistent").unwrap());
|
|
}
|
|
|
|
// ── Phase 2 extension table CRUD round-trips (Task 2.1) ──────────────
|
|
|
|
/// Helper: inserts a minimal namespace + task into `c` for FK-dependent tests.
|
|
fn seed_namespace_and_task(c: &Connection) {
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
c.execute(
|
|
"INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp)
|
|
VALUES ('t1','ns1','Seed Task',1,'hlc-seed');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
/// Helper: inserts a minimal namespace + note into `c` for FK-dependent tests.
|
|
fn seed_namespace_and_note(c: &Connection) {
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
c.execute(
|
|
"INSERT INTO notes (id, namespace_id, title, content_markdown, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('n1','ns1','Seed Note','# body',1,1,'hlc-note');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn crud_time_blocks() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
seed_namespace_and_task(c);
|
|
|
|
// Insert a time block linked to the task.
|
|
c.execute(
|
|
"INSERT INTO time_blocks
|
|
(id, namespace_id, task_id, title, start_time, end_time, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('tb1','ns1','t1','Deep Work',1000,3600000,1,1,'hlc-tb1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let title: String = c
|
|
.query_row(
|
|
"SELECT title FROM time_blocks WHERE id='tb1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(title, "Deep Work");
|
|
|
|
// Verify FK: deleting the task must SET NULL on time_blocks.task_id.
|
|
c.execute("DELETE FROM tasks WHERE id='t1';", []).unwrap();
|
|
let task_id: Option<String> = c
|
|
.query_row(
|
|
"SELECT task_id FROM time_blocks WHERE id='tb1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(task_id.is_none(), "task_id must be NULL after task deletion");
|
|
}
|
|
|
|
#[test]
|
|
fn crud_attachments() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
seed_namespace_and_note(c);
|
|
|
|
c.execute(
|
|
"INSERT INTO attachments
|
|
(id, note_id, file_name, mime_type, file_size_bytes, storage_path, checksum_sha256, created_at, hlc_timestamp)
|
|
VALUES ('att1','n1','report.pdf','application/pdf',204800,'vault/att1.pdf','deadbeef',1,'hlc-att1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let fname: String = c
|
|
.query_row(
|
|
"SELECT file_name FROM attachments WHERE id='att1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(fname, "report.pdf");
|
|
|
|
// Cascade: deleting the note must remove the attachment.
|
|
c.execute("DELETE FROM notes WHERE id='n1';", []).unwrap();
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM attachments;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "attachment must be cascade-deleted with its note");
|
|
}
|
|
|
|
#[test]
|
|
fn crud_calendar_feeds() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
c.execute(
|
|
"INSERT INTO calendar_feeds
|
|
(id, namespace_id, display_name, feed_url, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('cf1','ns1','Work Cal','https://caldav.example.com/cal',1,1,'hlc-cf1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let url: String = c
|
|
.query_row(
|
|
"SELECT feed_url FROM calendar_feeds WHERE id='cf1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(url, "https://caldav.example.com/cal");
|
|
|
|
// Cascade: deleting the namespace must remove the feed.
|
|
c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap();
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM calendar_feeds;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "calendar_feed must be cascade-deleted with its namespace");
|
|
}
|
|
|
|
#[test]
|
|
fn crud_node_peers() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
|
|
// node_peers has no FK to namespaces — it is a global peer registry.
|
|
c.execute(
|
|
"INSERT INTO node_peers
|
|
(id, public_key_bytes, swarm_key_fingerprint, is_trusted, created_at, updated_at)
|
|
VALUES ('peer-abc123', X'AABBCCDD', 'fp0011223344556677', 1, 1, 1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let trusted: i64 = c
|
|
.query_row(
|
|
"SELECT is_trusted FROM node_peers WHERE id='peer-abc123';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(trusted, 1);
|
|
|
|
// Update last_seen_at.
|
|
c.execute(
|
|
"UPDATE node_peers SET last_seen_at=9999 WHERE id='peer-abc123';",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let last_seen: i64 = c
|
|
.query_row(
|
|
"SELECT last_seen_at FROM node_peers WHERE id='peer-abc123';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(last_seen, 9999);
|
|
}
|
|
|
|
#[test]
|
|
fn crud_automation_rules() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
c.execute(
|
|
"INSERT INTO automation_rules
|
|
(id, namespace_id, name, trigger_type, trigger_config, action_type, action_config, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('ar1','ns1','Auto-assign','task_created','{\"project\":\"p1\"}','set_field','{\"field\":\"status\",\"value\":\"in_progress\"}',1,1,'hlc-ar1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let name: String = c
|
|
.query_row(
|
|
"SELECT name FROM automation_rules WHERE id='ar1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(name, "Auto-assign");
|
|
|
|
// Cascade: deleting the namespace must remove the rule.
|
|
c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap();
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM automation_rules;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "automation_rule must be cascade-deleted with its namespace");
|
|
}
|
|
|
|
#[test]
|
|
fn crud_ingestion_configs() {
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
let c = db.connection();
|
|
c.execute(
|
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Test',1,1);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// IMAP config
|
|
c.execute(
|
|
"INSERT INTO ingestion_configs
|
|
(id, namespace_id, config_type, display_name, imap_host, imap_port, imap_username, imap_use_tls, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('ic1','ns1','imap','Work IMAP','mail.example.com',993,'user@example.com',1,1,1,'hlc-ic1');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// File-watcher config
|
|
c.execute(
|
|
"INSERT INTO ingestion_configs
|
|
(id, namespace_id, config_type, display_name, watch_path, watch_extensions, created_at, updated_at, hlc_timestamp)
|
|
VALUES ('ic2','ns1','file_watcher','Docs Watcher','/home/user/docs','[\"md\",\"pdf\"]',1,1,'hlc-ic2');",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM ingestion_configs;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 2);
|
|
|
|
let host: String = c
|
|
.query_row(
|
|
"SELECT imap_host FROM ingestion_configs WHERE id='ic1';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(host, "mail.example.com");
|
|
|
|
// Cascade: deleting the namespace must remove both configs.
|
|
c.execute("DELETE FROM namespaces WHERE id='ns1';", []).unwrap();
|
|
let count: i64 = c
|
|
.query_row("SELECT count(*) FROM ingestion_configs;", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "ingestion_configs must be cascade-deleted with their namespace");
|
|
}
|
|
|
|
#[test]
|
|
fn crdt_doc_time_block_entity_type() {
|
|
// Verifies that 'time_block' is a valid entity_type value in crdt_docs.
|
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
|
db.save_crdt_doc("tb-001", "time_block", b"fake-am-bytes", 1000)
|
|
.unwrap();
|
|
let loaded = db.load_crdt_doc("tb-001").unwrap();
|
|
assert_eq!(loaded, Some(b"fake-am-bytes".to_vec()));
|
|
|
|
// Confirm entity_type is stored correctly.
|
|
let entity_type: String = db
|
|
.connection()
|
|
.query_row(
|
|
"SELECT entity_type FROM crdt_docs WHERE entity_id='tb-001';",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(entity_type, "time_block");
|
|
}
|
|
}
|