Files
tsunagi/tests/cache_and_state.rs
T

287 lines
10 KiB
Rust
Raw Normal View History

//! Scenario 7: the disposable cache and the mandatory state store.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::io::Write;
use common::{TestAgent, config_with, local_config, network, wait_for_peers};
use tsunagi::config::StoragePaths;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::storage::CacheOutcome;
use tsunagi::{Agent, Error};
/// Overwrites a file with bytes that are definitely not a SQLite database.
fn corrupt(path: &std::path::Path) {
let mut file = std::fs::File::create(path).unwrap();
file.write_all(&[0x7f; 8192]).unwrap();
file.sync_all().unwrap();
}
#[tokio::test]
async fn a_missing_cache_is_recreated_and_does_not_block_connecting() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("missing-cache");
let peer = TestAgent::spawn(&discovery).await.unwrap();
let subject = TestAgent::spawn(&discovery).await.unwrap();
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
subject.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&peer.agent, network_id, 1).await;
let dir = subject.stop().await;
let paths = StoragePaths::under(dir.path());
std::fs::remove_dir_all(&paths.cache_dir).unwrap();
assert!(!paths.cache_db().exists());
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_eq!(
restarted.status().await.unwrap().cache_outcome,
CacheOutcome::Created
);
wait_for_peers(&restarted, network_id, 1).await;
restarted.shutdown().await;
peer.agent.shutdown().await;
drop(restarted);
drop(dir);
}
#[tokio::test]
async fn a_corrupt_cache_is_discarded_and_does_not_block_connecting() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("corrupt-cache");
let peer = TestAgent::spawn(&discovery).await.unwrap();
let subject = TestAgent::spawn(&discovery).await.unwrap();
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
subject.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&peer.agent, network_id, 1).await;
let device_id = subject.agent.endpoint_id();
let dir = subject.stop().await;
let paths = StoragePaths::under(dir.path());
corrupt(&paths.cache_db());
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let status = restarted.status().await.unwrap();
assert!(
matches!(status.cache_outcome, CacheOutcome::Reset(_)),
"expected the cache to be discarded, got {:?}",
status.cache_outcome
);
assert!(status.cache_healthy);
assert_eq!(
restarted.endpoint_id(),
device_id,
"a bad cache must not touch the identity"
);
wait_for_peers(&restarted, network_id, 1).await;
restarted.shutdown().await;
peer.agent.shutdown().await;
drop(restarted);
drop(dir);
}
#[tokio::test]
async fn a_stale_cache_does_not_prevent_connecting_through_discovery() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("stale-cache");
let peer = TestAgent::spawn(&discovery).await.unwrap();
let subject = TestAgent::spawn(&discovery).await.unwrap();
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
subject.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&peer.agent, network_id, 1).await;
// Stop both. When they come back they bind new ports, so every cached
// address hint is stale, and only fresh discovery can bridge the gap.
let peer_dir = peer.stop().await;
let subject_dir = subject.stop().await;
assert!(
StoragePaths::under(subject_dir.path()).cache_db().exists(),
"hints were written, so the cache is genuinely stale now"
);
let peer_again = Agent::spawn(config_with(peer_dir.path(), &discovery))
.await
.unwrap();
let subject_again = Agent::spawn(config_with(subject_dir.path(), &discovery))
.await
.unwrap();
wait_for_peers(&subject_again, network_id, 1).await;
wait_for_peers(&peer_again, network_id, 1).await;
peer_again.shutdown().await;
subject_again.shutdown().await;
drop(peer_again);
drop(subject_again);
drop(peer_dir);
drop(subject_dir);
}
#[tokio::test]
async fn a_corrupt_state_store_is_an_error_and_never_a_fresh_identity() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("corrupt-state");
let subject = TestAgent::spawn(&discovery).await.unwrap();
let device_id = subject.agent.endpoint_id();
subject.agent.join_network(&name, &secret).await.unwrap();
let dir = subject.stop().await;
let paths = StoragePaths::under(dir.path());
corrupt(&paths.state_db());
let result = Agent::spawn(config_with(dir.path(), &discovery)).await;
match result {
Err(Error::StateCorrupted { path, reason }) => {
assert_eq!(path, paths.state_db());
assert!(!reason.is_empty());
}
Err(other) => panic!("expected StateCorrupted, got {other:?}"),
Ok(agent) => {
let new_id = agent.endpoint_id();
agent.shutdown().await;
panic!(
"a corrupt state store must not yield a working agent (id {new_id}) — the previous identity was {device_id}"
);
}
}
drop(dir);
}
#[tokio::test]
async fn a_state_store_from_a_newer_build_is_refused() {
let dir = tempfile::TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
std::fs::create_dir_all(&paths.state_dir).unwrap();
{
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
conn.pragma_update(None, "user_version", 999i64).unwrap();
}
let result = Agent::spawn(local_config(dir.path())).await;
assert!(
matches!(result, Err(Error::UnsupportedSchema { found: 999, .. })),
"expected UnsupportedSchema"
);
}
/// A store written by the previous schema must come up, not break.
///
/// The record body gained a hostname, which changed the signing domain, so
/// records written before it can never verify again. Leaving them in place
/// would mean every read rejecting rows that look exactly like corruption.
#[tokio::test]
async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() {
let dir = tempfile::TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
std::fs::create_dir_all(&paths.state_dir).unwrap();
// Build a schema-2 store by hand, with a record in it.
{
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
conn.execute_batch(
"BEGIN;
CREATE TABLE device_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
secret_key BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE networks (
network_id BLOB PRIMARY KEY,
name TEXT NOT NULL,
secret BLOB NOT NULL,
auto_start INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE signed_records (
network_id BLOB NOT NULL,
author BLOB NOT NULL,
version INTEGER NOT NULL,
body BLOB NOT NULL,
signature BLOB NOT NULL,
PRIMARY KEY (network_id, author)
);
CREATE TABLE own_record_version (
network_id BLOB PRIMARY KEY,
version INTEGER NOT NULL
);
INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333');
INSERT INTO own_record_version VALUES (x'00', 7);
PRAGMA user_version = 2;
COMMIT;",
)
.unwrap();
}
// An agent comes up on it, which is the whole point.
let discovery = SharedMemoryDiscovery::new();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let (name, secret) = network("migrated");
let network_id = agent.join_network(&name, &secret).await.unwrap();
assert!(agent.network_status(network_id).await.is_ok());
agent.shutdown().await;
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, tsunagi::storage::SCHEMA_VERSION);
// The unverifiable record is gone rather than left to be rejected for
// ever, and the counter is keyed by author now.
let stale: i64 = conn
.query_row(
"SELECT count(*) FROM signed_records WHERE author = x'11'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stale, 0, "records from the old signing domain are dropped");
conn.query_row(
"SELECT count(*) FROM own_record_version WHERE author IS NOT NULL",
[],
|row| row.get::<_, i64>(0),
)
.expect("the counter is keyed by author");
}
#[tokio::test]
async fn secrets_never_appear_in_status_or_debug_output() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("no-leaks");
let agent = TestAgent::spawn(&discovery).await.unwrap();
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
let status = agent.agent.status().await.unwrap();
let rendered = format!("{status:?}");
let encoded = secret.encode();
assert!(!rendered.contains(encoded.as_str()));
assert!(rendered.contains(&network_id.to_string()) || rendered.contains("NetworkId"));
let network_status = agent.agent.network_status(network_id).await.unwrap();
assert!(!format!("{network_status:?}").contains(encoded.as_str()));
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
let keys_debug = format!("{keys:?}");
assert!(keys_debug.contains("<redacted>"));
assert!(!keys_debug.contains(encoded.as_str()));
agent.agent.shutdown().await;
}