Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ vs 0.8.7: no semver update required — `^0.8` consumers pick it up automaticall
`calls_since` filters by it with a 1000-entry read — immune to the window's position. Method
signatures unchanged; verified live (the two failing 4DA dogfood tests pass with the fix, plus two
new mock-server regression tests).
- **`victauri-test`: IPC checkpoints can no longer miss a call sharing the checkpoint's
millisecond** (found by the pre-release GPT-5.5 adversarial audit). `ipc_calls_since` filters
with a strict `timestamp > checkpoint`, so a call logged in the same millisecond immediately
after `create_ipc_checkpoint` returned was silently invisible. The checkpoint now waits until
the local clock has advanced past the checkpoint millisecond before returning (bounded at 5 ms
against clock skew), so calls made after the checkpoint cannot share the boundary timestamp.
Regression test pins the same-ms boundary; a second new test pins Bearer-auth enforcement on
the `2026-07-28` `server/discover` lifecycle method for stateless MCP.

### Changed

Expand Down
56 changes: 56 additions & 0 deletions crates/victauri-plugin/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,62 @@ async fn stateless_still_enforces_auth() {
);
}

#[tokio::test]
async fn stateless_auth_gates_discover_lifecycle() {
let base = start_stateless_auth_test_server(test_state(), &["main"], "secret-token").await;
let client = reqwest::Client::new();

let discover = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
});

let unauth = client
.post(format!("{base}/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "server/discover")
.json(&discover)
.send()
.await
.unwrap();
assert_eq!(
unauth.status(),
reqwest::StatusCode::UNAUTHORIZED,
"server/discover must be rejected before rmcp dispatch without Bearer auth"
);

let authed = client
.post(format!("{base}/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "server/discover")
.header("Authorization", "Bearer secret-token")
.json(&discover)
.send()
.await
.unwrap();
assert!(
authed.status().is_success(),
"authenticated server/discover should reach the MCP handler, got {}",
authed.status()
);
let body: serde_json::Value = authed.json().await.unwrap();
assert!(
body["result"]["supportedVersions"].is_array(),
"authenticated response should be a DiscoverResult: {body}"
);
}

#[tokio::test]
async fn mcp_full_session_lists_tools() {
let base = start_test_server(test_state(), &["main"]).await;
Expand Down
40 changes: 34 additions & 6 deletions crates/victauri-test/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use crate::assertions::VerifyBuilder;
use crate::error::TestError;
use crate::visual::{VisualDiff, VisualOptions};

const IPC_CHECKPOINT_CLOCK_ADVANCE_CAP: Duration = Duration::from_millis(5);

fn current_epoch_ms() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}

async fn wait_past_ipc_checkpoint_ms(checkpoint_ms: u64) {
let Some(now_ms) = current_epoch_ms() else {
return;
};
if checkpoint_ms == 0 || checkpoint_ms < now_ms {
return;
}

let sleep_ms = checkpoint_ms.saturating_sub(now_ms).saturating_add(1);
let cap_ms = u64::try_from(IPC_CHECKPOINT_CLOCK_ADVANCE_CAP.as_millis()).unwrap_or(u64::MAX);
tokio::time::sleep(Duration::from_millis(sleep_ms.min(cap_ms))).await;
}

// ── Typed Response Structs (Phase 4E) ───────────────────────────────────────

/// Structured plugin information returned by [`VictauriClient::plugin_info`].
Expand Down Expand Up @@ -1421,10 +1444,13 @@ impl VictauriClient {
/// Returns the maximum entry `timestamp` (epoch milliseconds) currently
/// visible in the IPC log, or `0` when the log is empty. Pass this value to
/// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred
/// after the checkpoint. Timestamp-based rather than length-based because
/// the server's log tools return a capped sliding window of the newest
/// entries — a positional checkpoint stops working once a busy app exceeds
/// the cap.
/// after the checkpoint. For non-empty logs this method waits until the
/// local clock has advanced past the checkpoint millisecond before it
/// returns, so calls made immediately after the checkpoint cannot share the
/// boundary timestamp and be filtered out by the strict `>` comparison.
/// Timestamp-based rather than length-based because the server's log tools
/// return a capped sliding window of the newest entries — a positional
/// checkpoint stops working once a busy app exceeds the cap.
///
/// # Errors
///
Expand All @@ -1435,11 +1461,13 @@ impl VictauriClient {
// snapshot breaks (always-empty `calls_since`) once the app has logged
// more calls than the cap. Timestamps are window-position-independent.
let entries = self.ipc_log_entries().await?;
Ok(entries
let checkpoint_ms = entries
.iter()
.filter_map(|e| e.get("timestamp").and_then(Value::as_u64))
.max()
.unwrap_or(0) as usize)
.unwrap_or(0);
wait_past_ipc_checkpoint_ms(checkpoint_ms).await;
Ok(checkpoint_ms as usize)
}

// ── Typed Response Methods (Phase 4E) ────────────────────────────────────
Expand Down
48 changes: 48 additions & 0 deletions crates/victauri-test/tests/client_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use axum::Router;
use axum::body::Body;
Expand All @@ -15,6 +16,16 @@ use victauri_test::{
assert_no_a11y_violations, assert_performance_budget, assert_state_matches,
};

fn test_epoch_ms() -> u64 {
u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock must be after Unix epoch")
.as_millis(),
)
.expect("epoch milliseconds must fit in u64")
}

// ── Mock MCP Server ───────────────────────────────────────────────────────

#[derive(Clone)]
Expand Down Expand Up @@ -1162,6 +1173,43 @@ async fn ipc_checkpoint_survives_sliding_window_cap() {
);
}

#[tokio::test]
async fn ipc_checkpoint_waits_past_current_millisecond_boundary() {
let state = MockState::new();
let port = start_mock_server(state.clone()).await;
let mut client = VictauriClient::connect(port).await.unwrap();

let boundary = test_epoch_ms().saturating_add(2);
let before = vec![json!({"command": "before", "timestamp": boundary})];
*state.response_override.lock().await = Some(json!({
"content": [{"type": "text", "text": serde_json::to_string(&before).unwrap()}]
}));

let cp = client.create_ipc_checkpoint().await.unwrap();
assert_eq!(cp, boundary as usize);

let after_ts = test_epoch_ms();
assert!(
after_ts > boundary,
"checkpoint should not return until the next observable IPC timestamp is greater \
than the checkpoint boundary: after_ts={after_ts}, boundary={boundary}"
);

let after = vec![
json!({"command": "before", "timestamp": boundary}),
json!({"command": "after", "timestamp": after_ts}),
];
*state.response_override.lock().await = Some(json!({
"content": [{"type": "text", "text": serde_json::to_string(&after).unwrap()}]
}));

let since = client.get_ipc_calls_since(cp).await.unwrap();
assert_eq!(
since,
vec![json!({"command": "after", "timestamp": after_ts})]
);
}

#[tokio::test]
async fn ipc_checkpoint_empty_log_returns_all_later_entries() {
// Zero checkpoint (empty log at checkpoint time): everything that appears
Expand Down
Loading