test(peer): cover serve gating dispatch

Add focused serve-side tests for the gates around peer requests. GetGame now has
coverage for the non-catalog, active-operation, and missing-sentinel cases that
should return GameNotFound instead of exposing local files.

The full-file and chunk handlers both depend on the same transfer gate before
touching the QUIC send stream. Extract that gate into a small helper and test
the same cases there, plus the existing local-path exclusion, so both dispatch
paths stay aligned without adding fake QUIC stream plumbing.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_2.md
This commit is contained in:
2026-05-16 09:16:37 +02:00
parent 2a94445391
commit 7731a9daa0
+188 -7
View File
@@ -296,6 +296,16 @@ async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str
local_download_available(game_dir, game_id, &active_operations, &catalog).await local_download_available(game_dir, game_id, &active_operations, &catalog).await
} }
async fn can_dispatch_file_transfer(
ctx: &PeerCtx,
game_dir: &std::path::Path,
game_id: &str,
relative_path: &str,
) -> bool {
!path_points_inside_local(game_id, relative_path)
&& can_serve_game(ctx, game_dir, game_id).await
}
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool { fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
let normalised = relative_path.replace('\\', "/"); let normalised = relative_path.replace('\\', "/");
let mut parts = normalised.split('/').filter(|part| !part.is_empty()); let mut parts = normalised.split('/').filter(|part| !part.is_empty());
@@ -318,9 +328,7 @@ async fn handle_file_data_request(
let mut tx = framed_tx.into_inner(); let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone(); let game_dir = ctx.game_dir.read().await.clone();
if path_points_inside_local(&desc.game_id, &desc.relative_path) if !can_dispatch_file_transfer(ctx, &game_dir, &desc.game_id, &desc.relative_path).await {
|| !can_serve_game(ctx, &game_dir, &desc.game_id).await
{
log::info!( log::info!(
"Declining GetGameFileData for {} because the game is not currently transferable", "Declining GetGameFileData for {} because the game is not currently transferable",
desc.relative_path desc.relative_path
@@ -347,9 +355,7 @@ async fn handle_file_chunk_request(
let mut tx = framed_tx.into_inner(); let mut tx = framed_tx.into_inner();
let game_dir = ctx.game_dir.read().await.clone(); let game_dir = ctx.game_dir.read().await.clone();
if path_points_inside_local(&game_id, &relative_path) if !can_dispatch_file_transfer(ctx, &game_dir, &game_id, &relative_path).await {
|| !can_serve_game(ctx, &game_dir, &game_id).await
{
log::info!( log::info!(
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable" "Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
); );
@@ -389,7 +395,87 @@ async fn handle_announce_games(ctx: &PeerCtx, remote_addr: Option<SocketAddr>, g
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::path_points_inside_local; use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::{RwLock, mpsc};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{
UnpackFuture,
Unpacker,
context::{Ctx, OperationKind},
peer_db::PeerGameDB,
};
struct TempDir(PathBuf);
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
impl TempDir {
fn new() -> Self {
let mut path = std::env::temp_dir();
let unique_id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
path.push(format!(
"lanspread-stream-{}-{}-{}",
std::process::id(),
unique_id,
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&path).expect("temp dir should be created");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct NoopUnpacker;
impl Unpacker for NoopUnpacker {
fn unpack<'a>(&'a self, _archive: &'a Path, _dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async { Ok(()) })
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf, catalog: HashSet<String>) -> PeerCtx {
let (tx_notify_ui, _rx) = mpsc::unbounded_channel();
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
Arc::new(NoopUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
)
.to_peer_ctx(tx_notify_ui)
}
#[test] #[test]
fn local_relative_paths_are_never_transferable() { fn local_relative_paths_are_never_transferable() {
@@ -399,4 +485,99 @@ mod tests {
assert!(!path_points_inside_local("game", "game/version.ini")); assert!(!path_points_inside_local("game", "game/version.ini"));
assert!(!path_points_inside_local("game", "game/archive.eti")); assert!(!path_points_inside_local("game", "game/archive.eti"));
} }
#[tokio::test]
async fn get_game_response_respects_serve_gates() {
let temp = TempDir::new();
write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
write_file(
&temp.path().join("non-catalog").join("version.ini"),
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
ctx.active_operations
.write()
.await
.insert("active".to_string(), OperationKind::Downloading);
assert!(matches!(
get_game_response(&ctx, "ready".to_string()).await,
Response::GetGame { id, .. } if id == "ready"
));
assert!(matches!(
get_game_response(&ctx, "non-catalog".to_string()).await,
Response::GameNotFound(id) if id == "non-catalog"
));
assert!(matches!(
get_game_response(&ctx, "active".to_string()).await,
Response::GameNotFound(id) if id == "active"
));
assert!(matches!(
get_game_response(&ctx, "missing-sentinel".to_string()).await,
Response::GameNotFound(id) if id == "missing-sentinel"
));
}
#[tokio::test]
async fn file_transfer_dispatch_respects_serve_gates() {
let temp = TempDir::new();
write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
write_file(
&temp.path().join("non-catalog").join("version.ini"),
b"20250101",
);
write_file(&temp.path().join("active").join("version.ini"), b"20250101");
std::fs::create_dir_all(temp.path().join("missing-sentinel"))
.expect("missing sentinel root should be created");
let ctx = test_ctx(
temp.path().to_path_buf(),
HashSet::from([
"ready".to_string(),
"active".to_string(),
"missing-sentinel".to_string(),
]),
);
ctx.active_operations
.write()
.await
.insert("active".to_string(), OperationKind::Downloading);
assert!(can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/version.ini").await);
assert!(
!can_dispatch_file_transfer(
&ctx,
temp.path(),
"non-catalog",
"non-catalog/version.ini",
)
.await
);
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "active", "active/version.ini").await
);
assert!(
!can_dispatch_file_transfer(
&ctx,
temp.path(),
"missing-sentinel",
"missing-sentinel/archive.eti",
)
.await
);
assert!(
!can_dispatch_file_transfer(&ctx, temp.path(), "ready", "ready/local/save.dat").await
);
}
} }