use super::*;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
async fn test_schema_version_tracking() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let version = store
.get_current_version()
.await
.expect("get_current_version");
assert_eq!(
version, 10,
"expected all 10 migrations to be applied on a fresh DB"
);
for v in 1..=10 {
let applied = store.is_migration_run(v).await.expect("is_migration_run");
assert!(applied, "migration v{v} should be marked as applied");
}
let run_0 = store.is_migration_run(0).await.expect("is_migration_run");
assert!(
!run_0,
"version 0 is not a real migration and must not be set"
);
}
#[tokio::test]
async fn test_migration_idempotency() {
let store = Store::connect("sqlite::memory:")
.await
.expect("first connect");
store
.run_migrations()
.await
.expect("second run_migrations must be idempotent");
let version = store
.get_current_version()
.await
.expect("get_current_version");
assert_eq!(
version, 10,
"version must remain 10 after idempotent re-run"
);
}
#[tokio::test]
async fn test_migration_incremental_from_v2() {
let store = {
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let s = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
s.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
s.ddl(
"CREATE TABLE IF NOT EXISTS clients (
vtoken TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE,
label TEXT, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), last_seen TEXT
)",
)
.await
.expect("clients");
s.ddl(
"CREATE TABLE IF NOT EXISTS routing_state (
from_user TEXT PRIMARY KEY,
active_vtoken TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("routing_state");
s.ddl(
"CREATE TABLE IF NOT EXISTS context_token_map (
vctx TEXT PRIMARY KEY, real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '', expires_at TEXT
)",
)
.await
.expect("context_token_map");
s.ddl(
"CREATE TABLE IF NOT EXISTS bot_credentials (
id INTEGER PRIMARY KEY, token TEXT NOT NULL,
base_url TEXT NOT NULL DEFAULT 'https://ilinkai.weixin.qq.com',
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("bot_credentials");
s.ddl(
"CREATE TABLE IF NOT EXISTS backend_sessions_v2 (
vctx TEXT NOT NULL, vtoken TEXT NOT NULL,
session_name TEXT NOT NULL, backend_session_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (vctx, vtoken, session_name)
)",
)
.await
.expect("backend_sessions_v2");
s.ddl(
"CREATE TABLE IF NOT EXISTS active_sessions (
vctx TEXT NOT NULL, vtoken TEXT NOT NULL,
session_name TEXT NOT NULL DEFAULT 'default',
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (vctx, vtoken)
)",
)
.await
.expect("active_sessions");
s.record_migration_run(1).await.expect("mark v1");
s.record_migration_run(2).await.expect("mark v2");
s
};
assert!(!store.is_migration_run(3).await.unwrap());
assert!(!store.is_migration_run(4).await.unwrap());
assert!(!store.is_migration_run(5).await.unwrap());
store.run_migrations().await.expect("incremental migration");
let version = store.get_current_version().await.unwrap();
assert_eq!(version, 10, "must reach v10 after incremental migration");
for v in 1..=10 {
assert!(
store.is_migration_run(v).await.unwrap(),
"v{v} must be marked applied"
);
}
}
#[tokio::test]
async fn migration_runs_on_in_memory_sqlite() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let r = store.list_clients().await;
assert!(r.is_ok(), "list_clients failed: {:?}", r.err());
let r = store
.find_or_create_vctx("test-user", None, "real-ctx")
.await;
assert!(r.is_ok(), "find_or_create_vctx failed: {:?}", r.err());
}
#[tokio::test]
async fn test_migration_v6_normalizes_peer_user_id_format() {
sqlx::any::install_default_drivers();
let store = {
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let s = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
s.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
s.ddl(
"CREATE TABLE IF NOT EXISTS context_token_map (
vctx TEXT PRIMARY KEY,
real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '',
created_at TEXT
)",
)
.await
.expect("context_token_map");
for v in 1..=5 {
s.record_migration_run(v).await.expect("mark v{v}");
}
s.ddl(
"INSERT INTO context_token_map (vctx, real_ctx, peer_user_id) VALUES
('vctx-old-1', 'ctx-1', 'o9cq80_ZyXuz1vAtG-TMbQjwQPW8@im.wechat'),
('vctx-new-2', 'ctx-2', 'peer:already@im.wechat'),
('vctx-grp-3', 'ctx-3', 'group:chatroom-123')",
)
.await
.expect("seed");
s
};
assert!(
!store.is_migration_run(6).await.unwrap(),
"v6 must not be marked yet"
);
store.run_migrations().await.expect("run_migrations");
let cur_ver = store.get_current_version().await.unwrap();
assert_eq!(cur_ver, 10, "current version must be 9, got {}", cur_ver);
assert!(
store.is_migration_run(6).await.unwrap(),
"v6 must be marked after run"
);
let row1 = store
.resolve_context_token_full("vctx-old-1")
.await
.expect("resolve vctx-old-1")
.expect("vctx-old-1 must exist");
assert_eq!(
row1.1, "peer:o9cq80_ZyXuz1vAtG-TMbQjwQPW8@im.wechat",
"bare peer_user_id must be prefixed with 'peer:'"
);
let row2 = store
.resolve_context_token_full("vctx-new-2")
.await
.expect("resolve vctx-new-2")
.expect("vctx-new-2 must exist");
assert_eq!(
row2.1, "peer:already@im.wechat",
"already-prefixed peer: row must be left alone"
);
let row3 = store
.resolve_context_token_full("vctx-grp-3")
.await
.expect("resolve vctx-grp-3")
.expect("vctx-grp-3 must exist");
assert_eq!(
row3.1, "group:chatroom-123",
"already-prefixed group: row must be left alone"
);
store.run_migrations().await.expect("second run_migrations");
let row1_again = store
.resolve_context_token_full("vctx-old-1")
.await
.expect("resolve vctx-old-1 again")
.expect("vctx-old-1 must exist");
assert_eq!(
row1_again.1, "peer:o9cq80_ZyXuz1vAtG-TMbQjwQPW8@im.wechat",
"v6 must be idempotent on re-run"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn file_sqlite_serializes_concurrent_read_and_write_without_busy() {
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("concurrent.db");
let url = format!("sqlite:{}", db_path.display());
let store = std::sync::Arc::new(Store::connect(&url).await.expect("connect"));
assert_eq!(
store.pool.options().get_max_connections(),
1,
"SQLite pool must be pinned to max_connections(1) to avoid SQLITE_BUSY"
);
store
.find_or_create_vctx("peer-seed", None, "real-ctx-seed")
.await
.expect("seed");
store
.set_active_session_name("vctx-seed", "vtoken-seed", "default")
.await
.expect("seed active session");
let mut handles = Vec::new();
for w in 0..8 {
let store = std::sync::Arc::clone(&store);
handles.push(tokio::spawn(async move {
for i in 0..20 {
for j in 0..10 {
store
.find_or_create_vctx(
&format!("peer-w{w}-i{i}-j{j}"),
None,
&format!("real-ctx-w{w}-i{i}-j{j}"),
)
.await
.expect("find_or_create_vctx must not fail");
}
}
}));
}
for w in 0..4 {
let store = std::sync::Arc::clone(&store);
handles.push(tokio::spawn(async move {
for i in 0..200 {
let vctx = format!("vctx-active-w{w}-i{i}");
let vtoken = format!("vtoken-active-w{w}-i{i}");
store
.set_active_session_name(&vctx, &vtoken, "default")
.await
.expect("set_active_session_name must not fail");
}
}));
}
for r in 0..4 {
let store = std::sync::Arc::clone(&store);
handles.push(tokio::spawn(async move {
for i in 0..200 {
let vtoken = format!("ignored-vtoken-r{r}-i{i}");
let name = store
.get_active_session_name("vctx-seed", &vtoken)
.await
.expect("read must not fail");
assert_eq!(name, "default");
}
}));
}
for h in handles {
h.await.expect("task join");
}
}
#[tokio::test]
async fn test_sync_02_upsert_client_updates_routing_state() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.upsert_client("vtoken-1", "bridge-a", None)
.await
.unwrap();
store.set_route("alice", "vtoken-1").await.unwrap();
let route = store.get_route("alice").await.unwrap();
assert_eq!(route, Some("vtoken-1".to_string()));
store
.upsert_client("vtoken-2", "bridge-a", None)
.await
.unwrap();
let route = store.get_route("alice").await.unwrap();
assert_eq!(route, Some("vtoken-2".to_string()));
}
#[tokio::test]
async fn test_db_03_get_hub_ext_batch_query() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.set_active_session_name("vctx-1", "vtoken-1", "session-1")
.await
.unwrap();
store
.set_active_session_name("vctx-2", "vtoken-2", "session-2")
.await
.unwrap();
store
.set_backend_session("vctx-1", "vtoken-1", "session-1", "sid-1")
.await
.unwrap();
store
.set_backend_session("vctx-2", "vtoken-2", "session-2", "sid-2")
.await
.unwrap();
let pairs = vec![
("vctx-1".to_string(), "vtoken-1".to_string()),
("vctx-2".to_string(), "vtoken-2".to_string()),
("vctx-3".to_string(), "vtoken-3".to_string()), ];
let result = store.get_hub_ext_batch(&pairs).await.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(
result.get(&("vctx-1".to_string(), "vtoken-1".to_string())),
Some(&("session-1".to_string(), Some("sid-1".to_string())))
);
assert_eq!(
result.get(&("vctx-2".to_string(), "vtoken-2".to_string())),
Some(&("session-2".to_string(), Some("sid-2".to_string())))
);
assert_eq!(
result.get(&("vctx-3".to_string(), "vtoken-3".to_string())),
Some(&("default".to_string(), None))
);
}
#[tokio::test]
async fn test_db_02_find_or_create_vctx_multiple_peers() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
for i in 0..55 {
store
.find_or_create_vctx(&format!("peer-{i}"), None, &format!("real-{i}"))
.await
.unwrap();
}
for i in 0..55 {
let v1 = store
.find_or_create_vctx(&format!("peer-{i}"), None, &format!("real-{i}"))
.await
.unwrap();
let v2 = store
.find_or_create_vctx(&format!("peer-{i}"), None, &format!("real-{i}-new"))
.await
.unwrap();
assert_eq!(v1, v2, "peer-{i} must always get the same vctx");
}
}
#[tokio::test]
async fn test_sync_02_upsert_client_concurrent_adversarial() {
let temp_dir = tempfile::Builder::new()
.prefix("test_concurrent_db")
.tempdir_in("target")
.unwrap();
let db_path = temp_dir.path().join("test.db");
let db_url = format!("sqlite:{}", db_path.to_str().unwrap());
let store = Store::connect(&db_url).await.expect("connect");
store
.upsert_client("vtoken-initial", "bridge-concurrent", None)
.await
.unwrap();
store.set_route("alice", "vtoken-initial").await.unwrap();
let num_concurrency = 20;
let mut handles = vec![];
let store = std::sync::Arc::new(store);
for i in 0..num_concurrency {
let store_clone = store.clone();
let vtoken = format!("vtoken-{}", i);
let handle = tokio::spawn(async move {
store_clone
.upsert_client(&vtoken, "bridge-concurrent", None)
.await
});
handles.push(handle);
}
for h in handles {
h.await.unwrap().unwrap();
}
let clients = store.list_clients().await.unwrap();
let final_client_vtoken = clients
.iter()
.find(|c| c.name == "bridge-concurrent")
.map(|c| c.vtoken.clone())
.unwrap();
let final_route = store.get_route("alice").await.unwrap().unwrap();
assert_eq!(final_route, final_client_vtoken);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn adversarial_concurrent_store_connect_succeeds_and_converges() {
sqlx::any::install_default_drivers();
let tmp = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite:{}/concurrent.db", tmp.path().display());
let url1 = url.clone();
let url2 = url.clone();
let (s1, s2) = tokio::join!(async move { Store::connect(&url1).await }, async move {
Store::connect(&url2).await
},);
let s1 = s1.expect("connect #1 must succeed");
let s2 = s2.expect("connect #2 must succeed");
assert_eq!(
s1.get_current_version().await.unwrap(),
10,
"writer #1 must see all v1-v10 applied"
);
assert_eq!(
s2.get_current_version().await.unwrap(),
10,
"writer #2 must see all v1-v10 applied"
);
for s in [&s1, &s2] {
assert!(s.list_clients().await.is_ok());
assert!(s
.find_or_create_vctx("schema-check-user", None, "schema-check-real")
.await
.is_ok());
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn adversarial_many_concurrent_connects_converge() {
sqlx::any::install_default_drivers();
let tmp = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite:{}/many.db", tmp.path().display());
let mut handles = Vec::with_capacity(10);
for i in 0..10 {
let url = url.clone();
handles.push(tokio::spawn(async move {
let mut last_err = String::new();
for attempt in 0..15 {
match Store::connect(&url).await {
Ok(s) => return Ok(s),
Err(e) => {
let is_busy = e
.downcast_ref::<sqlx::Error>()
.map(|se| {
matches!(
se,
sqlx::Error::Database(ref db_err)
if db_err.code().as_deref() == Some("5")
)
})
.unwrap_or(false);
last_err = format!("{e}");
if is_busy && attempt < 14 {
let delay = 200 + (rand::random::<u32>() % 300) as u64;
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
continue;
}
}
}
}
Err(format!("connect #{i} failed after retries: {last_err}"))
}));
}
let mut stores = Vec::with_capacity(handles.len());
for h in handles {
stores.push(
h.await
.expect("task join")
.unwrap_or_else(|e| panic!("{e}")),
);
}
for (i, s) in stores.iter().enumerate() {
assert_eq!(
s.get_current_version().await.unwrap(),
10,
"connect #{i} must see all v1-v10 applied"
);
}
}
#[tokio::test]
async fn adversarial_v4_skips_alter_when_column_already_present() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
store
.ddl(
"CREATE TABLE IF NOT EXISTS clients (
vtoken TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE,
label TEXT, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), last_seen TEXT
)",
)
.await
.expect("clients");
store
.ddl(
"CREATE TABLE IF NOT EXISTS routing_state (
from_user TEXT PRIMARY KEY,
active_vtoken TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("routing_state");
store
.ddl(
"CREATE TABLE IF NOT EXISTS context_token_map (
vctx TEXT PRIMARY KEY, real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '', expires_at TEXT,
created_at TEXT
)",
)
.await
.expect("context_token_map (with created_at)");
store
.ddl(
"CREATE TABLE IF NOT EXISTS bot_credentials (
id INTEGER PRIMARY KEY, token TEXT NOT NULL,
base_url TEXT NOT NULL DEFAULT 'https://ilinkai.weixin.qq.com',
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("bot_credentials");
store
.ddl(
"CREATE TABLE IF NOT EXISTS backend_sessions_v2 (
vctx TEXT NOT NULL, vtoken TEXT NOT NULL,
session_name TEXT NOT NULL, backend_session_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (vctx, vtoken, session_name)
)",
)
.await
.expect("backend_sessions_v2");
store
.ddl(
"CREATE TABLE IF NOT EXISTS active_sessions (
vctx TEXT NOT NULL, vtoken TEXT NOT NULL,
session_name TEXT NOT NULL DEFAULT 'default',
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (vctx, vtoken)
)",
)
.await
.expect("active_sessions");
store.record_migration_run(1).await.expect("mark v1");
store.record_migration_run(2).await.expect("mark v2");
store
.run_migrations()
.await
.expect("run_migrations must succeed");
for v in 1..=5 {
assert!(
store.is_migration_run(v).await.unwrap(),
"v{v} must be marked applied after run_migrations"
);
}
assert!(
store
.column_exists("context_token_map", "created_at")
.await
.unwrap(),
"created_at must still exist (we only skip the ALTER, never drop)"
);
}
#[tokio::test]
async fn adversarial_get_current_version_propagates_decode_error() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
sqlx::query("INSERT INTO schema_version (version) VALUES ($1)")
.bind(i32::MAX)
.execute(&store.pool)
.await
.expect("insert lock sentinel");
let res = store.get_current_version().await;
assert_eq!(
res.ok(),
Some(0),
"get_current_version must exclude the lock sentinel and return 0 for an empty (real-version) table"
);
sqlx::query("DELETE FROM schema_version WHERE version = $1")
.bind(i32::MAX)
.execute(&store.pool)
.await
.expect("delete lock sentinel");
let bad_insert = sqlx::query("INSERT INTO schema_version (version) VALUES ('not-a-number')")
.execute(&store.pool)
.await;
assert!(
bad_insert.is_err(),
"SQLite INTEGER PRIMARY KEY must reject non-integer insert — F-M1-03 defence-in-depth"
);
}
#[tokio::test]
async fn adversarial_version_api_boundaries() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
assert!(!store.is_migration_run(0).await.unwrap());
assert!(!store.is_migration_run(-1).await.unwrap());
assert_eq!(store.get_current_version().await.unwrap(), 10);
}
#[tokio::test]
async fn adversarial_try_claim_is_mutually_exclusive() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
sqlx::query("DELETE FROM schema_version WHERE version = 5")
.execute(&store.pool)
.await
.expect("delete v5 row");
let first = store.try_claim_migration(5).await.unwrap();
let second = store.try_claim_migration(5).await.unwrap();
assert!(first, "first claim must win");
assert!(!second, "second claim must lose");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn adversarial_try_claim_is_mutually_exclusive_concurrent() {
let store = std::sync::Arc::new(Store::connect("sqlite::memory:").await.expect("connect"));
sqlx::query("DELETE FROM schema_version WHERE version = 5")
.execute(&store.pool)
.await
.expect("delete v5 row");
let s1 = std::sync::Arc::clone(&store);
let s2 = std::sync::Arc::clone(&store);
let (first, second) = tokio::join!(
async move { s1.try_claim_migration(5).await.unwrap() },
async move { s2.try_claim_migration(5).await.unwrap() },
);
let wins = [first, second].iter().filter(|w| **w).count();
assert_eq!(
wins, 1,
"exactly one of two concurrent try_claim_migration(5) calls must win; got {first}/{second}"
);
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM schema_version WHERE version = 5")
.fetch_one(&store.pool)
.await
.expect("count");
assert_eq!(rows.0, 1, "exactly one claim row must exist for v5");
}
#[tokio::test]
async fn m2_per_version_migrators_update_schema_version_independently() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
store.migrate_to_v2().await.expect("migrate_to_v2");
assert!(
store.is_migration_run(2).await.unwrap(),
"v2 must be marked after migrate_to_v2"
);
for v in [1, 3, 4, 5] {
assert!(
!store.is_migration_run(v).await.unwrap(),
"v{v} must NOT be marked after running only v2"
);
}
let row: Option<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master WHERE type='table' AND name='backend_sessions_v2'",
)
.fetch_optional(&store.pool)
.await
.expect("catalog");
assert!(
row.is_some(),
"backend_sessions_v2 must exist after migrate_to_v2"
);
let row: Option<(String,)> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name='clients'")
.fetch_optional(&store.pool)
.await
.expect("catalog");
assert!(row.is_none(), "clients must NOT exist (v1 was not run)");
}
#[tokio::test]
async fn m2_migrators_are_idempotent_per_step() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store.migrate_to_v1().await.expect("v1 re-run");
store.migrate_to_v2().await.expect("v2 re-run");
store.migrate_to_v3().await.expect("v3 re-run");
store.migrate_to_v4().await.expect("v4 re-run");
store.migrate_to_v5().await.expect("v5 re-run");
store.migrate_to_v6().await.expect("v6 re-run");
store.migrate_to_v7().await.expect("v7 re-run");
store.migrate_to_v8().await.expect("v8 re-run");
store.migrate_to_v9().await.expect("v9 re-run");
store.migrate_to_v10().await.expect("v10 re-run");
assert_eq!(store.get_current_version().await.unwrap(), 10);
}
#[tokio::test]
async fn m2_ddl_error_propagates_through_migrator() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
store
.ddl(
"CREATE TABLE context_token_map (
vctx TEXT PRIMARY KEY, real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '', expires_at TEXT
)",
)
.await
.expect("context_token_map");
sqlx::query("INSERT INTO context_token_map (vctx, real_ctx) VALUES ($1, $2)")
.bind("vctx-1")
.bind("dup-real")
.execute(&store.pool)
.await
.expect("seed row 1");
sqlx::query("INSERT INTO context_token_map (vctx, real_ctx) VALUES ($1, $2)")
.bind("vctx-2")
.bind("dup-real")
.execute(&store.pool)
.await
.expect("seed row 2");
let result = store.migrate_to_v3().await;
assert!(
result.is_err(),
"migrate_to_v3 must propagate DDL errors, got Ok — F-M2-03 not fixed"
);
assert!(
!store.is_migration_run(3).await.unwrap(),
"v3 claim row must be absent after rollback — migrator is safely retryable"
);
}
#[tokio::test]
async fn m2_claim_and_record_are_consistent_with_schema_version() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
assert!(
!store.try_claim_migration(3).await.unwrap(),
"v3 is already applied; second claim must lose"
);
store
.record_migration_run(3)
.await
.expect("record_migration_run(3) must be a no-op");
assert!(store.is_migration_run(3).await.unwrap());
}
#[tokio::test]
async fn m2_v4_alone_with_minimal_preconditions() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
store
.ddl(
"CREATE TABLE context_token_map (
vctx TEXT PRIMARY KEY, real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '', expires_at TEXT
)",
)
.await
.expect("context_token_map");
store.migrate_to_v4().await.expect("migrate_to_v4");
assert!(store.is_migration_run(4).await.unwrap());
assert!(
store
.column_exists("context_token_map", "created_at")
.await
.unwrap(),
"created_at column must exist after v4"
);
let row: Option<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master \
WHERE type='index' AND name='idx_context_token_map_created_at'",
)
.fetch_optional(&store.pool)
.await
.expect("catalog");
assert!(
row.is_some(),
"idx_context_token_map_created_at must exist after v4"
);
}
#[tokio::test]
async fn m2_run_migrations_records_all_versions_in_order() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
for v in 1..=10 {
assert!(
store.is_migration_run(v).await.unwrap(),
"v{v} must be recorded after run_migrations"
);
}
assert_eq!(store.get_current_version().await.unwrap(), 10);
}
#[tokio::test]
async fn m2_run_migrations_idempotent_double_call() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store.run_migrations().await.expect("second run_migrations");
assert_eq!(store.get_current_version().await.unwrap(), 10);
}
#[tokio::test]
async fn m2_ddl_uses_current_timestamp_only() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let rows: Vec<(String,)> =
sqlx::query_as("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL")
.fetch_all(&store.pool)
.await
.expect("catalog");
for (sql,) in rows {
assert!(
!sql.contains("datetime('now')"),
"DDL must not use legacy datetime('now'): {sql}"
);
assert!(
sql.contains("CURRENT_TIMESTAMP")
|| !sql.contains("TIMESTAMP") && !sql.contains("timestamp"),
"DDL should prefer CURRENT_TIMESTAMP where applicable: {sql}"
);
}
}
fn normalise_sql(s: &str) -> String {
s.lines()
.map(|l| {
if let Some(idx) = l.find("--") {
&l[..idx]
} else {
l
}
})
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| {
let mut out = String::with_capacity(l.len());
let mut prev_space = false;
for c in l.chars() {
if c == ';' {
out.push(';');
prev_space = false;
} else if c.is_whitespace() {
if !prev_space {
out.push(' ');
}
prev_space = true;
} else {
out.push(c);
prev_space = false;
}
}
out
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn m3_v5_sqlite_ddl_matches_migration_file() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let sql_path = manifest_dir.join("migrations").join("0005_messages.sql");
let sql_text = std::fs::read_to_string(&sql_path)
.unwrap_or_else(|e| panic!("read {}: {e}", sql_path.display()));
let create_table_block = sql_text.split(';').next().unwrap_or("").trim().to_string() + ";";
let expected = Store::v5_create_messages_sql(DatabaseKind::Sqlite) + ";";
assert_eq!(
normalise_sql(&expected),
normalise_sql(&create_table_block),
"SQLite v5 CREATE TABLE DDL diverges from migrations/0005_messages.sql — \
update one or the other to keep them in sync (M3 invariant)"
);
}
#[test]
fn m3_v5_postgres_ddl_uses_identity_not_autoincrement() {
let ddl = Store::v5_create_messages_sql(DatabaseKind::Postgres);
assert!(
ddl.contains("GENERATED BY DEFAULT AS IDENTITY"),
"Postgres v5 DDL must use SQL standard IDENTITY clause: {ddl}"
);
assert!(
!ddl.contains("AUTOINCREMENT"),
"Postgres v5 DDL must NOT use SQLite-only AUTOINCREMENT: {ddl}"
);
}
#[test]
fn m3_v5_mysql_ddl_uses_auto_increment_and_bigint() {
let ddl = Store::v5_create_messages_sql(DatabaseKind::MySql);
assert!(
ddl.contains("AUTO_INCREMENT"),
"MySQL v5 DDL must use MySQL AUTO_INCREMENT: {ddl}"
);
assert!(
!ddl.contains("AUTOINCREMENT"),
"MySQL v5 DDL must NOT use SQLite-only AUTOINCREMENT: {ddl}"
);
assert!(
!ddl.contains("GENERATED BY DEFAULT AS IDENTITY"),
"MySQL v5 DDL must NOT use Postgres IDENTITY clause: {ddl}"
);
assert!(
ddl.contains("BIGINT"),
"MySQL v5 DDL must declare id as BIGINT (not INTEGER): {ddl}"
);
}
#[test]
fn adversarial_database_kind_from_url() {
assert_eq!(
DatabaseKind::from_url("sqlite::memory:").unwrap(),
DatabaseKind::Sqlite
);
assert_eq!(
DatabaseKind::from_url("sqlite:/tmp/x.db").unwrap(),
DatabaseKind::Sqlite
);
assert_eq!(
DatabaseKind::from_url("sqlite:///var/data/x.db").unwrap(),
DatabaseKind::Sqlite
);
#[cfg(feature = "postgres")]
{
assert_eq!(
DatabaseKind::from_url("postgres://u:p@h:5432/db").unwrap(),
DatabaseKind::Postgres
);
assert_eq!(
DatabaseKind::from_url("postgresql://u:p@h:5432/db").unwrap(),
DatabaseKind::Postgres
);
}
#[cfg(not(feature = "postgres"))]
{
assert!(
DatabaseKind::from_url("postgres://u:p@h:5432/db").is_err(),
"postgres:// must return Err when `postgres` feature is disabled"
);
assert!(
DatabaseKind::from_url("postgresql://u:p@h:5432/db").is_err(),
"postgresql:// must return Err when `postgres` feature is disabled"
);
}
#[cfg(feature = "mysql")]
{
assert_eq!(
DatabaseKind::from_url("mysql://u:p@h:3306/db").unwrap(),
DatabaseKind::MySql
);
assert_eq!(
DatabaseKind::from_url("mariadb://u:p@h:3306/db").unwrap(),
DatabaseKind::MySql
);
}
#[cfg(not(feature = "mysql"))]
{
assert!(
DatabaseKind::from_url("mysql://u:p@h:3306/db").is_err(),
"mysql:// must return Err when `mysql` feature is disabled"
);
assert!(
DatabaseKind::from_url("mariadb://u:p@h:3306/db").is_err(),
"mariadb:// must return Err when `mysql` feature is disabled"
);
}
assert_eq!(DatabaseKind::from_url("").unwrap(), DatabaseKind::Sqlite);
assert!(DatabaseKind::from_url("file:/tmp/x.db").is_err());
assert!(DatabaseKind::from_url("postgress://u:p@h/db").is_err());
assert!(DatabaseKind::from_url("http://example.com/db").is_err());
let err = DatabaseKind::from_url("/home/user/db.sqlite").unwrap_err();
assert!(
err.to_string().contains("looks like a file path"),
"expected 'looks like a file path' hint, got: {err}"
);
let err = DatabaseKind::from_url("./relative/db.sqlite").unwrap_err();
assert!(
err.to_string().contains("looks like a file path"),
"expected 'looks like a file path' hint for relative path, got: {err}"
);
let err = DatabaseKind::from_url("~/db.sqlite").unwrap_err();
assert!(
err.to_string().contains("looks like a file path"),
"expected 'looks like a file path' hint for tilde path, got: {err}"
);
}
#[test]
fn adversarial_database_kind_drives_v5_ddl_branch() {
let sqlite_ddl = Store::v5_create_messages_sql(DatabaseKind::Sqlite);
let postgres_ddl = Store::v5_create_messages_sql(DatabaseKind::Postgres);
let mysql_ddl = Store::v5_create_messages_sql(DatabaseKind::MySql);
for (label, ddl) in [
("sqlite", &sqlite_ddl),
("postgres", &postgres_ddl),
("mysql", &mysql_ddl),
] {
assert!(
ddl.contains("vctx TEXT NOT NULL"),
"[{label}] missing vctx column: {ddl}"
);
assert!(
ddl.contains("session_name TEXT NOT NULL DEFAULT 'default'"),
"[{label}] missing session_name: {ddl}"
);
assert!(
ddl.contains("created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)"),
"[{label}] missing created_at with CURRENT_TIMESTAMP default: {ddl}"
);
}
assert!(sqlite_ddl.contains("INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(postgres_ddl.contains("GENERATED BY DEFAULT AS IDENTITY"));
assert!(mysql_ddl.contains("BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY"));
assert_ne!(sqlite_ddl, postgres_ddl);
assert_ne!(sqlite_ddl, mysql_ddl);
assert_ne!(postgres_ddl, mysql_ddl);
}
#[tokio::test]
async fn adversarial_column_exists_uses_pragma_on_sqlite() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl("CREATE TABLE t (a INTEGER, b TEXT)")
.await
.expect("create");
assert!(store.column_exists("t", "a").await.unwrap());
assert!(store.column_exists("t", "b").await.unwrap());
assert!(!store.column_exists("t", "c").await.unwrap());
assert!(!store.column_exists("t; DROP", "a").await.unwrap());
}
#[test]
fn m3_no_legacy_datetime_now_in_migration_files() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dir = manifest_dir.join("migrations");
let mut checked = 0usize;
for entry in
std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
{
let entry = entry.expect("entry");
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("sql") {
continue;
}
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert!(
!text.contains("datetime('now')"),
"{} still contains legacy datetime('now') — use CURRENT_TIMESTAMP",
path.display()
);
checked += 1;
}
assert!(
checked >= 4,
"expected at least 4 .sql files, found {checked}"
);
}
#[test]
fn m3_migration_files_use_current_timestamp() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dir = manifest_dir.join("migrations");
for entry in std::fs::read_dir(&dir).expect("read_dir") {
let entry = entry.expect("entry");
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("sql") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
let mentions_timestamp = text.contains("timestamp") || text.contains("TIMESTAMP");
if !mentions_timestamp {
continue;
}
assert!(
text.contains("CURRENT_TIMESTAMP"),
"{} is missing CURRENT_TIMESTAMP — every timestamp default \
must use the SQL standard form (M3 alignment)",
path.display()
);
}
}
#[tokio::test]
async fn m3_index_names_match_sql_files_and_catalog() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
for idx in [
"idx_context_token_map_real_ctx",
"idx_context_token_map_created_at",
"idx_messages_vctx_created",
"idx_messages_peer_role_created",
] {
let row: Option<(String,)> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='index' AND name = $1")
.bind(idx)
.fetch_optional(&store.pool)
.await
.expect("catalog");
assert!(row.is_some(), "index {idx} missing from SQLite catalog");
}
}
#[tokio::test]
async fn m3_migration_files_match_inline_ddl_for_v1_v2_v4() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let expected_tables = [
"clients", "routing_state", "context_token_map", "bot_credentials", "backend_sessions_v2", "active_sessions", "messages", ];
for t in expected_tables {
let row: Option<(String,)> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name = $1")
.bind(t)
.fetch_optional(&store.pool)
.await
.expect("catalog");
assert!(
row.is_some(),
"table {t} declared in migrations/*.sql but missing from catalog"
);
}
}
#[tokio::test]
async fn session_status_empty_vtokens_returns_empty_map() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let result = store
.get_session_status_per_vtoken(&[])
.await
.expect("query");
assert!(result.is_empty());
}
#[tokio::test]
async fn session_status_no_messages_returns_empty_map() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let vtokens = vec!["vt-unknown".to_string()];
let result = store
.get_session_status_per_vtoken(&vtokens)
.await
.expect("query");
assert!(result.is_empty());
}
#[tokio::test]
async fn session_status_waiting_when_last_message_is_user() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.save_message(
"vctx1",
Some("vt1"),
"default",
"user1",
"user",
"帮我看看这个问题",
)
.await
.expect("save user");
let result = store
.get_session_status_per_vtoken(&["vt1".to_string()])
.await
.expect("query");
let entry = result.get("vt1").expect("entry for vt1");
assert!(
entry.waiting_for_reply,
"last role is user → should be waiting"
);
assert_eq!(entry.session_name, "default");
assert_eq!(entry.last_user_content.as_deref(), Some("帮我看看这个问题"));
}
#[tokio::test]
async fn session_status_not_waiting_when_last_message_is_assistant() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.save_message(
"vctx2",
Some("vt2"),
"work",
"user2",
"user",
"请解释一下 Rust 的生命周期",
)
.await
.expect("save user");
store
.save_message(
"vctx2",
Some("vt2"),
"work",
"user2",
"assistant",
"生命周期是…",
)
.await
.expect("save assistant");
let result = store
.get_session_status_per_vtoken(&["vt2".to_string()])
.await
.expect("query");
let entry = result.get("vt2").expect("entry for vt2");
assert!(
!entry.waiting_for_reply,
"last role is assistant → not waiting"
);
assert_eq!(entry.session_name, "work");
assert_eq!(
entry.last_user_content.as_deref(),
Some("请解释一下 Rust 的生命周期")
);
}
#[tokio::test]
async fn session_status_multiple_vtokens_independent() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.save_message("ctx-a", Some("vt-a"), "default", "pa", "user", "问题A")
.await
.expect("save");
store
.save_message("ctx-b", Some("vt-b"), "session-x", "pb", "user", "问题B")
.await
.expect("save");
store
.save_message(
"ctx-b",
Some("vt-b"),
"session-x",
"pb",
"assistant",
"回答B",
)
.await
.expect("save");
let result = store
.get_session_status_per_vtoken(&["vt-a".to_string(), "vt-b".to_string()])
.await
.expect("query");
let a = result.get("vt-a").expect("vt-a");
assert!(a.waiting_for_reply);
assert_eq!(a.last_user_content.as_deref(), Some("问题A"));
let b = result.get("vt-b").expect("vt-b");
assert!(!b.waiting_for_reply);
assert_eq!(b.last_user_content.as_deref(), Some("问题B"));
assert_eq!(b.session_name, "session-x");
}
#[tokio::test]
async fn session_status_unknown_vtoken_not_in_result() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.save_message("ctx", Some("vt-known"), "default", "p", "user", "hi")
.await
.expect("save");
let result = store
.get_session_status_per_vtoken(&["vt-known".to_string(), "vt-missing".to_string()])
.await
.expect("query");
assert!(result.contains_key("vt-known"));
assert!(
!result.contains_key("vt-missing"),
"unknown vtoken must not appear"
);
}
#[test]
fn adversarial_ensure_sqlite_file_does_not_truncate_existing_db() {
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("existing.db");
let url = format!("sqlite:{}", db_path.display());
let rt = tokio::runtime::Runtime::new().unwrap();
let _store = rt.block_on(async { Store::connect(&url).await.expect("first connect") });
let size_before = std::fs::metadata(&db_path).expect("metadata").len();
assert!(
size_before > 0,
"database file must have content after Store::connect"
);
Store::ensure_sqlite_file(&url).expect("ensure_sqlite_file must succeed");
let size_after = std::fs::metadata(&db_path).expect("metadata").len();
assert!(
size_after >= size_before,
"ensure_sqlite_file must not truncate existing database: \
size_before={size_before}, size_after={size_after}"
);
let store2 = rt.block_on(async { Store::connect(&url).await.expect("second connect") });
let v = rt.block_on(store2.get_current_version()).unwrap();
assert_eq!(
v, 10,
"database must still be at v10 after ensure_sqlite_file"
);
}
#[test]
fn adversarial_ensure_sqlite_file_concurrent_threads_safe() {
use std::sync::Arc;
let tmp = tempfile::tempdir().expect("tempdir");
let db_path = tmp.path().join("race.db");
let url = format!("sqlite:{}", db_path.display());
let rt = tokio::runtime::Runtime::new().unwrap();
let _store = rt.block_on(async { Store::connect(&url).await.expect("first connect") });
let size_before = std::fs::metadata(&db_path).expect("metadata").len();
let url = Arc::new(url);
let mut handles = Vec::new();
for _ in 0..16 {
let url = Arc::clone(&url);
handles.push(std::thread::spawn(move || {
for _ in 0..50 {
Store::ensure_sqlite_file(&url).expect("ensure_sqlite_file");
}
}));
}
for h in handles {
h.join().expect("thread join");
}
let size_after = std::fs::metadata(&db_path).expect("metadata").len();
assert!(
size_after >= size_before,
"concurrent ensure_sqlite_file must not truncate: \
size_before={size_before}, size_after={size_after}"
);
let store2 = rt.block_on(async {
Store::connect(&url)
.await
.expect("reconnect after concurrent race")
});
let v = rt.block_on(store2.get_current_version()).unwrap();
assert_eq!(v, 10);
}
#[tokio::test]
async fn adversarial_v4_tx_pragma_error_propagates() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
for v in 1..=3 {
store.record_migration_run(v).await.expect("mark");
}
store
.ddl(
"CREATE TABLE IF NOT EXISTS context_token_map (
vctx TEXT PRIMARY KEY, real_ctx TEXT NOT NULL,
peer_user_id TEXT NOT NULL DEFAULT '', expires_at TEXT
)",
)
.await
.expect("context_token_map");
store
.migrate_to_v4()
.await
.expect("v4 must add created_at column");
assert!(
store
.column_exists("context_token_map", "created_at")
.await
.unwrap(),
"created_at must exist after v4"
);
}
#[tokio::test]
async fn adversarial_column_exists_returns_false_on_nonexistent_table() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
let result = store.column_exists("no_such_table", "any_col").await;
assert!(
result.is_ok(),
"column_exists on non-existent table must return Ok, not Err"
);
assert!(
!result.unwrap(),
"column_exists on non-existent table must return Ok(false)"
);
}
#[tokio::test]
async fn adversarial_ddl_surfaces_error_after_column_exists_suppresses() {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
let col_missing = !store
.column_exists("ghost_table", "ghost_col")
.await
.unwrap();
assert!(col_missing);
let ddl_result = store
.ddl("ALTER TABLE ghost_table ADD COLUMN ghost_col TEXT")
.await;
assert!(
ddl_result.is_err(),
"DDL on non-existent table must propagate error"
);
let err_msg = format!("{}", ddl_result.unwrap_err());
assert!(
err_msg.to_lowercase().contains("no such table")
|| err_msg.to_lowercase().contains("error"),
"DDL error must mention the table problem; got: {err_msg}"
);
}
#[tokio::test]
async fn adversarial_sqlite_connect_configures_wal_and_busy_timeout() {
sqlx::any::install_default_drivers();
let tmp = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite:{}/pragma.db", tmp.path().display());
let store = Store::connect(&url).await.expect("connect");
let (jm,): (String,) = sqlx::query_as("SELECT * FROM pragma_journal_mode")
.fetch_one(&store.pool)
.await
.expect("journal_mode query");
assert_eq!(
jm, "wal",
"journal_mode must be WAL after Store::connect; got {jm}"
);
let (bt,): (i32,) = sqlx::query_as("SELECT * FROM pragma_busy_timeout")
.fetch_one(&store.pool)
.await
.expect("busy_timeout query");
assert_eq!(
bt, 5000,
"busy_timeout must be 5000ms after Store::connect; got {bt}"
);
}
#[tokio::test]
async fn adversarial_try_claim_in_tx_is_mutually_exclusive() {
sqlx::any::install_default_drivers();
let tmp = tempfile::tempdir().expect("tempdir");
let db_url = format!("sqlite:{}/txclaim.db", tmp.path().display());
Store::ensure_sqlite_file(&db_url).expect("ensure db file");
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(2)
.connect(&db_url)
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool: pool.clone(),
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.expect("schema_version");
let pool2 = pool.clone();
let store2 = Store {
rpool: pool.clone(),
pool: pool2,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
let (r1, r2) = tokio::join!(
async {
let mut tx = store.pool.begin().await.expect("tx1");
let claimed = store.try_claim_migration_in_tx(&mut tx, 99).await.unwrap();
tx.commit().await.expect("commit1");
claimed
},
async {
let mut tx = store2.pool.begin().await.expect("tx2");
let claimed = store2.try_claim_migration_in_tx(&mut tx, 99).await.unwrap();
tx.commit().await.expect("commit2");
claimed
},
);
let winners = [r1, r2].iter().filter(|c| **c).count();
assert_eq!(
winners, 1,
"exactly one tx must claim v99; r1={r1}, r2={r2}"
);
}
use crate::hub::{hash_vtoken, is_vtoken_hash};
#[tokio::test]
async fn m1_upsert_client_stores_hash_not_plaintext() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let plain = "vhub_0123456789abcdef0123456789abcdef";
let hashed = hash_vtoken(plain);
store
.upsert_client(&hashed, "claude", Some("claude test"))
.await
.expect("upsert_client");
let rows = store.list_clients().await.expect("list_clients");
let row = rows
.iter()
.find(|r| r.name == "claude")
.expect("claude row present");
assert_eq!(
row.vtoken, hashed,
"upsert must store exactly what was bound (hash form)"
);
assert!(
is_vtoken_hash(&row.vtoken),
"stored vtoken must be the canonical SHA-256 hex"
);
assert_ne!(row.vtoken, plain, "plaintext must NOT be persisted");
}
#[tokio::test]
async fn m1_touch_client_uses_hash() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let plain = "vhub_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1";
let hashed = hash_vtoken(plain);
store.upsert_client(&hashed, "claude", None).await.unwrap();
store.touch_client(&hashed).await.expect("touch_client");
let rows = store.list_clients().await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].vtoken, hashed);
assert_ne!(rows[0].vtoken, plain);
}
#[tokio::test]
async fn m1_routes_are_keyed_by_hash() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let plain = "vhub_route-target-aaaaaaaaaaaaaaaa";
let hashed = hash_vtoken(plain);
store.upsert_client(&hashed, "claude", None).await.unwrap();
store.set_route("alice", &hashed).await.expect("set_route");
let route = store.get_route("alice").await.expect("get_route");
assert_eq!(route.as_deref(), Some(hashed.as_str()));
assert_ne!(route.as_deref(), Some(plain));
let routes = store.list_routes().await.expect("list_routes");
assert_eq!(routes, vec![("alice".to_string(), hashed.clone())]);
store
.clear_routes_for_vtoken(&hashed)
.await
.expect("clear_routes_for_vtoken");
assert!(store.get_route("alice").await.unwrap().is_none());
}
#[tokio::test]
async fn m1_messages_table_keys_by_hash() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let plain = "vhub_msg-target-bbbbbbbbbbbbbbbbb";
let hashed = hash_vtoken(plain);
store.upsert_client(&hashed, "claude", None).await.unwrap();
store
.save_message(
"vctx-1",
Some(&hashed),
"default",
"user-1",
"assistant",
"hello",
)
.await
.expect("save_message");
let (vtoken, _session) = store
.find_assistant_message_by_content("user-1", "hello")
.await
.expect("find_assistant_message_by_content")
.expect("an assistant message should be found");
assert_eq!(vtoken, hashed);
assert_ne!(vtoken, plain);
}
#[tokio::test]
async fn m1_two_distinct_plaintexts_never_collide() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let plain_a = "vhub_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let plain_b = "vhub_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let hash_a = hash_vtoken(plain_a);
let hash_b = hash_vtoken(plain_b);
assert_ne!(hash_a, hash_b, "distinct plaintexts hash differently");
store.upsert_client(&hash_a, "alice", None).await.unwrap();
store.upsert_client(&hash_b, "bob", None).await.unwrap();
let rows = store.list_clients().await.unwrap();
let by_name: std::collections::HashMap<_, _> = rows
.iter()
.map(|r| (r.name.clone(), r.vtoken.clone()))
.collect();
assert_eq!(
by_name.get("alice").map(String::as_str),
Some(hash_a.as_str())
);
assert_eq!(
by_name.get("bob").map(String::as_str),
Some(hash_b.as_str())
);
}
#[tokio::test]
async fn test_bot_credentials_encryption_decryption() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
assert!(store.load_credentials().await.unwrap().is_none());
assert!(store
.save_credentials("my-secret-token", "https://api.example.com")
.await
.is_err());
let raw_key = [0u8; 32];
let unbound_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &raw_key).unwrap();
let key = ring::aead::LessSafeKey::new(unbound_key);
store
.set_master_key(std::sync::Arc::new(key))
.expect("set_master_key");
let loaded = store.load_credentials().await.unwrap();
assert!(loaded.is_none());
store
.save_credentials("my-secret-token", "https://api.example.com")
.await
.unwrap();
let loaded = store.load_credentials().await.unwrap().expect("loaded");
assert_eq!(loaded.0, "my-secret-token");
assert_eq!(loaded.1, "https://api.example.com");
let row: (String,) = sqlx::query_as("SELECT token FROM bot_credentials WHERE id = 1")
.fetch_one(&store.pool)
.await
.unwrap();
assert_ne!(row.0, "my-secret-token");
use base64::{engine::general_purpose::STANDARD as B64, Engine};
assert!(B64.decode(&row.0).is_ok());
let store2 = Store {
pool: store.pool.clone(),
rpool: store.pool.clone(),
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
assert!(store2.load_credentials().await.is_err());
}
#[test]
fn test_load_or_derive_master_key_scenarios() {
let _guard = ENV_MUTEX.lock().unwrap();
let old_val = std::env::var("ILINK_HUB_MASTER_KEY");
std::env::remove_var("ILINK_HUB_MASTER_KEY");
let res = crate::runtime::crypto::load_or_derive_master_key();
assert!(res.is_err());
std::env::set_var("ILINK_HUB_MASTER_KEY", "short");
assert!(crate::runtime::crypto::load_or_derive_master_key().is_err());
std::env::set_var(
"ILINK_HUB_MASTER_KEY",
"not-hex-and-too-long-but-invalid-characters-zzzzzzzzzzzzzzzzzzzzzzzzz",
);
assert!(crate::runtime::crypto::load_or_derive_master_key().is_err());
let hex_key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
std::env::set_var("ILINK_HUB_MASTER_KEY", hex_key);
let res = crate::runtime::crypto::load_or_derive_master_key();
assert!(res.is_ok());
std::env::set_var("ILINK_HUB_MASTER_KEY", format!("\"{}\"", hex_key));
assert!(crate::runtime::crypto::load_or_derive_master_key().is_ok());
std::env::set_var("ILINK_HUB_MASTER_KEY", format!("'{}'", hex_key));
assert!(crate::runtime::crypto::load_or_derive_master_key().is_ok());
std::env::set_var("ILINK_HUB_MASTER_KEY", format!(" {} ", hex_key));
assert!(crate::runtime::crypto::load_or_derive_master_key().is_ok());
let b64_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
std::env::set_var("ILINK_HUB_MASTER_KEY", b64_key);
let res = crate::runtime::crypto::load_or_derive_master_key();
assert!(res.is_ok());
std::env::set_var("ILINK_HUB_MASTER_KEY", format!(" \"{}\" ", b64_key));
assert!(crate::runtime::crypto::load_or_derive_master_key().is_ok());
match old_val {
Ok(val) => std::env::set_var("ILINK_HUB_MASTER_KEY", val),
Err(_) => std::env::remove_var("ILINK_HUB_MASTER_KEY"),
}
}
#[tokio::test]
async fn test_bot_credentials_decryption_adversarial_wrong_key() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let raw_key_a = [0u8; 32];
let unbound_key_a = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &raw_key_a).unwrap();
let key_a = ring::aead::LessSafeKey::new(unbound_key_a);
store
.set_master_key(std::sync::Arc::new(key_a))
.expect("set_master_key");
store
.save_credentials("my-secret-token", "https://api.example.com")
.await
.unwrap();
let raw_key_b = [1u8; 32];
let unbound_key_b = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &raw_key_b).unwrap();
let key_b = ring::aead::LessSafeKey::new(unbound_key_b);
let store_b = Store {
pool: store.pool.clone(),
rpool: store.pool.clone(),
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store_b
.set_master_key(std::sync::Arc::new(key_b))
.expect("set_master_key");
let res = store_b.load_credentials().await;
assert!(res.is_err());
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("Decryption failed"));
}
#[tokio::test]
async fn test_bot_credentials_decryption_adversarial_tampered_ciphertext() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let raw_key = [0u8; 32];
let unbound_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &raw_key).unwrap();
let key = ring::aead::LessSafeKey::new(unbound_key);
store
.set_master_key(std::sync::Arc::new(key))
.expect("set_master_key");
store
.save_credentials("my-secret-token", "https://api.example.com")
.await
.unwrap();
sqlx::query("UPDATE bot_credentials SET token = 'not-base64-at-all-$$$' WHERE id = 1")
.execute(&store.pool)
.await
.unwrap();
assert!(store.load_credentials().await.is_err());
sqlx::query("UPDATE bot_credentials SET token = 'c2hvcnQ=' WHERE id = 1") .execute(&store.pool)
.await
.unwrap();
let res = store.load_credentials().await;
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("data too short"));
store
.save_credentials("my-secret-token", "https://api.example.com")
.await
.unwrap();
let row: (String,) = sqlx::query_as("SELECT token FROM bot_credentials WHERE id = 1")
.fetch_one(&store.pool)
.await
.unwrap();
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let mut bytes = B64.decode(&row.0).unwrap();
bytes[20] ^= 1;
let corrupted_b64 = B64.encode(&bytes);
sqlx::query("UPDATE bot_credentials SET token = $1 WHERE id = 1")
.bind(corrupted_b64)
.execute(&store.pool)
.await
.unwrap();
let res = store.load_credentials().await;
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("Decryption failed"));
}
#[tokio::test]
async fn m3_recent_outbound_messages_filters_role_and_orders_newest_first() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
store
.save_message("vctx1", Some("vt1"), "default", "user@x", "user", "inbound")
.await
.unwrap();
store
.save_message(
"vctx1",
Some("vt1"),
"default",
"user@x",
"assistant",
"first reply",
)
.await
.unwrap();
store
.save_message(
"vctx1",
Some("vt1"),
"default",
"user@x",
"assistant",
"second reply",
)
.await
.unwrap();
store
.save_message(
"vctx2",
Some("vt2"),
"default",
"user@y",
"assistant",
"", )
.await
.unwrap();
store
.save_message(
"vctx1",
Some("vt1"),
"default",
"user@x",
"assistant",
"third reply",
)
.await
.unwrap();
let rows = store.recent_outbound_messages(500).await.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].text, "third reply");
assert_eq!(rows[0].from_user, "user@x");
assert_eq!(rows[0].vtoken.as_deref(), Some("vt1"));
assert_eq!(rows[1].text, "second reply");
assert_eq!(rows[2].text, "first reply");
}
#[tokio::test]
async fn m3_recent_outbound_messages_clamps_limit() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
for i in 0..5 {
store
.save_message(
"vctx1",
Some("vt1"),
"default",
"user@x",
"assistant",
&format!("reply {i}"),
)
.await
.unwrap();
}
let rows = store.recent_outbound_messages(0).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].text, "reply 4");
let rows_neg = store.recent_outbound_messages(-5).await.unwrap();
assert_eq!(rows_neg.len(), 1);
let rows_huge = store.recent_outbound_messages(100_000).await.unwrap();
assert_eq!(rows_huge.len(), 5);
}
#[tokio::test]
async fn m3_warmup_round_trip_through_quote_index() {
use crate::hub::quote_route::{
warm_item_from_recent_row, QuoteOrigin, QuoteRouteIndex, WarmItem,
};
use crate::ilink::types::{MessageItem, TextItem, WeixinMessage};
let store = Store::connect("sqlite::memory:").await.expect("connect");
let body = "你好!有什么我可以帮你的吗?\n\n---\nilink-claude · session-20260611-125634";
let body2 = "完成了\n\n---\nilink-claude · session-20260611-130000";
store
.save_message(
"vctx1",
Some("vt1"),
"session-20260611-125634",
"user@x",
"user",
"在吗",
)
.await
.unwrap();
store
.save_message(
"vctx1",
Some("vt1"),
"session-20260611-125634",
"user@x",
"assistant",
body,
)
.await
.unwrap();
store
.save_message(
"vctx1",
Some("vt1"),
"session-20260611-130000",
"user@x",
"assistant",
body2,
)
.await
.unwrap();
let rows = store.recent_outbound_messages(500).await.unwrap();
assert_eq!(rows.len(), 2);
let items: Vec<WarmItem> = rows.iter().filter_map(warm_item_from_recent_row).collect();
let mut idx = QuoteRouteIndex::default();
let n = idx.warm_from_history(&items);
assert_eq!(n, 2);
fn quote_reply(scope: &str, text: &str) -> WeixinMessage {
let ref_item = serde_json::json!({
"ref_msg": {
"message_item": {
"type": 1,
"text_item": { "text": text }
}
}
});
WeixinMessage {
message_type: Some(1),
from_user_id: Some(scope.into()),
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(1),
text_item: Some(TextItem {
text: Some("follow up".into()),
}),
extra: ref_item,
..Default::default()
}])),
..Default::default()
}
}
let user_msg = quote_reply("user@x", body);
match idx
.resolve_user_quote("user@x", &user_msg)
.expect("warmup must resolve")
{
QuoteOrigin::Client {
vtoken,
session_name,
..
} => {
assert_eq!(vtoken, "vt1");
assert_eq!(session_name.as_deref(), Some("session-20260611-125634"));
}
_ => panic!("expected Client origin"),
}
let user_msg2 = quote_reply("user@x", body2);
match idx
.resolve_user_quote("user@x", &user_msg2)
.expect("warmup must resolve second row")
{
QuoteOrigin::Client {
vtoken,
session_name,
..
} => {
assert_eq!(vtoken, "vt1");
assert_eq!(session_name.as_deref(), Some("session-20260611-130000"));
}
_ => panic!("expected Client origin"),
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_migration_v8_hash_vtoken_and_encrypt_bot_token() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.unwrap();
store.migrate_to_v1().await.unwrap();
store.migrate_to_v2().await.unwrap();
store.migrate_to_v3().await.unwrap();
store.migrate_to_v4().await.unwrap();
store.migrate_to_v5().await.unwrap();
store.migrate_to_v6().await.unwrap();
store.migrate_to_v7().await.unwrap();
let plain_vtoken = "plain_vtoken_12345";
sqlx::query("INSERT INTO clients (vtoken, name, label) VALUES ($1, $2, $3)")
.bind(plain_vtoken)
.bind("client_1")
.bind(Some("My Client"))
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO routing_state (from_user, active_vtoken) VALUES ($1, $2)")
.bind("user_1")
.bind(plain_vtoken)
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO messages (vctx, vtoken, session_name, role, content) VALUES ($1, $2, $3, $4, $5)")
.bind("vctx_1")
.bind(plain_vtoken)
.bind("default")
.bind("user")
.bind("hello")
.execute(store.pool())
.await
.unwrap();
let plain_bot_token = "plain_bot_token_secret_value";
sqlx::query("INSERT INTO bot_credentials (id, token, base_url) VALUES (1, $1, $2)")
.bind(plain_bot_token)
.bind("https://dummy.url")
.execute(store.pool())
.await
.unwrap();
let old_key = std::env::var("ILINK_HUB_MASTER_KEY");
let temp_key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
std::env::set_var("ILINK_HUB_MASTER_KEY", temp_key);
store
.migrate_to_v8()
.await
.expect("migrate_to_v8 should succeed");
let migration_key = crate::runtime::crypto::load_or_derive_master_key()
.expect("master key must be loadable while temp_key is still set");
if let Ok(ref k) = old_key {
std::env::set_var("ILINK_HUB_MASTER_KEY", k);
} else {
std::env::remove_var("ILINK_HUB_MASTER_KEY");
}
let hashed_vtoken = crate::hub::hash_vtoken(plain_vtoken);
let client_vtoken_db: String =
sqlx::query_scalar("SELECT vtoken FROM clients WHERE name = 'client_1'")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(client_vtoken_db, hashed_vtoken);
let route_vtoken_db: String =
sqlx::query_scalar("SELECT active_vtoken FROM routing_state WHERE from_user = 'user_1'")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(route_vtoken_db, hashed_vtoken);
let msg_vtoken_db: String =
sqlx::query_scalar("SELECT vtoken FROM messages WHERE vctx = 'vctx_1'")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(msg_vtoken_db, hashed_vtoken);
let cred_token_db: String =
sqlx::query_scalar("SELECT token FROM bot_credentials WHERE id = 1")
.fetch_one(store.pool())
.await
.unwrap();
assert_ne!(cred_token_db, plain_bot_token);
let decrypted = crate::runtime::crypto::decrypt_token(&cred_token_db, &migration_key).unwrap();
assert_eq!(decrypted, plain_bot_token);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_migration_v8_missing_master_key_fails() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.unwrap();
store.migrate_to_v1().await.unwrap();
store.migrate_to_v2().await.unwrap();
store.migrate_to_v3().await.unwrap();
store.migrate_to_v4().await.unwrap();
store.migrate_to_v5().await.unwrap();
store.migrate_to_v6().await.unwrap();
store.migrate_to_v7().await.unwrap();
sqlx::query("INSERT INTO clients (vtoken, name, label) VALUES ($1, $2, $3)")
.bind("plain_token")
.bind("client_1")
.bind(Some("Client"))
.execute(store.pool())
.await
.unwrap();
let old_key = std::env::var("ILINK_HUB_MASTER_KEY");
std::env::remove_var("ILINK_HUB_MASTER_KEY");
let res = store.migrate_to_v8().await;
if let Ok(ref k) = old_key {
std::env::set_var("ILINK_HUB_MASTER_KEY", k);
} else {
std::env::remove_var("ILINK_HUB_MASTER_KEY");
}
assert!(res.is_err());
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("ILINK_HUB_MASTER_KEY is required"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_migration_v8_idempotency_does_not_double_encrypt() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
let store = Store {
rpool: pool.clone(),
pool,
kind: DatabaseKind::Sqlite,
master_key: std::sync::OnceLock::new(),
};
store
.ddl(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)",
)
.await
.unwrap();
store.migrate_to_v1().await.unwrap();
store.migrate_to_v2().await.unwrap();
store.migrate_to_v3().await.unwrap();
store.migrate_to_v4().await.unwrap();
store.migrate_to_v5().await.unwrap();
store.migrate_to_v6().await.unwrap();
store.migrate_to_v7().await.unwrap();
let plain_bot_token = "plain_bot_token_secret_value";
sqlx::query("INSERT INTO bot_credentials (id, token, base_url) VALUES (1, $1, $2)")
.bind(plain_bot_token)
.bind("https://dummy.url")
.execute(store.pool())
.await
.unwrap();
sqlx::query("INSERT INTO clients (vtoken, name, label) VALUES ($1, $2, $3)")
.bind("vhub_plain_sentinel_for_migration_test")
.bind("test-client")
.bind(Option::<String>::None)
.execute(store.pool())
.await
.unwrap();
let old_key = std::env::var("ILINK_HUB_MASTER_KEY");
let temp_key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
std::env::set_var("ILINK_HUB_MASTER_KEY", temp_key);
store
.migrate_to_v8()
.await
.expect("migrate_to_v8 first run should succeed");
let migration_key =
crate::runtime::crypto::load_or_derive_master_key().expect("master key loadable");
let cred_token_1: String = sqlx::query_scalar("SELECT token FROM bot_credentials WHERE id = 1")
.fetch_one(store.pool())
.await
.unwrap();
assert_ne!(cred_token_1, plain_bot_token);
assert_eq!(
crate::runtime::crypto::decrypt_token(&cred_token_1, &migration_key).unwrap(),
plain_bot_token
);
sqlx::query("DELETE FROM schema_version WHERE version = 8")
.execute(store.pool())
.await
.unwrap();
store
.migrate_to_v8()
.await
.expect("migrate_to_v8 second run should succeed");
let cred_token_2: String = sqlx::query_scalar("SELECT token FROM bot_credentials WHERE id = 1")
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(cred_token_2, cred_token_1);
assert_eq!(
crate::runtime::crypto::decrypt_token(&cred_token_2, &migration_key).unwrap(),
plain_bot_token
);
if let Ok(ref k) = old_key {
std::env::set_var("ILINK_HUB_MASTER_KEY", k);
} else {
std::env::remove_var("ILINK_HUB_MASTER_KEY");
}
}
#[tokio::test]
async fn test_recent_outbound_messages_limit_clamp() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
for i in 0..200_i64 {
store
.save_message(
&format!("vctx-{i}"),
Some("vtoken-test"),
"default",
"peer:user1",
"assistant",
&format!("message content {i}"),
)
.await
.expect("save_message");
}
let rows_100 = store
.recent_outbound_messages(100)
.await
.expect("recent_outbound_messages(100)");
assert_eq!(
rows_100.len(),
100,
"limit=100 should return exactly 100 rows"
);
let rows_5000 = store
.recent_outbound_messages(5000)
.await
.expect("recent_outbound_messages(5000)");
assert_eq!(
rows_5000.len(),
200,
"limit=5000 should return all 200 available rows"
);
}
#[tokio::test]
async fn test_like_escape_in_find_assistant_message() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let peer = "peer:escape-test-user";
let special_content = "test%value_here\\end";
store
.save_message(
"vctx-esc",
Some("vtoken-esc"),
"default",
peer,
"assistant",
special_content,
)
.await
.expect("save_message with special chars");
store
.save_message(
"vctx-esc2",
Some("vtoken-esc2"),
"default",
peer,
"assistant",
"testXvalueYhereZend",
)
.await
.expect("save_message decoy");
let result = store
.find_assistant_message_by_content(peer, special_content)
.await
.expect("find_assistant_message_by_content");
assert!(
result.is_some(),
"should find the message with special content"
);
let (vtoken, session) = result.unwrap();
assert_eq!(vtoken, "vtoken-esc", "should match the correct vtoken");
assert_eq!(session, Some("default".to_string()));
}
#[tokio::test]
async fn test_get_session_status_empty() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let result = store
.get_session_status_per_vtoken(&[])
.await
.expect("get_session_status_per_vtoken with empty slice");
assert!(result.is_empty(), "empty input must produce empty output");
}
#[tokio::test]
async fn test_get_session_status_multi_vtoken() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let vtoken1 = "vtoken-alpha";
let vtoken2 = "vtoken-beta";
store
.save_message(
"vctx-a1",
Some(vtoken1),
"default",
"peer:a",
"assistant",
"reply A1",
)
.await
.unwrap();
store
.save_message(
"vctx-a2",
Some(vtoken1),
"default",
"peer:a",
"user",
"question A2",
)
.await
.unwrap();
store
.save_message(
"vctx-b1",
Some(vtoken2),
"default",
"peer:b",
"user",
"question B1",
)
.await
.unwrap();
store
.save_message(
"vctx-b2",
Some(vtoken2),
"default",
"peer:b",
"assistant",
"reply B2",
)
.await
.unwrap();
let vtokens = vec![vtoken1.to_string(), vtoken2.to_string()];
let result = store
.get_session_status_per_vtoken(&vtokens)
.await
.expect("get_session_status_per_vtoken");
assert_eq!(result.len(), 2, "should return entries for both vtokens");
let entry1 = result.get(vtoken1).expect("entry for vtoken1");
assert!(
entry1.waiting_for_reply,
"vtoken1: last message is user → waiting"
);
assert_eq!(
entry1.last_user_content.as_deref(),
Some("question A2"),
"vtoken1: latest user content must be question A2"
);
let entry2 = result.get(vtoken2).expect("entry for vtoken2");
assert!(
!entry2.waiting_for_reply,
"vtoken2: last message is assistant → not waiting"
);
assert_eq!(
entry2.last_user_content.as_deref(),
Some("question B1"),
"vtoken2: latest user content must be question B1"
);
}
#[tokio::test]
async fn test_get_all_session_entries_empty() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let result = store
.get_all_session_entries_per_vtoken(&[])
.await
.expect("get_all_session_entries_per_vtoken with empty slice");
assert!(result.is_empty(), "empty input must produce empty output");
}
#[tokio::test]
async fn test_get_all_session_entries_single_vtoken() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let vtoken = "vt-n13-single";
store
.save_message("ctx1", Some(vtoken), "default", "peer1", "user", "hello")
.await
.unwrap();
store
.save_message("ctx2", Some(vtoken), "default", "peer1", "assistant", "hi")
.await
.unwrap();
store
.save_message("ctx3", Some(vtoken), "default", "peer1", "user", "world")
.await
.unwrap();
let result = store
.get_all_session_entries_per_vtoken(&[vtoken.to_string()])
.await
.expect("get_all_session_entries_per_vtoken");
assert_eq!(result.len(), 1, "should return entries for one vtoken");
let entries = result.get(vtoken).expect("entries for vtoken");
assert_eq!(entries.len(), 1, "one session");
let entry = &entries[0];
assert_eq!(entry.session_name, "default");
assert!(
entry.waiting_for_reply,
"last message is user → waiting_for_reply must be true"
);
assert_eq!(
entry.last_user_content.as_deref(),
Some("world"),
"last user content must be 'world'"
);
assert!(
entry.user_msg_created_at.is_some(),
"user_msg_created_at must be set"
);
}
#[tokio::test]
async fn test_get_all_session_entries_multi_session() {
let store = Store::connect("sqlite::memory:").await.expect("connect");
let vtoken = "vt-n13-multi";
store
.save_message(
"ctxA1",
Some(vtoken),
"session-a",
"peerA",
"user",
"question-A",
)
.await
.unwrap();
store
.save_message(
"ctxA2",
Some(vtoken),
"session-a",
"peerA",
"assistant",
"answer-A",
)
.await
.unwrap();
store
.save_message(
"ctxB1",
Some(vtoken),
"session-b",
"peerB",
"user",
"question-B",
)
.await
.unwrap();
let result = store
.get_all_session_entries_per_vtoken(&[vtoken.to_string()])
.await
.expect("get_all_session_entries_per_vtoken");
assert_eq!(result.len(), 1);
let entries = result.get(vtoken).expect("entries for vtoken");
assert_eq!(entries.len(), 2, "two sessions must each produce an entry");
let entry_a = entries
.iter()
.find(|e| e.session_name == "session-a")
.expect("entry for session-a");
let entry_b = entries
.iter()
.find(|e| e.session_name == "session-b")
.expect("entry for session-b");
assert!(
!entry_a.waiting_for_reply,
"session-a: assistant replied last → not waiting"
);
assert_eq!(
entry_a.last_user_content.as_deref(),
Some("question-A"),
"session-a last user content"
);
assert!(
entry_b.waiting_for_reply,
"session-b: user message pending → waiting"
);
assert_eq!(
entry_b.last_user_content.as_deref(),
Some("question-B"),
"session-b last user content"
);
}
#[tokio::test]
async fn find_assistant_message_scope_uses_peer_prefix() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let scope = "peer:o9cq80_testuser@im.wechat";
let vtoken = "a92250b1deadbeef";
let session = "at-20260622-152900941";
let text = "🤖 Claude\n───────\nhello world\n\n---\nat-20260622-152900941";
store
.save_message("vctx_abc", Some(vtoken), session, scope, "assistant", text)
.await
.unwrap();
let result = store
.find_assistant_message_by_content(scope, "🤖 Claude")
.await
.unwrap();
assert!(
result.is_some(),
"DB quote lookup must find the row when the scope includes 'peer:' prefix"
);
let (found_vt, found_session) = result.unwrap();
assert_eq!(found_vt, vtoken);
assert_eq!(found_session.as_deref(), Some(session));
let raw_uid = "o9cq80_testuser@im.wechat";
let miss = store
.find_assistant_message_by_content(raw_uid, "🤖 Claude")
.await
.unwrap();
assert!(
miss.is_none(),
"DB quote lookup must NOT match when scope is missing the 'peer:' prefix (pre-fix regression guard)"
);
}
#[tokio::test]
async fn find_vtoken_for_session_resolves_persona_footer_fallback() {
let store = Store::connect("sqlite::memory:").await.unwrap();
let scope = "peer:o9cq80_testuser@im.wechat";
let vtoken = "a92250b1deadbeef";
let session = "at-20260622-152900941";
store
.find_or_create_vctx("o9cq80_testuser@im.wechat", None, "AARzJWAFAAA_real_ctx")
.await
.unwrap();
let actual_vctx = store
.find_vctx_for_scope(scope)
.await
.unwrap()
.expect("vctx must exist after find_or_create_vctx");
store
.set_backend_session(&actual_vctx, vtoken, session, "some-uuid")
.await
.unwrap();
let found_vctx = store
.find_vctx_for_scope(scope)
.await
.unwrap()
.expect("find_vctx_for_scope must return Some");
assert_eq!(found_vctx, actual_vctx);
let found_vt = store
.find_vtoken_for_session(&actual_vctx, session)
.await
.unwrap()
.expect("find_vtoken_for_session must return Some");
assert_eq!(found_vt, vtoken);
let not_found = store
.find_vtoken_for_session(&actual_vctx, "at-99991231-999999")
.await
.unwrap();
assert!(not_found.is_none());
}