Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
2399 lines
89 KiB
Rust
2399 lines
89 KiB
Rust
//! End-to-end client tests for team-OAuth managed config against a mock
|
|
//! deployment-config endpoint. Proxy-side resolution is unit-tested in
|
|
//! the cli-chat-proxy deployment-config route.
|
|
//!
|
|
//! Every test here MUST be `#[serial]`: they share one process-global
|
|
//! `GROK_HOME` (the `grok_home` `OnceLock` allows a single value per process)
|
|
//! and mutate that directory + process env, so concurrent tests would race.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::TcpListener;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use serial_test::serial;
|
|
use xai_grok_shell::config::ServingIdentity;
|
|
use xai_grok_test_support::spawn_counting_server;
|
|
|
|
/// The serving identity for a team id (the staleness checks key on this).
|
|
fn team_identity(id: &str) -> ServingIdentity {
|
|
ServingIdentity::Team(id.to_owned())
|
|
}
|
|
|
|
/// Shared temp dir used as GROK_HOME for the whole test binary (the grok_home
|
|
/// `OnceLock` only allows one value per process). Also scrubs/installs the env
|
|
/// this suite depends on, before any test thread reads it.
|
|
fn test_home() -> &'static PathBuf {
|
|
static HOME: OnceLock<PathBuf> = OnceLock::new();
|
|
HOME.get_or_init(|| {
|
|
let path = tempfile::TempDir::new().unwrap().keep();
|
|
// SAFETY: set once at init before other threads read the vars.
|
|
unsafe {
|
|
std::env::set_var("GROK_HOME", &path);
|
|
// Ambient env must not shadow the scenarios under test: a real
|
|
// deployment key, a managed-config opt-out, or a proxy that would
|
|
// intercept the 127.0.0.1 mocks.
|
|
for var in [
|
|
"GROK_DEPLOYMENT_KEY",
|
|
"GROK_MANAGED_CONFIG",
|
|
"GROK_DEPLOYMENT_CONFIG_REFRESH_INTERVAL_SECS",
|
|
"GROK_DEPLOYMENT_CONFIG_CACHE_TTL_SECS",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
] {
|
|
std::env::remove_var(var);
|
|
}
|
|
// Real exponential backoff would add seconds per retry test.
|
|
std::env::set_var("GROK_DEPLOYMENT_CONFIG_BACKOFF_MS", "10");
|
|
}
|
|
path
|
|
})
|
|
}
|
|
|
|
fn reset(home: &std::path::Path) {
|
|
for f in [
|
|
"config.toml",
|
|
"auth.json",
|
|
"managed_config.toml",
|
|
"requirements.toml",
|
|
"managed_config.sig.json",
|
|
"managed_config_cache.json",
|
|
"managed_config.lock",
|
|
] {
|
|
let _ = std::fs::remove_file(home.join(f));
|
|
}
|
|
}
|
|
|
|
/// Read one HTTP request's header block (up to the blank line) and return the
|
|
/// `Authorization` header value, if any. Header-boundary-safe, unlike a single
|
|
/// fixed-size `read()`.
|
|
fn read_request_auth(stream: &mut std::net::TcpStream) -> Option<String> {
|
|
let mut reader = BufReader::new(stream);
|
|
let mut auth = None;
|
|
loop {
|
|
let mut line = String::new();
|
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
|
return auth;
|
|
}
|
|
let line = line.trim_end();
|
|
if line.is_empty() {
|
|
return auth;
|
|
}
|
|
if let Some((name, value)) = line.split_once(':')
|
|
&& name.eq_ignore_ascii_case("authorization")
|
|
{
|
|
auth = Some(value.trim().to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mock deployment-config server serving `body` to every request. Returns the
|
|
/// URL and the `Authorization` header of every request in order.
|
|
fn spawn_mock(body: String) -> (String, Arc<Mutex<Vec<String>>>) {
|
|
let (url, _count, auths) = spawn_mock_seq(vec![(200, body)]);
|
|
(url, auths)
|
|
}
|
|
|
|
/// Like [`spawn_mock_seq`] but sleeps `delay` before each response (for mid-fetch races).
|
|
fn spawn_mock_delayed(body: String, delay: std::time::Duration) -> MockHandle {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
|
|
let counter = count.clone();
|
|
let auths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let seen_auths = auths.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
if let Some(auth) = read_request_auth(&mut stream) {
|
|
seen_auths.lock().unwrap().push(auth);
|
|
}
|
|
{
|
|
*counter.lock().unwrap() += 1;
|
|
}
|
|
std::thread::sleep(delay);
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes());
|
|
}
|
|
});
|
|
(format!("http://{addr}/v1/deployment-config"), count, auths)
|
|
}
|
|
|
|
/// `(url, request_count, authorization_headers_in_order)`.
|
|
type MockHandle = (String, Arc<Mutex<usize>>, Arc<Mutex<Vec<String>>>);
|
|
|
|
/// Mock server that serves a sequence of `(status, body)` responses — response
|
|
/// `i` for request `i`, clamping to the last. The handle's request counter
|
|
/// backs retry/fail-fast assertions; the auth log backs credential-fallback
|
|
/// assertions.
|
|
fn spawn_mock_seq(responses: Vec<(u16, String)>) -> MockHandle {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
|
|
let counter = count.clone();
|
|
let auths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let seen_auths = auths.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
if let Some(auth) = read_request_auth(&mut stream) {
|
|
seen_auths.lock().unwrap().push(auth);
|
|
}
|
|
let i = {
|
|
let mut c = counter.lock().unwrap();
|
|
let idx = *c;
|
|
*c += 1;
|
|
idx
|
|
};
|
|
let (status, body) = responses
|
|
.get(i)
|
|
.or_else(|| responses.last())
|
|
.cloned()
|
|
.unwrap_or((200, "{}".to_string()));
|
|
let reason = match status {
|
|
200 => "OK",
|
|
401 => "Unauthorized",
|
|
500 => "Internal Server Error",
|
|
_ => "Status",
|
|
};
|
|
let resp = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(resp.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
});
|
|
(format!("http://{addr}/deployment/config"), count, auths)
|
|
}
|
|
|
|
/// Mock that abruptly closes its first `close_first` connections right after
|
|
/// reading the request — simulating a stale/poisoned keep-alive connection the
|
|
/// client reused — then serves `body` (HTTP 200) on every later connection. The
|
|
/// counter records accepted connections, backing the retry assertion.
|
|
fn spawn_mock_closing_first(close_first: usize, body: String) -> (String, Arc<Mutex<usize>>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
|
|
let counter = count.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
// Read the request first so the abort lands mid-response ("connection
|
|
// closed before message completed"), not as a connect/write failure.
|
|
let _ = read_request_auth(&mut stream);
|
|
let i = {
|
|
let mut c = counter.lock().unwrap();
|
|
let idx = *c;
|
|
*c += 1;
|
|
idx
|
|
};
|
|
if i < close_first {
|
|
drop(stream);
|
|
continue;
|
|
}
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(resp.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
});
|
|
(format!("http://{addr}/deployment/config"), count)
|
|
}
|
|
|
|
/// Mock that, for its first `truncate_first` connections, writes a valid status line
|
|
/// + headers with an OVERSIZED `Content-Length` then closes WITHOUT the body — so the
|
|
/// client's body read fails mid-body ("connection closed before message completed", a
|
|
/// `reqwest` body-phase error, NOT a decode error). Every later connection serves the
|
|
/// full valid `body` (HTTP 200). The counter records accepted connections, backing the
|
|
/// retry assertion.
|
|
fn spawn_mock_truncating_body_first(
|
|
truncate_first: usize,
|
|
body: String,
|
|
) -> (String, Arc<Mutex<usize>>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
|
|
let counter = count.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
// Read the request first so the abort lands in the body phase, not as a
|
|
// connect/write failure.
|
|
let _ = read_request_auth(&mut stream);
|
|
let i = {
|
|
let mut c = counter.lock().unwrap();
|
|
let idx = *c;
|
|
*c += 1;
|
|
idx
|
|
};
|
|
if i < truncate_first {
|
|
// Promise 100000 body bytes, send none, then drop: the client reads the
|
|
// headers fine but fails collecting the body.
|
|
let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n";
|
|
let _ = stream.write_all(headers.as_bytes());
|
|
let _ = stream.flush();
|
|
drop(stream);
|
|
continue;
|
|
}
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(resp.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
});
|
|
(format!("http://{addr}/deployment/config"), count)
|
|
}
|
|
|
|
/// Write a `config.toml` that routes the managed-config fetch at the mock.
|
|
fn write_config(home: &std::path::Path, managed_config_url: &str) {
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{managed_config_url}\"\n"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
/// Write an `auth.json` with a team OAuth principal under the active scope.
|
|
fn write_team_auth(home: &std::path::Path, team_id: &str) {
|
|
write_team_auth_expiry(home, team_id, "2099-01-01T00:00:00Z");
|
|
}
|
|
|
|
/// Like [`write_team_auth`] but with an explicit `expires_at`, so tests can
|
|
/// simulate a routine cold-start where the persisted access token is expired.
|
|
fn write_team_auth_expiry(home: &std::path::Path, team_id: &str, expires_at: &str) {
|
|
let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope();
|
|
let auth = serde_json::json!({
|
|
scope: {
|
|
"key": "team-session-token",
|
|
"auth_mode": "oidc",
|
|
"create_time": "2026-01-01T00:00:00Z",
|
|
"expires_at": expires_at,
|
|
"user_id": "user-1",
|
|
"principal_type": "Team",
|
|
"team_id": team_id,
|
|
}
|
|
});
|
|
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
|
}
|
|
|
|
/// Write an `auth.json` with an EXPIRED `external`-mode team principal, so a configured refresher
|
|
/// drives `AuthManager::auth()`. Models the cold-start where the persisted token is expired but refreshable.
|
|
fn write_expired_external_team_auth(home: &std::path::Path, team_id: &str) {
|
|
let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope();
|
|
let auth = serde_json::json!({
|
|
scope: {
|
|
"key": "stale-team-token",
|
|
"auth_mode": "external",
|
|
"create_time": "2026-01-01T00:00:00Z",
|
|
"expires_at": PAST,
|
|
"user_id": "user-1",
|
|
"principal_type": "Team",
|
|
"team_id": team_id,
|
|
"refresh_token": "rt-team",
|
|
}
|
|
});
|
|
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
|
}
|
|
|
|
const FAR_FUTURE: &str = "2099-01-01T00:00:00Z";
|
|
const PAST: &str = "2000-01-01T00:00:00Z";
|
|
|
|
const TEAM_MANAGED: &str = "[[marketplace.sources]]\nname = \"internal\"\ngit = \"https://github.com/example/plugin-marketplace-internal\"\n";
|
|
const TEAM_REQUIREMENTS: &str =
|
|
"[marketplace]\nallowlist = [\"https://github.com/example/plugin-marketplace-internal\"]\n";
|
|
|
|
fn team_config_body() -> String {
|
|
serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-007",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": TEAM_REQUIREMENTS,
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn team_sync_writes_files() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed");
|
|
assert!(wrote, "expected team config to be written");
|
|
|
|
assert_eq!(
|
|
auths.lock().unwrap().last().map(String::as_str),
|
|
Some("Bearer team-session-token"),
|
|
"client must authenticate with the team session token"
|
|
);
|
|
|
|
let managed = std::fs::read_to_string(home.join("managed_config.toml")).unwrap();
|
|
assert!(
|
|
managed.contains("plugin-marketplace-internal"),
|
|
"managed_config should contain the team marketplace source: {managed}"
|
|
);
|
|
|
|
let requirements = std::fs::read_to_string(home.join("requirements.toml")).unwrap();
|
|
assert!(
|
|
requirements.contains("allowlist"),
|
|
"requirements should contain the enforced allowlist: {requirements}"
|
|
);
|
|
}
|
|
|
|
/// A directory squatting at the MARKER path must not permanently disarm the staleness
|
|
/// detector: the atomic marker write would fail onto it on every sync, forever. The
|
|
/// locked apply clears the squat (same rule as the sidecar) and records the sync.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn marker_dir_squat_is_cleared_and_marker_written() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
// Dir-squat the marker (with a child, like a real squat).
|
|
let marker_path = home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE);
|
|
std::fs::create_dir(&marker_path).unwrap();
|
|
std::fs::write(marker_path.join("junk"), "x").unwrap();
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed");
|
|
assert!(
|
|
wrote,
|
|
"the policy files are written despite the marker squat"
|
|
);
|
|
|
|
assert!(
|
|
marker_path.is_file(),
|
|
"the apply must replace the squatting directory with the marker FILE"
|
|
);
|
|
let marker = std::fs::read_to_string(&marker_path).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["principal"].as_str(),
|
|
Some("team-007"),
|
|
"the recorded marker must describe this sync: {marker}"
|
|
);
|
|
}
|
|
|
|
/// A whitespace-padded `team_id` in `auth.json` is one identity end-to-end: the serving
|
|
/// identity and the recorded marker are trimmed, and re-syncing with the padded id is the
|
|
/// same tenant (no eviction, no confirmed switch).
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn padded_team_id_is_one_identity() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, " team-007 ");
|
|
|
|
assert_eq!(
|
|
xai_grok_shell::managed_config::current_serving_identity(),
|
|
team_identity("team-007"),
|
|
"the serving identity must be the trimmed team id"
|
|
);
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed");
|
|
let marker =
|
|
std::fs::read_to_string(home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["principal"].as_str(),
|
|
Some("team-007"),
|
|
"the marker stores the trimmed identity: {marker}"
|
|
);
|
|
assert_eq!(
|
|
xai_grok_config::confirmed_team_switch("team-007"),
|
|
None,
|
|
"padding is not a tenant switch"
|
|
);
|
|
}
|
|
|
|
/// Switching the active team must not keep enforcing the prior team's policy: after B syncs,
|
|
/// A's artifacts are evicted and the marker records B served nothing. Fail-open.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn team_switch_evicts_prior_teams_policy() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Team A serves both managed_config and requirements. A leftover sidecar (from an
|
|
// earlier signing build; verification is inactive here) must also be evicted, or a
|
|
// later signing build would read A's foreign-bound sidecar against B's identity.
|
|
let (url_a, _auths_a) = spawn_mock(team_config_body());
|
|
write_config(&home, &url_a);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team A sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
assert!(home.join("managed_config.toml").exists());
|
|
std::fs::write(home.join("managed_config.sig.json"), "{}").unwrap();
|
|
|
|
// Switch to team B, whose server returns a row (team_id) but no artifacts.
|
|
let body_b = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-b",
|
|
"managed_config": serde_json::Value::Null,
|
|
"requirements": serde_json::Value::Null,
|
|
})
|
|
.to_string();
|
|
let (url_b, _auths_b) = spawn_mock(body_b);
|
|
write_config(&home, &url_b);
|
|
write_team_auth(&home, "team-b");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team B sync must not fail the session");
|
|
assert!(!wrote, "team B serves no artifacts, so nothing is written");
|
|
|
|
// Team A's enforced policy is gone — team B does not inherit it.
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"team A's requirements must be evicted on the switch to team B"
|
|
);
|
|
assert!(
|
|
!home.join("managed_config.toml").exists(),
|
|
"team A's managed_config must be evicted on the switch to team B"
|
|
);
|
|
assert!(
|
|
!home.join("managed_config.sig.json").exists(),
|
|
"team A's stale sidecar must be evicted on the switch to team B"
|
|
);
|
|
|
|
// The marker is now team B's and must not claim B served A's artifacts.
|
|
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["principal"].as_str(),
|
|
Some("team-b"),
|
|
"marker must rebind to team B: {marker}"
|
|
);
|
|
assert_eq!(
|
|
v["had_requirements"].as_bool(),
|
|
Some(false),
|
|
"marker must not claim team B served requirements: {marker}"
|
|
);
|
|
assert_eq!(
|
|
v["had_managed_config"].as_bool(),
|
|
Some(false),
|
|
"marker must not claim team B served managed_config: {marker}"
|
|
);
|
|
|
|
// Team B's cache reads fresh + identity-matched (no missing-artifact stale).
|
|
assert!(!xai_grok_shell::config::is_managed_config_stale_for(
|
|
&team_identity("team-b")
|
|
));
|
|
}
|
|
|
|
/// An artifact the server stops serving is removed on the next sync (disk converges
|
|
/// to the served set), and the marker stops claiming it — a withdrawn policy must not
|
|
/// keep enforcing from a stale file.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn withdrawn_artifact_is_removed_on_next_sync() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Sync 1: both artifacts served.
|
|
let (url_full, _a) = spawn_mock(team_config_body());
|
|
write_config(&home, &url_full);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// Sync 2: same team, requirements withdrawn.
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-007",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": serde_json::Value::Null,
|
|
})
|
|
.to_string();
|
|
let (url_partial, _a2) = spawn_mock(body);
|
|
write_config(&home, &url_partial);
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("second sync should succeed");
|
|
assert!(wrote, "removing the withdrawn artifact is a change");
|
|
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"the still-served artifact stays"
|
|
);
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"the withdrawn artifact is removed"
|
|
);
|
|
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["had_requirements"].as_bool(),
|
|
Some(false),
|
|
"the marker stops claiming the withdrawn artifact: {marker}"
|
|
);
|
|
assert!(
|
|
!xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
|
"the converged cache is not stale"
|
|
);
|
|
}
|
|
|
|
/// An empty dk response (`{}`) with a team signed in falls through to the team WITHOUT
|
|
/// applying: applying converges disk to the served (empty) set, which would delete the
|
|
/// team's files right before the team apply — observable when the team fetch then fails.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn empty_dk_response_with_failing_team_leaves_team_policy_intact() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Seed the team's policy files + marker.
|
|
let (url, _a) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team seed sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// dk serves an empty row; the team fetch then fails (5xx for every retry).
|
|
let (url2, _c, auths) = spawn_mock_seq(vec![(200, "{}".into()), (500, "boom".into())]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url2}\"\ndeployment_key = \"dep-key\"\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
let err = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect_err("the team fetch fails after the dk fallthrough");
|
|
assert!(err.is_retryable(), "5xx is a transient failure: {err}");
|
|
assert_eq!(
|
|
auths.lock().unwrap().first().map(String::as_str),
|
|
Some("Bearer dep-key"),
|
|
"the dk was consulted first"
|
|
);
|
|
|
|
assert!(
|
|
home.join("requirements.toml").exists() && home.join("managed_config.toml").exists(),
|
|
"the empty dk body must not be applied (it would delete the team's files)"
|
|
);
|
|
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["principal"].as_str(),
|
|
Some("team-007"),
|
|
"the failed sync must not rewrite the marker: {marker}"
|
|
);
|
|
}
|
|
|
|
/// A served-then-deleted artifact reads stale for the active identity; the session-start refresh refetches it.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn served_then_deleted_refetches_best_effort() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
assert!(
|
|
!xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
|
"a fresh, identity-matched, complete cache is not stale"
|
|
);
|
|
|
|
// Tamper: delete the served file but keep the fresh marker.
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
assert!(
|
|
xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
|
"a served-but-now-missing artifact must read stale"
|
|
);
|
|
// Identity mismatch also reads stale (team switch).
|
|
assert!(xai_grok_shell::config::is_managed_config_stale_for(
|
|
&team_identity("team-other")
|
|
));
|
|
|
|
// Best-effort refresh restores it; the session is never refused. Here the on-disk token is unexpired
|
|
// (cache hit). The expired-refreshable path is covered by `expired_refreshable_team_token_heals_after_auth_refresh`.
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"the best-effort refresh restored the deleted artifact"
|
|
);
|
|
assert!(!xai_grok_shell::config::is_managed_config_stale_for(
|
|
&team_identity("team-007")
|
|
));
|
|
}
|
|
|
|
/// An expired-but-refreshable team token with a served-then-deleted artifact must heal at session start.
|
|
/// The heal drives `auth()` first so the refreshed principal can refetch; else the expiry filters drop it unhealed.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn expired_refreshable_team_token_heals_after_auth_refresh() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, auths) = spawn_mock(team_config_body());
|
|
// Point both the managed-config fetch and the proxy (post-refresh `/user`) at the mock — no production calls.
|
|
let base = url.trim_end_matches("/deployment/config");
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!(
|
|
"[endpoints]\nmanaged_config_url = \"{url}\"\ncli_chat_proxy_base_url = \"{base}\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
// Establish a served, identity-matched cache (writes the sync marker).
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// Cold-start brick: expired-but-refreshable on-disk token, served requirements.toml gone but still in the marker.
|
|
write_expired_external_team_auth(&home, "team-007");
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
assert!(
|
|
!xai_grok_shell::managed_config::has_principal(),
|
|
"the expired token leaves no eligible managed principal — the expiry-filtered heal path can't see it (the brick)"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
|
"a served-but-now-missing artifact reads stale"
|
|
);
|
|
|
|
// A real AuthManager whose refresher mints a fresh team token, persisted by `auth()`.
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
auth_manager.configure_refresher(
|
|
Some(r#"echo '{"access_token":"refreshed-team-token","expires_in":3600}'"#.to_string()),
|
|
None,
|
|
);
|
|
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
|
|
// The refresh re-enabled the heal: policy restored, refetched with the fresh token.
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"the token refresh let the best-effort heal restore the deleted policy"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::managed_config::has_principal(),
|
|
"the refreshed token is a live, eligible managed (team) principal"
|
|
);
|
|
assert!(
|
|
auths
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|a| a == "Bearer refreshed-team-token"),
|
|
"the heal refetch authenticated with the refreshed token"
|
|
);
|
|
}
|
|
|
|
/// The boundary: when no refresh can succeed (offline / dead token), the expired token still fails closed —
|
|
/// `auth()` errs, the heal doesn't run, and the deleted policy is NOT restored. Unbricks ONLY on a real refresh.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn expired_team_token_without_successful_refresh_stays_failed_closed() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
write_expired_external_team_auth(&home, "team-007");
|
|
|
|
// A refresher that always fails -> `auth()` cannot produce a principal.
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
auth_manager.configure_refresher(Some("false".to_string()), None);
|
|
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"with no successful refresh the expired token cannot heal (fail-closed)"
|
|
);
|
|
}
|
|
|
|
/// `managed_policy_gate` refuses a managed session when its served policy was deleted and the refetch can't
|
|
/// restore it (offline); intact or config-less is allowed. Exercises the real sync → marker → gate path.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn managed_policy_gate_fails_closed_on_deleted_policy_offline() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Admin opts in by serving `fail_closed = true` (server-driven; no local env).
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-007",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
// Intact, identity-matched policy → the gate proceeds.
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"an intact served policy must not be refused"
|
|
);
|
|
|
|
// Tamper: delete the served file but keep the marker → gate fails closed.
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_err(),
|
|
"a served-then-deleted policy must fail closed"
|
|
);
|
|
|
|
// Offline: the 5xx refetch can't restore the file → gate stays fail-closed (far-future token makes auth a cache hit).
|
|
let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]);
|
|
write_config(&home, &err_url);
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"a failed refetch cannot restore the deleted policy"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_err(),
|
|
"still missing after a failed refetch → gate stays fail-closed"
|
|
);
|
|
|
|
// A config-less principal (the server served nothing) is never refused.
|
|
reset(&home);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker {
|
|
principal: Some("team-007"),
|
|
had_managed_config: false,
|
|
had_requirements: false,
|
|
key_fingerprint: None,
|
|
fail_closed: false,
|
|
});
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a config-less principal must not be refused"
|
|
);
|
|
}
|
|
|
|
/// `bootstrap` must run the fail-closed gate (it is `bootstrap`'s first step): a compromised managed policy
|
|
/// must fail the whole bootstrap closed, not just the standalone `managed_policy_gate`. Guards against a
|
|
/// refactor that drops the gate call from `bootstrap` — which the gate's own tests would not catch.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn bootstrap_fails_closed_when_managed_policy_compromised() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Provision a fail_closed team install (both artifacts served), then tamper by deleting the served policy.
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-007",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial sync should succeed");
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
|
|
// The gate is bootstrap's first step, so it refuses before any config/model work.
|
|
let cfg = xai_grok_shell::agent::config::Config::default();
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
// `bootstrap`'s Ok type isn't `Debug`, so match rather than `expect_err`.
|
|
let err = match xai_grok_shell::agent::init::bootstrap(&cfg, &auth_manager, None) {
|
|
Err(e) => e,
|
|
Ok(_) => {
|
|
panic!("a compromised fail_closed policy must fail bootstrap closed, but it succeeded")
|
|
}
|
|
};
|
|
assert!(
|
|
err.contains("Managed policy is required for this account"),
|
|
"bootstrap must fail via the managed-policy gate (proves bootstrap calls it); got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Live wiring guard: an offline `GROK_DEPLOYMENT_KEY` switch on a fail_closed install must FAIL CLOSED, else a
|
|
/// regression returning `None` silently disables deploy-key-switch detection. Same-key ALLOW checks the lib's own `blake3(KEY-AAA)` exactly.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn managed_policy_gate_fails_closed_on_deployment_key_switch_offline() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Provision a fail_closed deploy install bound to key A; both artifacts written, so the only tamper signal is the key fingerprint.
|
|
let body = serde_json::json!({
|
|
"deployment_id": "deploy-A",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
|
|
// SAFETY: #[serial] test; the env is restored before any assertion below.
|
|
unsafe { std::env::set_var("GROK_DEPLOYMENT_KEY", "KEY-AAA") };
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("deployment-key sync should record the fail_closed marker");
|
|
|
|
// The marker records the lib-computed fingerprint (never the raw key), full blake3 hex.
|
|
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
let fp_a = v["key_fingerprint"]
|
|
.as_str()
|
|
.expect("deploy-key sync records a key fingerprint")
|
|
.to_string();
|
|
let fail_closed_recorded = v["fail_closed"].as_bool().unwrap_or(false);
|
|
|
|
// GROK_MANAGED_CONFIG=0 disables any incidental background fetch (the gate is sync anyway).
|
|
// SAFETY: #[serial] test; restored before any assertion below.
|
|
unsafe { std::env::set_var("GROK_MANAGED_CONFIG", "0") };
|
|
|
|
// Same key A → matching fingerprint → ALLOW (exact-equality vs recorded blake3).
|
|
let gate_same_key = xai_grok_shell::managed_config::managed_policy_gate();
|
|
|
|
// Switch to a different key B → REFUSE: exercises the full offline wiring.
|
|
// SAFETY: #[serial] test; restored immediately below.
|
|
unsafe { std::env::set_var("GROK_DEPLOYMENT_KEY", "KEY-BBB") };
|
|
let gate_switched_key = xai_grok_shell::managed_config::managed_policy_gate();
|
|
|
|
// SAFETY: #[serial] test; restore env BEFORE asserting so a failed assert can't leak it to later tests.
|
|
unsafe {
|
|
std::env::remove_var("GROK_DEPLOYMENT_KEY");
|
|
std::env::remove_var("GROK_MANAGED_CONFIG");
|
|
}
|
|
|
|
// blake3-256 hex is exactly 64 hex chars — pins the recorded format.
|
|
assert!(
|
|
fp_a.len() == 64 && fp_a.chars().all(|c| c.is_ascii_hexdigit()),
|
|
"recorded key_fingerprint must be a full blake3 hex, not just non-empty: {marker}"
|
|
);
|
|
assert!(
|
|
fail_closed_recorded,
|
|
"marker must record fail_closed = true: {marker}"
|
|
);
|
|
assert!(
|
|
home.join("requirements.toml").exists() && home.join("managed_config.toml").exists(),
|
|
"both served artifacts must be present so the fingerprint is the only tamper signal"
|
|
);
|
|
assert!(
|
|
!marker.contains("KEY-AAA"),
|
|
"the raw deployment key must never be written to disk: {marker}"
|
|
);
|
|
assert!(
|
|
gate_same_key.is_ok(),
|
|
"same deployment key + intact fail_closed policy must be ALLOWED (proves recorded fp == fresh blake3(KEY-AAA))"
|
|
);
|
|
assert!(
|
|
gate_switched_key.is_err(),
|
|
"a deployment-key switch (different fingerprint) on a fail_closed machine must FAIL CLOSED offline"
|
|
);
|
|
}
|
|
|
|
/// A leftover fail_closed marker from a prior managed stint must NOT lock out a user who has since signed out
|
|
/// (no deployment key, no team auth): the gate requires a present principal, so `managed_principal_present()`
|
|
/// short-circuits. Guards the worst-case regression — locking a normal user out of their own CLI.
|
|
#[test]
|
|
#[serial]
|
|
fn former_managed_user_signed_out_is_not_locked_out() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// No deployment key in config, and `reset` removed auth.json → no principal.
|
|
std::fs::write(home.join("config.toml"), "[endpoints]\n").unwrap();
|
|
// A stale, opted-in marker that reads tampered: it recorded a served requirements.toml that's now absent.
|
|
xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker {
|
|
principal: Some("team-007"),
|
|
had_managed_config: false,
|
|
had_requirements: true,
|
|
key_fingerprint: None,
|
|
fail_closed: true,
|
|
});
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a signed-out user with a leftover fail_closed marker must not be refused (no principal to enforce)"
|
|
);
|
|
}
|
|
|
|
/// An unreadable auth.json makes `managed_principal_present()` fail safe to "present", but with NO fail_closed
|
|
/// marker the gate still allows — the fail-safe must never lock out a personal user who has no managed policy.
|
|
#[test]
|
|
#[serial]
|
|
fn unreadable_auth_without_marker_is_not_refused() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
std::fs::write(home.join("config.toml"), "[endpoints]\n").unwrap();
|
|
std::fs::write(home.join("auth.json"), "{corrupt json").unwrap();
|
|
// No managed_config_cache.json marker at all.
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"unreadable auth (fail-safe present) with no fail_closed marker must not be refused"
|
|
);
|
|
}
|
|
|
|
/// Deploy-key online heal: a fail_closed deploy install whose served requirements.toml was deleted heals at
|
|
/// session start when the refetch succeeds, and the gate then allows — the key path's mirror of the team heal.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn deployment_key_served_then_deleted_heals_online() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": "deploy-A",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"),
|
|
)
|
|
.unwrap();
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("initial deploy-key sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// Tamper: delete the served artifact (offline this would fail closed).
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
|
|
// The mock still serves, so the best-effort session-start refresh restores it; the gate then allows.
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"the online refetch must restore the deleted deploy-key policy"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"after a successful heal the deploy-key gate must allow"
|
|
);
|
|
}
|
|
|
|
/// A confirmed offline team switch (fail_closed team-A install, then team B signs in with no
|
|
/// network): the gate PURGES team A's now-foreign artifacts and marker, then PERMITS team B —
|
|
/// a legitimate switch is neither refused nor left running under team A's lingering policy.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn identity_change_permits_offline_team_switch_and_purges_prior_team() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team-a sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"team A's intact fail_closed policy must start"
|
|
);
|
|
|
|
// Switch to team B while OFFLINE: the 5xx server means the session-start refresh cannot
|
|
// purge via the apply path; the gate's own purge must handle the switch.
|
|
let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]);
|
|
write_config(&home, &err_url);
|
|
write_team_auth(&home, "team-b");
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a legitimate offline team switch must not fail closed"
|
|
);
|
|
for f in xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES
|
|
.into_iter()
|
|
.chain([xai_grok_config::MANAGED_CONFIG_CACHE_FILE])
|
|
{
|
|
assert!(
|
|
!home.join(f).exists(),
|
|
"team A's {f} must be purged on the switch"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The gate purge takes the managed-config lock best-effort and SKIPS on contention (the holder
|
|
/// owns the transition). While held, an A→B switch retains team A's files — and the gate still
|
|
/// permits (a pure identity mismatch is not gate-grade tamper); once released, the next gate
|
|
/// call purges — proving the skip was contention-driven, not a silent no-op.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn gate_purge_skips_while_lock_contended() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team A sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
assert!(home.join("managed_config_cache.json").exists());
|
|
|
|
write_team_auth(&home, "team-b");
|
|
|
|
// Hold the managed-config flock (the same lock the gate purge tries), so the purge skips.
|
|
let lock = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a contended purge skip leaves a pure identity mismatch, which must not refuse"
|
|
);
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"team A requirements must be RETAINED while the lock is contended (purge skipped)"
|
|
);
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"team A marker must be RETAINED while the lock is contended"
|
|
);
|
|
|
|
// Release the lock: the next gate call acquires it and purges team A.
|
|
lock.unlock().unwrap();
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"after the lock releases, the gate purges team A and team B starts"
|
|
);
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"the uncontended gate purges team A's requirements"
|
|
);
|
|
assert!(
|
|
!home.join("managed_config_cache.json").exists(),
|
|
"the uncontended gate purges team A's marker"
|
|
);
|
|
}
|
|
|
|
/// A TRANSIENT lock holder must not turn an offline team switch into a skipped purge:
|
|
/// the purge retries the lock once after 100ms (`PURGE_LOCK_RETRY_DELAY`), so a holder
|
|
/// that releases within that window (~20ms here) is absorbed and the SAME gate call
|
|
/// purges team A on the second attempt.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn gate_purge_retries_past_a_transient_lock_holder() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team A sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
write_team_auth(&home, "team-b");
|
|
|
|
// Acquire the flock BEFORE the gate call, then hand it to a helper that releases
|
|
// it ~20ms in — inside the purge's retry window.
|
|
let lock = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
let holder = std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
drop(lock); // releases the flock
|
|
});
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a pure identity mismatch never refuses, purged or not"
|
|
);
|
|
holder.join().unwrap();
|
|
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"one gate call must absorb the transient holder via the retry and purge team A"
|
|
);
|
|
assert!(
|
|
!home
|
|
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"team A's marker goes with the retried purge"
|
|
);
|
|
}
|
|
|
|
/// A blank `team_id` in `auth.json` (a parse blip / malformed write) is "unknown", not a
|
|
/// distinct identity: the gate must NOT fail closed and the purge must NOT shed team A's
|
|
/// policy. Guards the blank→None map in `active_team_id_any_expiry` and the detector's
|
|
/// blank guard end to end.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn blank_team_id_neither_fails_closed_nor_purges() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team A sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// auth.json now carries a team principal with a BLANK team_id.
|
|
write_team_auth(&home, "");
|
|
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"a blank team_id must read as unknown, not a foreign substituted cache"
|
|
);
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"a parse blip must not purge team A's enforced policy"
|
|
);
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"the team A marker must be retained on a blank team_id"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
xai_grok_shell::managed_config::current_serving_identity(),
|
|
ServingIdentity::None
|
|
),
|
|
"a blank team_id must resolve to no identity, not Team(\"\") (spurious refetch input)"
|
|
);
|
|
}
|
|
|
|
/// The session-start gate reads no env: `GROK_MANAGED_CONFIG_FAIL_CLOSED=0` must NOT disarm a fail_closed
|
|
/// refusal (unlike the requirements-layer version check, which the env can only tighten). No local bypass.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn fail_closed_env_cannot_disarm_the_gate() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": "deploy-A",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"),
|
|
)
|
|
.unwrap();
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("deploy-key sync should succeed");
|
|
|
|
// Tamper: delete the served requirements (offline this fails closed).
|
|
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
|
|
|
// SAFETY: #[serial] test; both vars restored before the assertion below.
|
|
unsafe {
|
|
std::env::set_var("GROK_MANAGED_CONFIG", "0"); // offline (the gate is sync anyway)
|
|
std::env::set_var("GROK_MANAGED_CONFIG_FAIL_CLOSED", "0"); // attempt a local disarm
|
|
}
|
|
let gate = xai_grok_shell::managed_config::managed_policy_gate();
|
|
unsafe {
|
|
std::env::remove_var("GROK_MANAGED_CONFIG");
|
|
std::env::remove_var("GROK_MANAGED_CONFIG_FAIL_CLOSED");
|
|
}
|
|
|
|
assert!(
|
|
gate.is_err(),
|
|
"GROK_MANAGED_CONFIG_FAIL_CLOSED=0 must not disarm the session-start gate (no local bypass)"
|
|
);
|
|
}
|
|
|
|
/// Full logout removes the team scope from `auth.json`; the post-logout clear
|
|
/// (what `perform_logout` runs) removes the orphaned team-sourced files.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn logout_clears_team_config() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _last_auth) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed");
|
|
assert!(home.join("managed_config.toml").exists());
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"a successful sync writes the sync-marker cache"
|
|
);
|
|
|
|
// `AuthManager::clear` deletes auth.json when the last scope is removed.
|
|
std::fs::remove_file(home.join("auth.json")).unwrap();
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
!home.join("managed_config.toml").exists(),
|
|
"team-sourced managed_config should be cleared on logout"
|
|
);
|
|
assert!(
|
|
!home.join("requirements.toml").exists(),
|
|
"enforced requirements should be cleared on logout"
|
|
);
|
|
assert!(
|
|
!home.join("managed_config_cache.json").exists(),
|
|
"the sync-marker cache should be cleared on logout too"
|
|
);
|
|
}
|
|
|
|
/// Seed on-disk fail_closed policy for clear_orphan keep tests.
|
|
/// `with_managed_files` writes managed_config + sig sidecars.
|
|
/// `with_marker` stamps a fail_closed sync marker for team-ms-fail-closed.
|
|
fn seed_fail_closed_orphan_artifacts(
|
|
home: &std::path::Path,
|
|
with_managed_files: bool,
|
|
with_marker: bool,
|
|
) {
|
|
if with_managed_files {
|
|
std::fs::write(home.join("managed_config.toml"), TEAM_MANAGED).unwrap();
|
|
std::fs::write(home.join("managed_config.sig.json"), r#"{"key_id":"v1"}"#).unwrap();
|
|
std::fs::write(home.join("managed_identity.sig.json"), r#"{"key_id":"v1"}"#).unwrap();
|
|
}
|
|
std::fs::write(
|
|
home.join("requirements.toml"),
|
|
format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
)
|
|
.unwrap();
|
|
if with_marker {
|
|
xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker {
|
|
principal: Some("team-ms-fail-closed"),
|
|
had_managed_config: with_managed_files,
|
|
had_requirements: true,
|
|
key_fingerprint: None,
|
|
fail_closed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// fail_closed escape fix: personal (User) auth with leftover MS fail_closed
|
|
/// artifacts must NOT be wiped by `clear_orphan` — that was the offline
|
|
/// switch-to-personal escape (managed files deleted, session ALLOW unrestricted).
|
|
#[test]
|
|
#[serial]
|
|
fn clear_orphan_keeps_fail_closed_when_switched_to_personal() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
seed_fail_closed_orphan_artifacts(&home, true, true);
|
|
|
|
// Personal User principal (no team_id) — the escape repro.
|
|
let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope();
|
|
let auth = serde_json::json!({
|
|
scope: {
|
|
"key": "personal-token",
|
|
"auth_mode": "oidc",
|
|
"create_time": "2026-01-01T00:00:00Z",
|
|
"expires_at": FAR_FUTURE,
|
|
"user_id": "user-1",
|
|
}
|
|
});
|
|
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
|
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"fail_closed requirements must survive personal identity switch"
|
|
);
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"fail_closed managed_config must survive personal identity switch"
|
|
);
|
|
assert!(
|
|
home.join("managed_config.sig.json").exists(),
|
|
"sig sidecar must survive personal identity switch under fail_closed"
|
|
);
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"fail_closed marker must survive personal identity switch"
|
|
);
|
|
}
|
|
|
|
/// Signed-out logout with fail_closed still keeps policy (same as personal switch).
|
|
#[test]
|
|
#[serial]
|
|
fn clear_orphan_keeps_fail_closed_when_signed_out() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
seed_fail_closed_orphan_artifacts(&home, false, true);
|
|
// No auth.json = signed out.
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"signed-out must not wipe fail_closed requirements"
|
|
);
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"signed-out must not wipe fail_closed marker"
|
|
);
|
|
}
|
|
|
|
/// Marker stripped but requirements still say fail_closed = true: still keep.
|
|
#[test]
|
|
#[serial]
|
|
fn clear_orphan_keeps_fail_closed_requirements_without_marker() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
seed_fail_closed_orphan_artifacts(&home, false, false);
|
|
// No marker, no team auth.
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"on-disk fail_closed requirements must be kept even without a marker"
|
|
);
|
|
}
|
|
|
|
/// Unreadable requirements (PermissionDenied) with no fail_closed marker must
|
|
/// still keep artifacts — cannot confirm disarmed, so clear_orphan must not wipe.
|
|
#[test]
|
|
#[serial]
|
|
#[cfg(unix)]
|
|
fn clear_orphan_keeps_unreadable_requirements_without_marker() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
seed_fail_closed_orphan_artifacts(&home, true, false);
|
|
// No fail_closed marker; requirements exist with fail_closed = true but will
|
|
// be made unreadable so the flag cannot be parsed.
|
|
let req = home.join("requirements.toml");
|
|
std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap();
|
|
struct RestorePerms<'a>(&'a std::path::Path);
|
|
impl Drop for RestorePerms<'_> {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
}
|
|
let _restore = RestorePerms(&req);
|
|
|
|
assert!(
|
|
xai_grok_config::fail_closed_policy_armed_at(&home),
|
|
"unreadable requirements must arm fail_closed"
|
|
);
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
// Restore so exists() / cleanup can inspect the tree.
|
|
drop(_restore);
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"unreadable requirements must not be wiped by clear_orphan"
|
|
);
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"managed_config must survive when requirements are unreadable"
|
|
);
|
|
}
|
|
|
|
/// An expired token for a still-signed-in team is not a logout: cold-start
|
|
/// tokens are routinely expired before refresh, so the clear is expiry-agnostic.
|
|
#[test]
|
|
#[serial]
|
|
fn cold_start_expired_token_keeps_config() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
std::fs::write(home.join("managed_config.toml"), TEAM_MANAGED).unwrap();
|
|
std::fs::write(home.join("requirements.toml"), TEAM_REQUIREMENTS).unwrap();
|
|
write_team_auth_expiry(&home, "team-007", PAST);
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"expired-but-present team token must not wipe enforced managed_config"
|
|
);
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"expired-but-present team token must not wipe enforced requirements"
|
|
);
|
|
}
|
|
|
|
/// Fail-closed: an UNREADABLE (corrupt) auth.json is not a logout — the clear
|
|
/// must keep the team's enforced files until the read recovers.
|
|
#[test]
|
|
#[serial]
|
|
fn unreadable_auth_keeps_config() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
std::fs::write(home.join("requirements.toml"), TEAM_REQUIREMENTS).unwrap();
|
|
std::fs::write(home.join("auth.json"), "{corrupt json").unwrap();
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"an unreadable auth.json must not wipe enforced policy"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn deployment_key_wins_over_team_when_both_present() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, auths) = spawn_mock(team_config_body());
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key-123\"\n"),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed");
|
|
assert!(wrote);
|
|
assert_eq!(
|
|
auths.lock().unwrap().last().map(String::as_str),
|
|
Some("Bearer dep-key-123"),
|
|
"deployment key must win over the team token"
|
|
);
|
|
}
|
|
|
|
/// A successful deploy-key sync records the served `deployment_id` as `principal` and a non-empty
|
|
/// `key_fingerprint` (one-way hash), never the raw key — so a switched key stops serving the prior config.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn deployment_key_sync_records_principal_and_key_fingerprint() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": "dep-42",
|
|
"managed_config": "[cli]\ntheme = \"dark\"\n",
|
|
"requirements": "[features]\nweb_fetch = false\n",
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!(
|
|
"[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key-secret\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("deployment-key sync should succeed");
|
|
assert!(wrote);
|
|
|
|
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
|
assert_eq!(
|
|
v["principal"].as_str(),
|
|
Some("dep-42"),
|
|
"deploy-key marker records the served deployment_id as principal: {marker}"
|
|
);
|
|
let fp = v["key_fingerprint"]
|
|
.as_str()
|
|
.expect("deploy-key marker records a key fingerprint");
|
|
assert!(!fp.is_empty(), "the key fingerprint must be non-empty");
|
|
assert!(
|
|
!marker.contains("dep-key-secret"),
|
|
"the raw deployment key must never be written to disk: {marker}"
|
|
);
|
|
}
|
|
|
|
/// A configured deployment key keeps its files even with no team signed in —
|
|
/// the orphan clear must never delete a deployment-key install's config.
|
|
#[test]
|
|
#[serial]
|
|
fn deployment_key_config_survives_clear() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// A deployment-key install: files present + the key persisted in config.toml.
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
"[endpoints]\ndeployment_key = \"dep-key-123\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
home.join("managed_config.toml"),
|
|
"[cli]\ninstaller = \"internal\"\n",
|
|
)
|
|
.unwrap();
|
|
let _ = std::fs::remove_file(home.join("auth.json"));
|
|
|
|
xai_grok_shell::managed_config::clear_orphan();
|
|
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"deployment-key managed_config must survive the orphan clear"
|
|
);
|
|
}
|
|
|
|
/// A personal (non-team) OAuth login is not eligible: no bearer sent, nothing
|
|
/// written. Guards the `is_team_principal()` eligibility check.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn personal_login_is_noop() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, _) = spawn_mock_seq(vec![(200, team_config_body())]);
|
|
write_config(&home, &url);
|
|
// A signed-in USER principal (no team_id, principal_type absent).
|
|
let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope();
|
|
let auth = serde_json::json!({
|
|
scope: {
|
|
"key": "personal-token",
|
|
"auth_mode": "oidc",
|
|
"create_time": "2026-01-01T00:00:00Z",
|
|
"expires_at": FAR_FUTURE,
|
|
"user_id": "user-1",
|
|
}
|
|
});
|
|
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("personal login → no-op, not an error");
|
|
assert!(!wrote, "a personal login must not fetch team config");
|
|
assert_eq!(*count.lock().unwrap(), 0, "no bearer sent to the endpoint");
|
|
assert!(!home.join("managed_config.toml").exists());
|
|
}
|
|
|
|
/// Security guard: a lock-skipped apply (dk row HAS config) must not be read as
|
|
/// an empty row and fall through to the team token on a deployment-key machine.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn lock_contention_does_not_fall_through_to_team() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// dk row returns real config; a team principal is also present.
|
|
let (url, count, auths) = spawn_mock_seq(vec![(200, team_config_body())]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-007");
|
|
|
|
// Hold the managed-config lock (same flock the client uses) so apply_fetched
|
|
// skips and returns Ok(false).
|
|
let lock = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("contended sync is a no-op, not an error");
|
|
lock.unlock().unwrap();
|
|
|
|
assert!(!wrote, "nothing applied while the lock is held");
|
|
// The dk fetch happened; the team token must NOT have been tried as a
|
|
// fallthrough (that would fetch the team's config onto a dk machine).
|
|
assert_eq!(
|
|
auths.lock().unwrap().as_slice(),
|
|
["Bearer dep-key"],
|
|
"contention must not trigger the dk->team fallthrough"
|
|
);
|
|
assert_eq!(*count.lock().unwrap(), 1);
|
|
}
|
|
|
|
/// `grok setup` with config served but the lock held by another writer reports the
|
|
/// skip: not Installed (THIS run persisted nothing) and not NothingConfigured (the
|
|
/// server does have config).
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn setup_lock_skip_is_not_reported_as_no_config() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, _auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let lock = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
|
|
let outcome = xai_grok_shell::managed_config::run_setup().await;
|
|
lock.unlock().unwrap();
|
|
|
|
assert!(
|
|
matches!(
|
|
outcome,
|
|
xai_grok_shell::managed_config::SetupOutcome::Skipped
|
|
),
|
|
"a lock skip persisted nothing: it must report Skipped, not Installed or \
|
|
NothingConfigured, got {outcome:?}"
|
|
);
|
|
}
|
|
|
|
/// A transient (5xx) failure is retried; once the server recovers, the config
|
|
/// is written. (Backoff is overridden to 10ms in `test_home`.)
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn sync_retries_after_transient_error() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, _auths) =
|
|
spawn_mock_seq(vec![(500, "boom".into()), (200, team_config_body())]);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("should retry the 500 then succeed");
|
|
assert!(wrote);
|
|
assert!(home.join("managed_config.toml").exists());
|
|
assert!(
|
|
*count.lock().unwrap() >= 2,
|
|
"expected a retry after the transient 500"
|
|
);
|
|
}
|
|
|
|
/// A body-phase interruption — the server writes valid headers then drops before the
|
|
/// body completes ("connection closed before message completed", a `reqwest` body error,
|
|
/// NOT a decode error) — must be classified transient and recovered on a fresh
|
|
/// connection. Pre-fix this mapped to non-retryable `InvalidResponse` and would NOT
|
|
/// retry; here `sync()` succeeds. (Backoff is 10ms via `test_home`.)
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn sync_retries_after_body_phase_drop() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count) = spawn_mock_truncating_body_first(1, team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("a mid-body drop must be retried on a fresh connection, then succeed");
|
|
assert!(wrote);
|
|
assert!(home.join("managed_config.toml").exists());
|
|
assert!(
|
|
*count.lock().unwrap() >= 2,
|
|
"expected a retry on a new connection after the first body read was interrupted"
|
|
);
|
|
}
|
|
|
|
/// Classification: an in-flight connection interruption must NOT be misreported as
|
|
/// an unreachable server — the error must not blame the user's network (the bug),
|
|
/// and must surface the transient-interruption wording instead.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn connection_drop_is_not_reported_as_unreachable() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Every connection is dropped mid-flight, so all retries fail identically.
|
|
let (url, _count) = spawn_mock_closing_first(usize::MAX, team_config_body());
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let err = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect_err("all connections dropped → the fetch fails");
|
|
let msg = err.to_string().to_lowercase();
|
|
assert!(
|
|
!msg.contains("check your network"),
|
|
"an in-flight interruption must not be misreported as unreachable: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("interrupted") || msg.contains("timed out"),
|
|
"the message must describe a transient connection interruption: {msg}"
|
|
);
|
|
}
|
|
|
|
/// The payload side of the body split: a 200 with a non-JSON body is a malformed payload, not a
|
|
/// transport interruption — it must fail TERMINALLY (`InvalidResponse`), not retry. Guards the
|
|
/// `from_slice` arm of the split; the transport arm is covered by `sync_retries_after_body_phase_drop`.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn sync_fails_terminally_on_malformed_payload() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// A 200 whose body is not JSON (e.g. an HTML error page slipped through a proxy).
|
|
let (url, count, _auths) = spawn_mock_seq(vec![(200, "<html>not json</html>".into())]);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let err = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect_err("a malformed payload must fail, not write config");
|
|
assert_eq!(
|
|
*count.lock().unwrap(),
|
|
1,
|
|
"a malformed payload is terminal and must not be retried"
|
|
);
|
|
let msg = err.to_string().to_lowercase();
|
|
assert!(
|
|
msg.contains("unexpected response"),
|
|
"a malformed payload must surface as an unexpected-response error, not a transient one: {msg}"
|
|
);
|
|
assert!(!home.join("managed_config.toml").exists());
|
|
}
|
|
|
|
// note: this asserts the POOLING half of the `shared_client()` tuning. Evicting/recovering a
|
|
// half-dead h2 connection (and the `send_with_retry_escaping_pool` fresh-final-attempt escape) isn't
|
|
// deterministically simulable with the std `TcpListener` harness, so it's covered by the e2e pass.
|
|
|
|
/// The tuned pooled `shared_client()` reuses one TCP connection across back-to-back requests (pool
|
|
/// eviction is time-based, so two quick requests share a connection). Regression guard against a
|
|
/// future mis-tuning that disables pooling for the general-purpose client.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn shared_client_reuses_pooled_connection() {
|
|
// Scrub the suite's env (notably HTTP(S)_PROXY) before `shared_client()` is built, so a proxy
|
|
// can't intercept the 127.0.0.1 mock and break the accept count regardless of test ordering.
|
|
let _ = test_home();
|
|
let (base_url, accepts, _heads) = spawn_counting_server().await;
|
|
let client = xai_grok_shell::http::shared_client();
|
|
|
|
client
|
|
.get(base_url.as_str())
|
|
.send()
|
|
.await
|
|
.expect("first request succeeds")
|
|
.bytes()
|
|
.await
|
|
.expect("first body reads to completion");
|
|
// Brief pause so the idle connection is checked back into the pool before the second request.
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
client
|
|
.get(base_url.as_str())
|
|
.send()
|
|
.await
|
|
.expect("second request succeeds")
|
|
.bytes()
|
|
.await
|
|
.expect("second body reads to completion");
|
|
|
|
assert_eq!(
|
|
accepts.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"the pooled client must reuse one TCP connection across back-to-back requests"
|
|
);
|
|
}
|
|
|
|
/// An auth rejection is terminal: fail fast with the team-tailored message and
|
|
/// no retries (a bad credential won't fix itself by retrying).
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn sync_fails_fast_on_auth_error_without_retry() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, _auths) = spawn_mock_seq(vec![(401, "{\"error\":\"nope\"}".into())]);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let err = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect_err("401 should be an error");
|
|
assert_eq!(*count.lock().unwrap(), 1, "auth error must not be retried");
|
|
let msg = err.to_string().to_lowercase();
|
|
assert!(msg.contains("team sign-in"), "team-tailored message: {msg}");
|
|
}
|
|
|
|
/// A rejected deployment key (stale env/config leftover) must not starve a
|
|
/// valid team sign-in: the sync falls back to the team session token.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn rejected_deployment_key_falls_back_to_team() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, auths) = spawn_mock_seq(vec![
|
|
(401, "{\"error\":\"bad key\"}".into()), // deployment key attempt
|
|
(200, team_config_body()), // team fallback
|
|
]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"stale-key\"\n"),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team fallback should succeed after the key is rejected");
|
|
|
|
assert!(wrote);
|
|
assert_eq!(*count.lock().unwrap(), 2, "key attempt + team attempt");
|
|
assert_eq!(
|
|
auths.lock().unwrap().as_slice(),
|
|
["Bearer stale-key", "Bearer team-session-token"],
|
|
"first the rejected key, then the team token"
|
|
);
|
|
}
|
|
|
|
/// A deployment key whose response is EMPTY (no row provisioned) must not
|
|
/// starve the signed-in team: the sync falls through to the team token.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn empty_deployment_response_falls_through_to_team() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, auths) = spawn_mock_seq(vec![
|
|
(200, "{}".into()), // deployment key: no row
|
|
(200, team_config_body()), // team fallback
|
|
]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team fallthrough should succeed");
|
|
|
|
assert!(wrote);
|
|
assert_eq!(*count.lock().unwrap(), 2);
|
|
assert_eq!(
|
|
auths.lock().unwrap().as_slice(),
|
|
["Bearer dep-key", "Bearer team-session-token"]
|
|
);
|
|
}
|
|
|
|
/// A deployment row with empty content (echoed `deployment_id`) still owns the
|
|
/// machine: fallthrough gates on existence, not content, so the team token isn't
|
|
/// tried.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn empty_content_deployment_row_does_not_fall_through_to_team() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// 200 with a row (deployment_id present) but empty content; team config is
|
|
// queued second so a wrong fallthrough would be observable.
|
|
let degraded = serde_json::json!({
|
|
"deployment_id": "dep-1",
|
|
"managed_config": "",
|
|
"requirements": serde_json::Value::Null,
|
|
})
|
|
.to_string();
|
|
let (url, count, auths) = spawn_mock_seq(vec![(200, degraded), (200, team_config_body())]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"dep-key\"\n"),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let wrote = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("degraded dk row is a no-op, not an error");
|
|
|
|
assert!(!wrote, "empty content writes nothing");
|
|
assert_eq!(
|
|
*count.lock().unwrap(),
|
|
1,
|
|
"a real dk row must not trigger the team fetch"
|
|
);
|
|
assert_eq!(
|
|
auths.lock().unwrap().as_slice(),
|
|
["Bearer dep-key"],
|
|
"a provisioned-but-empty dk row must not fall through to the team token"
|
|
);
|
|
assert!(!home.join("managed_config.toml").exists());
|
|
}
|
|
|
|
/// `GROK_MANAGED_CONFIG=0` is an explicit opt-out: the post-login sync must
|
|
/// make zero requests.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn managed_config_opt_out_makes_no_requests() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, _) = spawn_mock_seq(vec![(200, team_config_body())]);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
// SAFETY: #[serial] test; restored before returning.
|
|
unsafe { std::env::set_var("GROK_MANAGED_CONFIG", "0") };
|
|
let outcome = xai_grok_shell::managed_config::post_login_sync(None).await;
|
|
unsafe { std::env::remove_var("GROK_MANAGED_CONFIG") };
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
xai_grok_shell::managed_config::ManagedConfigSync::Skipped
|
|
);
|
|
|
|
assert_eq!(*count.lock().unwrap(), 0, "opt-out must suppress the fetch");
|
|
assert!(!home.join("managed_config.toml").exists());
|
|
}
|
|
|
|
/// Post-login pins the just-authenticated principal over a different on-disk
|
|
/// team — a login to team B can't sync team A's policy.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn post_login_pins_authenticated_team_over_disk() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, auths) = spawn_mock(team_config_body());
|
|
write_config(&home, &url);
|
|
// The on-disk "current" team uses the default "team-session-token".
|
|
write_team_auth(&home, "team-disk");
|
|
|
|
// But we just authenticated as a different team with a distinct token.
|
|
let pinned: xai_grok_shell::auth::GrokAuth = serde_json::from_value(serde_json::json!({
|
|
"key": "pinned-token",
|
|
"auth_mode": "oidc",
|
|
"create_time": "2026-01-01T00:00:00Z",
|
|
"expires_at": FAR_FUTURE,
|
|
"user_id": "user-1",
|
|
"principal_type": "Team",
|
|
"team_id": "team-pinned",
|
|
}))
|
|
.unwrap();
|
|
|
|
let outcome = xai_grok_shell::managed_config::post_login_sync(Some(pinned)).await;
|
|
assert_eq!(
|
|
outcome,
|
|
xai_grok_shell::managed_config::ManagedConfigSync::Updated { is_team: true }
|
|
);
|
|
assert_eq!(
|
|
auths.lock().unwrap().last().map(String::as_str),
|
|
Some("Bearer pinned-token"),
|
|
"must authenticate as the pinned principal, not the on-disk current team"
|
|
);
|
|
}
|
|
|
|
/// The login-path sync stops after its small retry budget (2), not the full
|
|
/// background budget (5), and never surfaces an error.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn post_login_sync_is_latency_bounded() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let (url, count, _auths) = spawn_mock_seq(vec![(500, "boom".into())]);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-007");
|
|
|
|
let outcome = xai_grok_shell::managed_config::post_login_sync(None).await;
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
xai_grok_shell::managed_config::ManagedConfigSync::Failed
|
|
);
|
|
assert_eq!(
|
|
*count.lock().unwrap(),
|
|
2,
|
|
"login sync must stop after its bounded retry budget"
|
|
);
|
|
assert!(!home.join("managed_config.toml").exists());
|
|
}
|
|
|
|
/// A deploy key is local config any process can write, not a signed-in identity — so on a dk
|
|
/// machine the gate purge must never fire, even when `auth.json` shows a confirmed team switch
|
|
/// underneath: purging would let any local process shed the key's policy offline.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn deploy_key_machine_never_gate_purges_on_team_switch() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
// Sync as team A, then go offline and switch auth.json to team B — the purge-eligible shape.
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": TEAM_REQUIREMENTS,
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team-a sync should succeed");
|
|
assert!(home.join("managed_config.toml").exists());
|
|
|
|
// Same switch as the purging sibling test, but with a deployment key configured: the
|
|
// serving identity resolves to the key, so the Team-only purge path must not run.
|
|
let (err_url, _c, _a) = spawn_mock_seq(vec![(500, "{}".to_string())]);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!(
|
|
"[endpoints]\nmanaged_config_url = \"{err_url}\"\ndeployment_key = \"dk-under-test\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
write_team_auth(&home, "team-b");
|
|
let auth_manager = std::sync::Arc::new(xai_grok_shell::auth::AuthManager::new(
|
|
&home,
|
|
xai_grok_shell::auth::GrokComConfig::default(),
|
|
));
|
|
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
|
|
|
// The gate is the purge's only caller — without this call the guard is unexercised.
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"dk gate must permit"
|
|
);
|
|
assert!(
|
|
home.join("managed_config.toml").exists(),
|
|
"a dk machine must keep its policy across an auth.json team flip"
|
|
);
|
|
assert!(
|
|
home.join("managed_config_cache.json").exists(),
|
|
"the sync marker must survive too — the key, not the team, owns this machine's policy"
|
|
);
|
|
}
|
|
|
|
/// For every on-disk state a crashed purge can leave (each proper prefix of the removal
|
|
/// order), the marker is still present, the detector still fires for the new team, and a
|
|
/// later purge converges. The order itself is pinned by `marker_is_not_a_managed_artifact`
|
|
/// plus the fault-injection unit test.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn purge_crash_prefixes_stay_armed_and_converge() {
|
|
let home = test_home().clone();
|
|
let artifacts = xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES;
|
|
// 0..=len: every proper prefix of the 4-step removal order, up to and including
|
|
// "all artifacts removed, marker still present" (a crash right before the marker step).
|
|
for prefix_len in 0..=artifacts.len() {
|
|
reset(&home);
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("team-a sync should succeed");
|
|
|
|
// Simulate a purge crashed after removing only this prefix.
|
|
for name in &artifacts[..prefix_len] {
|
|
let _ = std::fs::remove_file(home.join(name));
|
|
}
|
|
assert!(
|
|
home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"marker must outlive every artifact prefix (prefix_len={prefix_len})"
|
|
);
|
|
|
|
// Team B arrives offline: the detector must still confirm and the purge converge.
|
|
write_team_auth(&home, "team-b");
|
|
assert_eq!(
|
|
xai_grok_config::confirmed_team_switch("team-b").as_deref(),
|
|
Some("team-a"),
|
|
"detector must stay armed after a crash prefix (prefix_len={prefix_len})"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"offline switch over a crash prefix must not refuse (prefix_len={prefix_len})"
|
|
);
|
|
for name in artifacts {
|
|
assert!(
|
|
!home.join(name).exists(),
|
|
"{name} must be purged (prefix_len={prefix_len})"
|
|
);
|
|
}
|
|
assert!(
|
|
!home
|
|
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"the converged purge drops the marker last (prefix_len={prefix_len})"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Marker written under the apply lock by the holder only: lock-contended apply records nothing.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn contended_sync_writes_no_marker() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": serde_json::Value::Null,
|
|
"team_id": "team-a",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": TEAM_REQUIREMENTS,
|
|
})
|
|
.to_string();
|
|
let (url, auths) = spawn_mock(body);
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-a");
|
|
|
|
// Hold the managed-config flock across the sync: apply skips, so nothing is persisted.
|
|
let lock = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
let synced = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed (skip, not error)");
|
|
assert!(!synced, "a lock-contended apply must not report a write");
|
|
lock.unlock().unwrap();
|
|
|
|
// Positive control: the FETCH happened (only the apply was skipped), so the
|
|
// no-marker assertions below can't pass vacuously on a sync that never ran.
|
|
assert!(
|
|
!auths.lock().unwrap().is_empty(),
|
|
"the fetch must have reached the server"
|
|
);
|
|
assert!(
|
|
!home
|
|
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"a contended sync must not write a marker for files it never persisted"
|
|
);
|
|
assert!(!home.join("requirements.toml").exists());
|
|
}
|
|
|
|
/// Credential vanished mid-fetch → apply Skipped, no marker (sibling of contention skip).
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn credential_gone_mid_fetch_writes_no_marker() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": "dep-1",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": TEAM_REQUIREMENTS,
|
|
})
|
|
.to_string();
|
|
// Delay the response so we can clear the deployment key after the fetch starts
|
|
// but before apply runs.
|
|
let (url, count, auths) = spawn_mock_delayed(body, std::time::Duration::from_millis(200));
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!(
|
|
"[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-GOING-AWAY\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let home_for_clear = home.clone();
|
|
let clearer = std::thread::spawn(move || {
|
|
// Wait until the mock has accepted a request, then drop the key.
|
|
for _ in 0..50 {
|
|
if *count.lock().unwrap() > 0 {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
std::fs::write(
|
|
home_for_clear.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\n"),
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
let synced = xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("sync should succeed (skip, not error)");
|
|
clearer.join().unwrap();
|
|
|
|
assert!(!synced, "credential-gone apply must not report a write");
|
|
assert!(
|
|
!auths.lock().unwrap().is_empty(),
|
|
"the fetch must have reached the server"
|
|
);
|
|
assert!(
|
|
!home
|
|
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"credential-gone must not write a marker for an unapplied body"
|
|
);
|
|
assert!(!home.join("requirements.toml").exists());
|
|
}
|
|
|
|
/// A dk-synced marker means the KEY owns this machine's policy: with the key line gone
|
|
/// from config.toml (the shape of a transient read failure) and a team user signed in,
|
|
/// the gate must NOT purge. Pins the marker-scoped exemption — one keyed on live config
|
|
/// resolution would purge here.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn dk_synced_marker_survives_config_blip_with_team_signed_in() {
|
|
let home = test_home().clone();
|
|
reset(&home);
|
|
|
|
let body = serde_json::json!({
|
|
"deployment_id": "deploy-A",
|
|
"managed_config": TEAM_MANAGED,
|
|
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
|
})
|
|
.to_string();
|
|
let (url, _auths) = spawn_mock(body);
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"),
|
|
)
|
|
.unwrap();
|
|
xai_grok_shell::managed_config::sync()
|
|
.await
|
|
.expect("deploy-key sync should succeed");
|
|
assert!(home.join("requirements.toml").exists());
|
|
|
|
// The blip: the key line is gone (same shape as a transient config read failure),
|
|
// while a team user is also signed in. Identity resolves Team("team-b"), which
|
|
// differs from the marker principal ("deploy-A") — but the marker is key-scoped.
|
|
write_config(&home, &url);
|
|
write_team_auth(&home, "team-b");
|
|
assert_eq!(
|
|
xai_grok_config::confirmed_team_switch("team-b"),
|
|
None,
|
|
"a key-scoped marker must never confirm a team switch"
|
|
);
|
|
assert!(
|
|
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
|
"the blip must not refuse: the key-scoped marker still matches the on-disk policy"
|
|
);
|
|
assert!(
|
|
home.join("requirements.toml").exists(),
|
|
"the machine's enforced policy must survive the blip"
|
|
);
|
|
assert!(
|
|
home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
|
.exists(),
|
|
"the dk marker must survive the blip"
|
|
);
|
|
}
|