- 1
//! Integration tests for multi-bot-per-channel identity - 2
//! (docs/design/34 Phase 5 follow-up, AGENTS.md invariants 23-24). - 3
//! - 4
//! These tests verify the three-segment key resolution at the HTTP - 5
//! `/gateway/inbound` boundary: a bot-scoped key (`surface:chat:bot_id`) - 6
//! must inherit from an already-allowed legacy key, each bot gets an - 7
//! independent entry, and a legacy (no bot_id) message still works. - 8
- 9
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 10
- 11
use std::collections::VecDeque; - 12
use std::sync::{Arc, Mutex}; - 13
- 14
use tokio_util::sync::CancellationToken; - 15
- 16
use vak_core::Core; - 17
use vak_llm::stream; - 18
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, Usage}; - 19
use vak_llm::{EventStream, LlmError, Provider}; - 20
- 21
struct Scripted { - 22
responses: Mutex<VecDeque<AssistantMessage>>, - 23
} - 24
- 25
#[async_trait::async_trait] - 26
impl Provider for Scripted { - 27
fn name(&self) -> &'static str { - 28
"scripted" - 29
} - 30
async fn stream( - 31
&self, - 32
_request: ChatRequest, - 33
_cancel: CancellationToken, - 34
) -> Result<EventStream, LlmError> { - 35
let next = self.responses.lock().unwrap().pop_front(); - 36
let (mut sink, rx) = stream::channel(64); - 37
match next { - 38
Some(m) => { - 39
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 40
sink.close_message(m).await; - 41
} - 42
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 43
} - 44
Ok(rx) - 45
} - 46
} - 47
- 48
fn text(t: &str) -> AssistantMessage { - 49
AssistantMessage { - 50
content: vec![ContentBlock::text(t)], - 51
stop_reason: vak_llm::types::StopReason::EndTurn, - 52
usage: Usage { - 53
input_tokens: 7, - 54
output_tokens: 3, - 55
..Default::default() - 56
}, - 57
model: "test-model".into(), - 58
response_id: None, - 59
} - 60
} - 61
- 62
/// Spawn a gateway server with the given allowlist and one scripted reply. - 63
/// Uses `gateway_router` (no bearer auth) for simplicity. - 64
async fn spawn_gateway(allowlist: &[&str]) -> String { - 65
let dir = tempfile::tempdir().unwrap(); - 66
let cwd = dir.path().to_path_buf(); - 67
let _ = std::fs::create_dir_all(cwd.join(".vak")); - 68
let list = allowlist - 69
.iter() - 70
.map(|k| format!("\"{k}\"")) - 71
.collect::<Vec<_>>() - 72
.join(", "); - 73
let config = format!( - 74
"[memory]\nreflection = false\n[gateway]\nenabled = true\nchat_allowlist = [{list}]\n" - 75
); - 76
let _ = std::fs::write(cwd.join(".vak/config.toml"), &config); - 77
vak_config::paths::isolate_home_for_tests(); - 78
let core = Core::new_with_trust(cwd.clone(), true).unwrap(); - 79
core.set_sessions_home(dir.path().join("home")); - 80
core.set_provider_instance(Arc::new(Scripted { - 81
responses: Mutex::new(VecDeque::from(vec![text("hello")])), - 82
})); - 83
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 84
let addr = listener.local_addr().unwrap(); - 85
tokio::spawn(async move { - 86
axum::serve(listener, vak_server::gateway_router(core)) - 87
.await - 88
.unwrap(); - 89
}); - 90
format!("http://{addr}") - 91
} - 92
- 93
/// POST a message to the gateway inbound endpoint (no auth for gateway_router). - 94
async fn post_inbound(base: &str, body: serde_json::Value) -> reqwest::StatusCode { - 95
let client = reqwest::Client::new(); - 96
client - 97
.post(format!("{base}/gateway/inbound")) - 98
.json(&body) - 99
.send() - 100
.await - 101
.unwrap() - 102
.status() - 103
} - 104
- 105
/// Two bots sharing one physical chat get independent allowlist entries. - 106
/// Bot A's message inherits the legacy approval; Bot B gets its own - 107
/// independent entry (rule 24: one identity per bot). - 108
#[tokio::test] - 109
async fn two_bots_on_same_chat_get_independent_keys() { - 110
let base = spawn_gateway(&["telegram:12345"]).await; - 111
- 112
// Bot A inherits from the pre-approved legacy key. - 113
let status_a = post_inbound( - 114
&base, - 115
serde_json::json!({ - 116
"surface": "telegram", - 117
"chat": "12345", - 118
"text": "hi from Alpha", - 119
"bot_id": "Alpha", - 120
}), - 121
) - 122
.await; - 123
assert!( - 124
status_a.is_success(), - 125
"bot-scoped key with legacy approval must be allowed (got {status_a})" - 126
); - 127
- 128
// Bot B — same physical chat, different bot identity. Also inherits - 129
// from the legacy key, but gets its own entry, session, and policy. - 130
let status_b = post_inbound( - 131
&base, - 132
serde_json::json!({ - 133
"surface": "telegram", - 134
"chat": "12345", - 135
"text": "hi from Beta", - 136
"bot_id": "Beta", - 137
}), - 138
) - 139
.await; - 140
assert!( - 141
status_b.is_success(), - 142
"second bot on same chat must be independently allowed (got {status_b})" - 143
); - 144
- 145
// A second message from Bot A should still be accepted (idempotent — - 146
// already-allowed bot-scoped key takes the same Allowed path). - 147
let status_a2 = post_inbound( - 148
&base, - 149
serde_json::json!({ - 150
"surface": "telegram", - 151
"chat": "12345", - 152
"text": "second from Alpha", - 153
"bot_id": "Alpha", - 154
}), - 155
) - 156
.await; - 157
assert!( - 158
status_a2.is_success(), - 159
"repeat bot-scoped message must be allowed (got {status_a2})" - 160
); - 161
} - 162
- 163
/// A bot-scoped key with no pre-approved legacy entry is rejected at the - 164
/// gateway (pending review, AG-34). - 165
#[tokio::test] - 166
async fn bot_scoped_key_without_legacy_approval_is_rejected() { - 167
let base = spawn_gateway(&["telegram:99999"]).await; - 168
- 169
let status = post_inbound( - 170
&base, - 171
serde_json::json!({ - 172
"surface": "telegram", - 173
"chat": "11111", - 174
"text": "unapproved chat with bot", - 175
"bot_id": "SoloBot", - 176
}), - 177
) - 178
.await; - 179
assert!( - 180
!status.is_success(), - 181
"bot-scoped key without legacy approval must be rejected (got {status})" - 182
); - 183
} - 184
- 185
/// A legacy (no bot_id) message to a pre-approved chat is allowed. - 186
/// This confirms the two-segment key path still works after multi-bot - 187
/// support was added (rule 24). - 188
#[tokio::test] - 189
async fn legacy_key_without_bot_id_still_works() { - 190
let base = spawn_gateway(&["telegram:54321"]).await; - 191
- 192
let status = post_inbound( - 193
&base, - 194
serde_json::json!({ - 195
"surface": "telegram", - 196
"chat": "54321", - 197
"text": "legacy message", - 198
}), - 199
) - 200
.await; - 201
assert!( - 202
status.is_success(), - 203
"legacy two-segment key should be allowed (got {status})" - 204
); - 205
} - 206
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.