feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -0,0 +1,724 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::{
|
||||
content_manifest::CatalogBundle,
|
||||
db::{Game, GameDB},
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
|
||||
use crate::eti::EtiGame;
|
||||
|
||||
/// Application catalog state loaded from one coherent database snapshot.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadedCatalog {
|
||||
game_db: GameDB,
|
||||
bundle: Arc<CatalogBundle>,
|
||||
}
|
||||
|
||||
impl LoadedCatalog {
|
||||
/// Returns the UI-facing game database.
|
||||
#[must_use]
|
||||
pub const fn game_db(&self) -> &GameDB {
|
||||
&self.game_db
|
||||
}
|
||||
|
||||
/// Returns the immutable content authority paired with the UI database.
|
||||
#[must_use]
|
||||
pub fn bundle(&self) -> &CatalogBundle {
|
||||
&self.bundle
|
||||
}
|
||||
|
||||
/// Clones the shared handle used by peer runtime consumers.
|
||||
#[must_use]
|
||||
pub fn shared_bundle(&self) -> Arc<CatalogBundle> {
|
||||
Arc::clone(&self.bundle)
|
||||
}
|
||||
|
||||
/// Splits the loaded state into its UI and content-authority components.
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (GameDB, Arc<CatalogBundle>) {
|
||||
(self.game_db, self.bundle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the UI catalog and its exact content authority from one database
|
||||
/// snapshot.
|
||||
///
|
||||
/// Manifest filenames and filesystem shapes are checked eagerly. Manifest
|
||||
/// bodies remain on-demand so application startup does not parse every catalog
|
||||
/// artifact.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the database or manifest root is unsafe or invalid,
|
||||
/// database identities are ambiguous, genre expansion is not exactly one row
|
||||
/// per game, or manifest filenames do not exactly cover the database catalog.
|
||||
pub async fn load_catalog_bundle(
|
||||
game_db_path: &Path,
|
||||
manifests_root: &Path,
|
||||
) -> eyre::Result<LoadedCatalog> {
|
||||
validate_regular_file(game_db_path, "catalog database")?;
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(game_db_path)
|
||||
.read_only(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to open catalog database {}", game_db_path.display()))?;
|
||||
|
||||
let query_result = query_catalog_snapshot(&pool).await;
|
||||
let (authority_rows, ui_rows) = close_pool_after_query(&pool, query_result)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to read catalog database {}", game_db_path.display()))?;
|
||||
|
||||
assemble_catalog(authority_rows, ui_rows, manifests_root)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
struct CatalogAuthorityRow {
|
||||
db_id: i64,
|
||||
game_id: String,
|
||||
game_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct JoinedCatalogRow {
|
||||
db_id: i64,
|
||||
game_id: String,
|
||||
game_title: String,
|
||||
game_key: String,
|
||||
game_release: String,
|
||||
game_publisher: String,
|
||||
game_size: f64,
|
||||
game_readme_de: String,
|
||||
game_readme_en: String,
|
||||
game_readme_fr: String,
|
||||
game_maxplayers: u32,
|
||||
game_master_req: i32,
|
||||
genre_de: String,
|
||||
game_version: String,
|
||||
}
|
||||
|
||||
impl JoinedCatalogRow {
|
||||
fn into_eti_game(self) -> EtiGame {
|
||||
EtiGame {
|
||||
game_id: self.game_id,
|
||||
game_title: self.game_title,
|
||||
game_key: self.game_key,
|
||||
game_release: self.game_release,
|
||||
game_publisher: self.game_publisher,
|
||||
game_size: self.game_size,
|
||||
game_readme_de: self.game_readme_de,
|
||||
game_readme_en: self.game_readme_en,
|
||||
game_readme_fr: self.game_readme_fr,
|
||||
game_maxplayers: self.game_maxplayers,
|
||||
game_master_req: self.game_master_req,
|
||||
genre_de: self.genre_de,
|
||||
game_version: self.game_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_catalog_snapshot(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<(Vec<CatalogAuthorityRow>, Vec<JoinedCatalogRow>), sqlx::Error> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let authority_rows = sqlx::query_as::<_, CatalogAuthorityRow>(
|
||||
"SELECT CAST(db_id AS INTEGER) AS db_id, game_id, game_version
|
||||
FROM games
|
||||
ORDER BY db_id, game_id",
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
let ui_rows = sqlx::query_as::<_, JoinedCatalogRow>(
|
||||
"SELECT
|
||||
CAST(g.db_id AS INTEGER) AS db_id,
|
||||
g.game_id, g.game_title, g.game_key, g.game_release,
|
||||
g.game_publisher, CAST(g.game_size AS REAL) AS game_size,
|
||||
g.game_readme_de, g.game_readme_en, g.game_readme_fr,
|
||||
CAST(g.game_maxplayers AS INTEGER) AS game_maxplayers,
|
||||
g.game_master_req, ge.genre_de, g.game_version
|
||||
FROM games g
|
||||
JOIN genre ge ON g.genre_id = ge.genre_id
|
||||
ORDER BY g.db_id, g.game_id",
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok((authority_rows, ui_rows))
|
||||
}
|
||||
|
||||
async fn close_pool_after_query<T>(
|
||||
pool: &SqlitePool,
|
||||
query_result: Result<T, sqlx::Error>,
|
||||
) -> Result<T, sqlx::Error> {
|
||||
// `Pool::close` is infallible. Await it on both result paths before the
|
||||
// caller propagates a database error.
|
||||
pool.close().await;
|
||||
debug_assert!(pool.is_closed());
|
||||
query_result
|
||||
}
|
||||
|
||||
fn assemble_catalog(
|
||||
authority_rows: Vec<CatalogAuthorityRow>,
|
||||
ui_rows: Vec<JoinedCatalogRow>,
|
||||
manifests_root: &Path,
|
||||
) -> eyre::Result<LoadedCatalog> {
|
||||
if authority_rows.is_empty() {
|
||||
eyre::bail!("catalog database contains no games");
|
||||
}
|
||||
|
||||
let mut authorities_by_db_id = BTreeMap::new();
|
||||
let mut expected_versions = BTreeMap::new();
|
||||
for row in authority_rows {
|
||||
if authorities_by_db_id.contains_key(&row.db_id) {
|
||||
eyre::bail!(
|
||||
"catalog database contains duplicate raw game db_id: {}",
|
||||
row.db_id
|
||||
);
|
||||
}
|
||||
if expected_versions.contains_key(&row.game_id) {
|
||||
eyre::bail!(
|
||||
"catalog database contains duplicate raw game ID: {}",
|
||||
row.game_id
|
||||
);
|
||||
}
|
||||
expected_versions.insert(row.game_id.clone(), row.game_version.clone());
|
||||
authorities_by_db_id.insert(row.db_id, row);
|
||||
}
|
||||
|
||||
let mut expanded_db_ids = HashSet::new();
|
||||
let mut games = Vec::with_capacity(authorities_by_db_id.len());
|
||||
for row in ui_rows {
|
||||
let authority = authorities_by_db_id
|
||||
.get(&row.db_id)
|
||||
.ok_or_else(|| eyre::eyre!("genre join produced unknown game db_id: {}", row.db_id))?;
|
||||
if !expanded_db_ids.insert(row.db_id) {
|
||||
eyre::bail!(
|
||||
"duplicate genre join expansion for game {} (db_id {})",
|
||||
authority.game_id,
|
||||
authority.db_id
|
||||
);
|
||||
}
|
||||
if row.game_id != authority.game_id || row.game_version != authority.game_version {
|
||||
eyre::bail!(
|
||||
"catalog identity/version mismatch for game db_id {}",
|
||||
authority.db_id
|
||||
);
|
||||
}
|
||||
games.push(Game::from(row.into_eti_game()));
|
||||
}
|
||||
|
||||
for (db_id, authority) in &authorities_by_db_id {
|
||||
if !expanded_db_ids.contains(db_id) {
|
||||
eyre::bail!(
|
||||
"missing genre join expansion for game {} (db_id {})",
|
||||
authority.game_id,
|
||||
authority.db_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let bundle = Arc::new(CatalogBundle::new(manifests_root, expected_versions)?);
|
||||
Ok(LoadedCatalog {
|
||||
game_db: GameDB::from(games),
|
||||
bundle,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_regular_file(path: &Path, label: &str) -> eyre::Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?;
|
||||
if is_link_or_reparse(&metadata) || !metadata.is_file() {
|
||||
eyre::bail!("{label} is not a regular non-link file: {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_type().is_symlink()
|
||||
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
CATALOG_CONTENT_INDEX_NAME,
|
||||
CatalogContentIdentity,
|
||||
CatalogContentIndex,
|
||||
CatalogContentIndexEntry,
|
||||
ContentId,
|
||||
write_canonical_content_index_atomic,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new() -> Self {
|
||||
let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should follow epoch")
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lanspread-catalog-bundle-{}-{nanos}-{sequence}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("test directory should be created");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GameRow<'a> {
|
||||
db_id: i64,
|
||||
game_id: &'a str,
|
||||
game_version: &'a str,
|
||||
genre_id: i64,
|
||||
}
|
||||
|
||||
async fn create_catalog_db(path: &Path, games: &[GameRow<'_>], genres: &[(i64, &str)]) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("fixture database should open");
|
||||
sqlx::query(
|
||||
"CREATE TABLE games (
|
||||
game_id TEXT NOT NULL, db_id INTEGER NOT NULL,
|
||||
game_title TEXT NOT NULL, game_key TEXT NOT NULL,
|
||||
game_release TEXT NOT NULL, game_publisher TEXT NOT NULL,
|
||||
game_size REAL NOT NULL, game_readme_de TEXT NOT NULL,
|
||||
game_readme_en TEXT NOT NULL, game_readme_fr TEXT NOT NULL,
|
||||
game_maxplayers INTEGER NOT NULL, game_master_req INTEGER NOT NULL,
|
||||
genre_id INTEGER NOT NULL, game_version TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("games table should be created");
|
||||
sqlx::query("CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genre table should be created");
|
||||
|
||||
for (genre_id, genre_de) in genres {
|
||||
sqlx::query("INSERT INTO genre (genre_id, genre_de) VALUES (?, ?)")
|
||||
.bind(genre_id)
|
||||
.bind(genre_de)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genre should insert");
|
||||
}
|
||||
for game in games {
|
||||
sqlx::query(
|
||||
"INSERT INTO games (
|
||||
game_id, db_id, game_title, game_key, game_release,
|
||||
game_publisher, game_size, game_readme_de, game_readme_en,
|
||||
game_readme_fr, game_maxplayers, game_master_req, genre_id,
|
||||
game_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(game.game_id)
|
||||
.bind(game.db_id)
|
||||
.bind(format!("Game {}", game.game_id))
|
||||
.bind("key")
|
||||
.bind("2024")
|
||||
.bind("publisher")
|
||||
.bind(1.0_f64)
|
||||
.bind("readme de")
|
||||
.bind("readme en")
|
||||
.bind("readme fr")
|
||||
.bind(4_i64)
|
||||
.bind(0_i64)
|
||||
.bind(game.genre_id)
|
||||
.bind(game.game_version)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("game should insert");
|
||||
}
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
fn create_manifest_root(
|
||||
root: &Path,
|
||||
indexed_games: &[(&str, &str)],
|
||||
artifact_game_ids: &[&str],
|
||||
) -> PathBuf {
|
||||
let manifests = root.join("manifests");
|
||||
fs::create_dir(&manifests).expect("manifest root should be created");
|
||||
for game_id in artifact_game_ids {
|
||||
fs::write(
|
||||
manifests.join(format!("{game_id}.json")),
|
||||
b"not parsed at startup\n",
|
||||
)
|
||||
.expect("manifest artifact should be created");
|
||||
}
|
||||
let index = CatalogContentIndex::from_entries(indexed_games.iter().enumerate().map(
|
||||
|(position, (game_id, game_version))| CatalogContentIndexEntry {
|
||||
game_id: (*game_id).to_owned(),
|
||||
game_version: (*game_version).to_owned(),
|
||||
identity: CatalogContentIdentity {
|
||||
content_id: ContentId::from_bytes(
|
||||
[u8::try_from(position + 1).expect("test position should fit u8"); 32],
|
||||
),
|
||||
supports_streamed_install: false,
|
||||
},
|
||||
},
|
||||
))
|
||||
.expect("test content index should validate");
|
||||
write_canonical_content_index_atomic(&manifests.join(CATALOG_CONTENT_INDEX_NAME), &index)
|
||||
.expect("test content index should publish");
|
||||
manifests
|
||||
}
|
||||
|
||||
fn joined_row(db_id: i64, game_id: &str, game_version: &str) -> JoinedCatalogRow {
|
||||
JoinedCatalogRow {
|
||||
db_id,
|
||||
game_id: game_id.to_owned(),
|
||||
game_title: format!("Game {game_id}"),
|
||||
game_key: "key".to_owned(),
|
||||
game_release: "2024".to_owned(),
|
||||
game_publisher: "publisher".to_owned(),
|
||||
game_size: 1.0,
|
||||
game_readme_de: "readme de".to_owned(),
|
||||
game_readme_en: "readme en".to_owned(),
|
||||
game_readme_fr: "readme fr".to_owned(),
|
||||
game_maxplayers: 4,
|
||||
game_master_req: 0,
|
||||
genre_de: "Strategy".to_owned(),
|
||||
game_version: game_version.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_pairs_ui_catalog_with_lazy_exact_authority() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
&[(10, "Strategy")],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
|
||||
let loaded = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect("filename coverage should load without parsing JSON");
|
||||
|
||||
let game = loaded
|
||||
.game_db()
|
||||
.get_game_by_id("g")
|
||||
.expect("UI game should exist");
|
||||
assert_eq!(game.genre, "Strategy");
|
||||
assert_eq!(game.eti_game_version.as_deref(), Some("20240101"));
|
||||
assert!(loaded.bundle().catalog().contains("g"));
|
||||
assert_eq!(
|
||||
loaded.bundle().catalog().expected_version("g"),
|
||||
Some("20240101")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.bundle()
|
||||
.content_identity("g")
|
||||
.expect("indexed identity should be available")
|
||||
.content_id,
|
||||
ContentId::from_bytes([1; 32])
|
||||
);
|
||||
assert!(loaded.bundle().cached_manifest("g").is_err());
|
||||
assert!(loaded.bundle().manifest("g").is_err());
|
||||
fs::remove_file(&db).expect("successful loading must close the catalog database");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_rejects_incomplete_manifest_publication() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
&[(10, "Strategy")],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
fs::write(
|
||||
manifests.join(lanspread_db::content_manifest::CATALOG_PUBLICATION_MARKER_NAME),
|
||||
b"lanspread catalog publication v1\n",
|
||||
)
|
||||
.expect("publication marker should be created");
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect_err("runtime loading must reject an interrupted publication");
|
||||
|
||||
assert!(error.to_string().contains("publication is incomplete"));
|
||||
fs::remove_file(&db).expect("validation failure must leave the database closed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_rejects_duplicate_raw_game_ids_before_hash_authority() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[
|
||||
GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
},
|
||||
GameRow {
|
||||
db_id: 2,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
},
|
||||
],
|
||||
&[(10, "Strategy")],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect_err("duplicate raw IDs must fail");
|
||||
assert!(error.to_string().contains("duplicate raw game ID: g"));
|
||||
fs::remove_file(&db).expect("validation failure must leave the database closed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_rejects_missing_genre_expansion() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect_err("a game without a joined genre must fail");
|
||||
assert!(error.to_string().contains("missing genre join expansion"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_rejects_duplicate_genre_expansion() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
&[(10, "Strategy"), (10, "Duplicate")],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect_err("ambiguous genre expansion must fail");
|
||||
assert!(error.to_string().contains("duplicate genre join expansion"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_loader_rejects_identity_and_genre_corruption_matrix() {
|
||||
let cases = [
|
||||
(
|
||||
"duplicate raw database ID",
|
||||
vec![
|
||||
GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
},
|
||||
GameRow {
|
||||
db_id: 1,
|
||||
game_id: "h",
|
||||
game_version: "20240102",
|
||||
genre_id: 10,
|
||||
},
|
||||
],
|
||||
vec![(10, "Strategy")],
|
||||
"duplicate raw game db_id",
|
||||
),
|
||||
(
|
||||
"missing genre join",
|
||||
vec![GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
vec![],
|
||||
"missing genre join expansion",
|
||||
),
|
||||
(
|
||||
"duplicate genre join",
|
||||
vec![GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
vec![(10, "Strategy"), (10, "Duplicate")],
|
||||
"duplicate genre join expansion",
|
||||
),
|
||||
];
|
||||
|
||||
for (label, games, genres, expected_error) in cases {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(&db, &games, &genres).await;
|
||||
let indexed_games = games
|
||||
.iter()
|
||||
.map(|game| (game.game_id, game.game_version))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let game_ids = indexed_games
|
||||
.iter()
|
||||
.map(|(game_id, _)| *game_id)
|
||||
.collect::<Vec<_>>();
|
||||
let manifests = create_manifest_root(root.path(), &indexed_games, &game_ids);
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests).await.expect_err(label);
|
||||
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{label} produced unexpected error: {error:#}"
|
||||
);
|
||||
fs::remove_file(&db).expect("loader failure must leave the database closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loader_rejects_inexact_manifest_filename_coverage() {
|
||||
let root = TestDir::new();
|
||||
let db = root.path().join("game.db");
|
||||
create_catalog_db(
|
||||
&db,
|
||||
&[GameRow {
|
||||
db_id: 1,
|
||||
game_id: "g",
|
||||
game_version: "20240101",
|
||||
genre_id: 10,
|
||||
}],
|
||||
&[(10, "Strategy")],
|
||||
)
|
||||
.await;
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g", "other"]);
|
||||
|
||||
let error = load_catalog_bundle(&db, &manifests)
|
||||
.await
|
||||
.expect_err("unexpected JSON artifacts must fail");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unexpected catalog manifest artifact: other.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_rejects_ui_authority_version_drift() {
|
||||
let root = TestDir::new();
|
||||
let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]);
|
||||
let authority = CatalogAuthorityRow {
|
||||
db_id: 1,
|
||||
game_id: "g".to_owned(),
|
||||
game_version: "20240101".to_owned(),
|
||||
};
|
||||
|
||||
let error = assemble_catalog(
|
||||
vec![authority],
|
||||
vec![joined_row(1, "g", "20250101")],
|
||||
&manifests,
|
||||
)
|
||||
.expect_err("UI rows must not drift from the authority snapshot");
|
||||
|
||||
assert!(error.to_string().contains("identity/version mismatch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_error_is_returned_only_after_pool_close() {
|
||||
let pool =
|
||||
SqlitePoolOptions::new().connect_lazy_with(SqliteConnectOptions::new().in_memory(true));
|
||||
let query_result: Result<(), sqlx::Error> = Err(sqlx::Error::RowNotFound);
|
||||
|
||||
let error = close_pool_after_query(&pool, query_result)
|
||||
.await
|
||||
.expect_err("query failure should propagate");
|
||||
|
||||
assert!(matches!(error, sqlx::Error::RowNotFound));
|
||||
assert!(pool.is_closed());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user