Skip to content

Commit 106a152

Browse files
committed
🧪 test(server): e2e — admin POST credential → agent pin → resolve env vars (ADR-005)
1 parent b36af85 commit 106a152

1 file changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
//! ADR-005 end-to-end: prove that a credential configured via the
2+
//! admin API actually reaches the agent runtime as the right env vars.
3+
//!
4+
//! Path under test (each step uses its production code path; the test
5+
//! only forges the auth context):
6+
//!
7+
//! 1. **Configure**: `POST /api/admin/credentials/llm` writes the
8+
//! profile through `admin_api::create_llm_credential` →
9+
//! `EncryptingConfigStore::put_with_actor` → vault.
10+
//! 2. **Reference**: insert an agent_def via the storage trait, then
11+
//! `PUT /api/agent-definitions/:id` to set `credential_id` —
12+
//! this exercises the same handler the AgentEditorForm hits.
13+
//! 3. **Visibility**: `GET /api/agent-definitions/:id/resolved-credential`
14+
//! returns the resolved binding (mode + namespace + profile_id)
15+
//! so the UI can show "Will use: providers:openai-prod".
16+
//! 4. **Runtime materialisation**: call `resolve_credential_for_agent`
17+
//! (the same function `dispatch_api`/`cli_agent_api` use) and
18+
//! assert the resulting `SecretMaterial::Env` entries carry the
19+
//! adapter-correct names + the value from the *configured*
20+
//! profile. This is what gets injected as env vars into the
21+
//! spawned CLI process.
22+
//!
23+
//! Steps 1, 2, 3 hit real HTTP handlers via `tower::ServiceExt::oneshot`
24+
//! against the routers admin_api/api expose. Step 4 calls into the
25+
//! resolver directly against the same `AppState`, proving the runtime
26+
//! reads the same row the API persisted.
27+
28+
use std::sync::{Arc, Mutex as StdMutex};
29+
30+
use awaken_contract::contract::config_store::ConfigStore;
31+
use axum::body::{to_bytes, Body};
32+
use axum::http::{Request, StatusCode};
33+
use chrono::Utc;
34+
use oversight_models::{AgentDef, AuthUser};
35+
use oversight_server::auth_mw::AuthContext;
36+
use oversight_server::credentials::{resolve_credential_for_agent, BindingMode};
37+
use oversight_server::state::AppState;
38+
use oversight_server::{admin_api, build_api_router};
39+
use oversight_store::repo_authz::NewGrant;
40+
use oversight_vault::{
41+
CooldownRepo, CredentialPool, ProfileMetaRepo, SqliteCooldownRepo, SqliteProfileMetaRepo,
42+
};
43+
use serde_json::{json, Value};
44+
use tower::ServiceExt;
45+
46+
mod common;
47+
48+
const VAULT_SCHEMA: &str = include_str!("../../../migrations/V068__vault.sql");
49+
50+
fn admin_user() -> AuthUser {
51+
AuthUser {
52+
id: "admin-e2e".into(),
53+
username: "admin".into(),
54+
password_hash: String::new(),
55+
role: "admin".into(),
56+
created_at: Utc::now(),
57+
disabled_at: None,
58+
}
59+
}
60+
61+
fn req(method: &str, path: &str, body: Option<Value>, auth: Option<AuthContext>) -> Request<Body> {
62+
let mut builder = Request::builder().method(method).uri(path);
63+
if body.is_some() {
64+
builder = builder.header("content-type", "application/json");
65+
}
66+
let body = match body {
67+
Some(value) => Body::from(value.to_string()),
68+
None => Body::empty(),
69+
};
70+
let mut request = builder.body(body).expect("request");
71+
if let Some(auth) = auth {
72+
request.extensions_mut().insert(auth);
73+
}
74+
request
75+
}
76+
77+
async fn body_json(response: axum::response::Response) -> Value {
78+
let bytes = to_bytes(response.into_body(), 1024 * 1024)
79+
.await
80+
.expect("body");
81+
if bytes.is_empty() {
82+
Value::Null
83+
} else {
84+
serde_json::from_slice(&bytes).expect("json")
85+
}
86+
}
87+
88+
/// Wire a real `CredentialPool` into the AppState. The common helper
89+
/// leaves it `None` (most tests don't need it); for this e2e we want
90+
/// the resolver to actually walk the vault.
91+
fn install_credential_pool(state: &mut AppState) {
92+
let conn = rusqlite::Connection::open_in_memory().expect("sqlite open");
93+
conn.execute_batch(VAULT_SCHEMA).expect("vault schema");
94+
let conn = Arc::new(StdMutex::new(conn));
95+
let meta: Arc<dyn ProfileMetaRepo> = Arc::new(SqliteProfileMetaRepo::new(conn.clone()));
96+
let cooldowns: Arc<dyn CooldownRepo> = Arc::new(SqliteCooldownRepo::new(conn));
97+
state.credential_pool = Some(Arc::new(CredentialPool::new(
98+
state.vault.clone() as Arc<dyn ConfigStore>,
99+
meta,
100+
cooldowns,
101+
)));
102+
}
103+
104+
/// Grant the given user the workspace-admin role and reload the
105+
/// authorizer so the new grant takes effect for subsequent calls.
106+
/// `agent.create` and `agent.write` permissions cascade from this.
107+
async fn grant_admin_workspace_async(state: &AppState, user_id: &str) {
108+
{
109+
let store = state.store.lock().await;
110+
store
111+
.insert_authz_grant(NewGrant {
112+
principal_type: "user".into(),
113+
principal_id: user_id.into(),
114+
role_id: "admin".into(),
115+
scope_type: "workspace".into(),
116+
scope_id: "root".into(),
117+
require_approval: false,
118+
granted_by_type: "system".into(),
119+
granted_by_id: "system".into(),
120+
expires_at: None,
121+
})
122+
.expect("insert grant");
123+
}
124+
oversight_server::authz_sync::reload_authorizer(state)
125+
.await
126+
.expect("reload authorizer");
127+
}
128+
129+
#[tokio::test]
130+
async fn config_to_dispatch_chain_injects_provider_env_vars() {
131+
let mut state = common::build_sqlite_app_state().await;
132+
install_credential_pool(&mut state);
133+
134+
let admin = AuthContext { user: admin_user() };
135+
grant_admin_workspace_async(&state, &admin.user.id).await;
136+
137+
// ── 1. Configure: admin POST creates an LLM credential profile. ──
138+
let admin_router = admin_api::routes(state.clone());
139+
let create_response = admin_router
140+
.clone()
141+
.oneshot(req(
142+
"POST",
143+
"/credentials/llm",
144+
Some(json!({
145+
"id": "openai-prod",
146+
"adapter": "openai",
147+
"api_key": "sk-CONFIGURED-VIA-API",
148+
"base_url": "https://api.openai.example/v1",
149+
})),
150+
Some(admin.clone()),
151+
))
152+
.await
153+
.expect("admin create");
154+
// admin_api::create_llm_credential returns 201 CREATED on success.
155+
assert_eq!(
156+
create_response.status(),
157+
StatusCode::CREATED,
158+
"credential create",
159+
);
160+
161+
// Sanity: the vault now has a redacted view of the row.
162+
let list_response = admin_router
163+
.clone()
164+
.oneshot(req("GET", "/credentials/llm", None, Some(admin.clone())))
165+
.await
166+
.expect("admin list");
167+
assert_eq!(list_response.status(), StatusCode::OK);
168+
let listed = body_json(list_response).await;
169+
let items = listed
170+
.get("items")
171+
.and_then(Value::as_array)
172+
.expect("items array");
173+
assert_eq!(items.len(), 1);
174+
assert_eq!(items[0]["id"], "openai-prod");
175+
assert_eq!(
176+
items[0]["has_api_key"].as_bool(),
177+
Some(true),
178+
"list endpoint reports the key is set",
179+
);
180+
181+
// ── 2. Reference: create the agent via HTTP POST so the creator-admin
182+
// grant gets recorded (the API has authorization checks on
183+
// subsequent PUT). Then PUT to set credential_id (Pinned mode).
184+
let api_router = build_api_router(state.clone());
185+
let create_agent = api_router
186+
.clone()
187+
.oneshot(req(
188+
"POST",
189+
"/api/agent-definitions",
190+
Some(json!({
191+
"agent_id": "codex-prod",
192+
"display_name": "Codex Prod",
193+
"system_prompt": "system prompt",
194+
"model": "gpt-5-codex",
195+
"agent_type": "codex",
196+
"created_by": format!("user:{}", admin.user.id),
197+
})),
198+
Some(admin.clone()),
199+
))
200+
.await
201+
.expect("create agent");
202+
assert_eq!(
203+
create_agent.status(),
204+
StatusCode::CREATED,
205+
"agent POST should create + grant creator-admin",
206+
);
207+
let created = body_json(create_agent).await;
208+
let agent_id = created["id"].as_str().expect("agent id").to_string();
209+
210+
let pin_body = json!({
211+
// Mirror the AgentEditorForm payload shape (CreateAgentDefRequest).
212+
"agent_id": "codex-prod",
213+
"display_name": "Codex Prod",
214+
"model": "gpt-5-codex",
215+
"agent_type": "codex",
216+
"credential_id": "providers:openai-prod",
217+
});
218+
let pin_response = api_router
219+
.clone()
220+
.oneshot(req(
221+
"PUT",
222+
&format!("/api/agent-definitions/{agent_id}"),
223+
Some(pin_body),
224+
Some(admin.clone()),
225+
))
226+
.await
227+
.expect("pin agent");
228+
assert_eq!(
229+
pin_response.status(),
230+
StatusCode::OK,
231+
"agent PUT with credential_id should succeed",
232+
);
233+
234+
// Confirm the storage layer persisted the pin.
235+
let stored = state
236+
.storage
237+
.get_agent_def(&agent_id)
238+
.await
239+
.expect("get agent");
240+
assert_eq!(
241+
stored.credential_id.as_deref(),
242+
Some("providers:openai-prod"),
243+
"agent_def must carry the pin after PUT",
244+
);
245+
246+
// ── 3. Visibility: the read endpoint reports the resolved binding. ──
247+
let resolved_response = api_router
248+
.clone()
249+
.oneshot(req(
250+
"GET",
251+
&format!("/api/agent-definitions/{agent_id}/resolved-credential"),
252+
None,
253+
Some(admin.clone()),
254+
))
255+
.await
256+
.expect("resolved endpoint");
257+
assert_eq!(resolved_response.status(), StatusCode::OK);
258+
let resolved_json = body_json(resolved_response).await;
259+
assert_eq!(resolved_json["mode"], "pinned");
260+
assert_eq!(resolved_json["namespace"], "providers");
261+
assert_eq!(resolved_json["profile_id"], "openai-prod");
262+
// No note expected on the happy path — pinned + present + materialised.
263+
assert!(
264+
resolved_json["note"].is_null(),
265+
"no note on a clean pinned resolution",
266+
);
267+
268+
// ── 4. Runtime materialisation: dispatch path uses the same resolver. ──
269+
//
270+
// The resolver returns `SecretMaterial::Env` entries that are then
271+
// copied into `DispatchPayload.secrets` by both `dispatch_api` and
272+
// `cli_agent_api`. Asserting them here proves the configured profile
273+
// makes it all the way to what the worker would inject as env vars.
274+
let pool = state.credential_pool.as_ref().expect("pool wired");
275+
let runtime = resolve_credential_for_agent(pool, &stored).await;
276+
assert_eq!(runtime.mode, BindingMode::Pinned, "runtime sees Pinned");
277+
let materialised = runtime.resolved.expect("runtime materialises secrets");
278+
assert_eq!(materialised.namespace, "providers");
279+
assert_eq!(materialised.profile_id, "openai-prod");
280+
assert_eq!(
281+
materialised.secrets.len(),
282+
2,
283+
"expected OPENAI_API_KEY + OPENAI_BASE_URL",
284+
);
285+
286+
let env_pairs: Vec<(String, String)> = materialised
287+
.secrets
288+
.iter()
289+
.map(|m| match m {
290+
oversight_vault::SecretMaterial::Env { name, value } => {
291+
(name.clone(), value.expose_secret().to_string())
292+
}
293+
other => panic!("expected Env material, got {other:?}"),
294+
})
295+
.collect();
296+
297+
assert!(
298+
env_pairs
299+
.iter()
300+
.any(|(n, v)| n == "OPENAI_API_KEY" && v == "sk-CONFIGURED-VIA-API"),
301+
"OPENAI_API_KEY must carry the value the admin POSTed; got {env_pairs:?}",
302+
);
303+
assert!(
304+
env_pairs
305+
.iter()
306+
.any(|(n, v)| n == "OPENAI_BASE_URL" && v == "https://api.openai.example/v1"),
307+
"OPENAI_BASE_URL must carry the base_url the admin POSTed; got {env_pairs:?}",
308+
);
309+
}
310+
311+
#[tokio::test]
312+
async fn config_to_dispatch_chain_auto_mode_picks_pool_default() {
313+
// Companion test to the pinned path: when the agent has *no*
314+
// credential_id, the same chain still works — the pool's auto-pick
315+
// resolves to the only configured profile and its env vars flow
316+
// through to the dispatch payload. This guards against accidentally
317+
// breaking the legacy Auto path in the unified resolver.
318+
let mut state = common::build_sqlite_app_state().await;
319+
install_credential_pool(&mut state);
320+
321+
let admin = AuthContext { user: admin_user() };
322+
grant_admin_workspace_async(&state, &admin.user.id).await;
323+
let admin_router = admin_api::routes(state.clone());
324+
325+
// Configure a single CLI claude profile via the admin endpoint.
326+
let response = admin_router
327+
.oneshot(req(
328+
"POST",
329+
"/credentials/cli",
330+
Some(json!({
331+
"id": "claude-default",
332+
"adapter_type": "claude",
333+
"auth_kind": "api_key",
334+
"secret": "sk-CLAUDE-DEFAULT",
335+
"base_url": "https://api.anthropic.com/",
336+
})),
337+
Some(admin.clone()),
338+
))
339+
.await
340+
.expect("create");
341+
assert_eq!(response.status(), StatusCode::CREATED);
342+
343+
// Insert an agent with NO credential_id (Auto mode).
344+
let mut def = AgentDef::new("claude-auto", "Claude Auto", "prompt");
345+
def.agent_type = "claude".into();
346+
def.model = "sonnet".into();
347+
state.storage.insert_agent_def(&def).await.unwrap();
348+
349+
// Resolve and verify env vars come from the only configured profile.
350+
let pool = state.credential_pool.as_ref().expect("pool wired");
351+
let runtime = resolve_credential_for_agent(pool, &def).await;
352+
assert_eq!(runtime.mode, BindingMode::Auto);
353+
let materialised = runtime.resolved.expect("auto materialises");
354+
let env_pairs: Vec<(String, String)> = materialised
355+
.secrets
356+
.iter()
357+
.map(|m| match m {
358+
oversight_vault::SecretMaterial::Env { name, value } => {
359+
(name.clone(), value.expose_secret().to_string())
360+
}
361+
other => panic!("expected Env, got {other:?}"),
362+
})
363+
.collect();
364+
365+
assert!(env_pairs
366+
.iter()
367+
.any(|(n, v)| n == "ANTHROPIC_API_KEY" && v == "sk-CLAUDE-DEFAULT"));
368+
assert!(env_pairs
369+
.iter()
370+
.any(|(n, v)| n == "ANTHROPIC_BASE_URL" && v == "https://api.anthropic.com/"));
371+
}

0 commit comments

Comments
 (0)