fabro/lib/apps/fabro-server/tests/it/api/install.rs
Bryan Helmkamp 75e5f34c0d
Expose lithos request and response shapes through the API, server, CLI, and web
The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:26:57 -06:00

2821 lines
92 KiB
Rust

#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::{ServerSettingsBuilder, Storage, envfile};
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
use fabro_server::install::{
InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_server::test_support::test_environment_from_storage_dir;
use fabro_types::ProviderId;
use fabro_util::Home;
use fabro_vault::Vault;
use httpmock::Method::GET;
use httpmock::MockServer;
use tokio::time::sleep;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry};
use crate::helpers::{checked_response, response_json, response_status, response_text};
fn spa_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}
async fn load_secret_snapshot(storage_dir: &std::path::Path) -> Vault {
let storage = Storage::new(storage_dir);
fabro_vault::SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
.await
.expect("test secret snapshot should load")
.into_vault()
}
fn assert_sandbox_provider_policy(
settings: &str,
local_enabled: bool,
docker_enabled: bool,
daytona_enabled: bool,
) {
assert!(settings.contains("[server.sandbox.providers.local]"));
assert!(settings.contains("[server.sandbox.providers.docker]"));
assert!(settings.contains("[server.sandbox.providers.daytona]"));
let resolved = ServerSettingsBuilder::from_toml(settings)
.expect("settings should resolve")
.server
.sandbox
.providers;
assert_eq!(resolved.local.enabled, local_enabled);
assert_eq!(resolved.docker.enabled, docker_enabled);
assert_eq!(resolved.daytona.enabled, daytona_enabled);
}
async fn seeded_default_environment(
temp_dir: &tempfile::TempDir,
) -> fabro_environment::Environment {
test_environment_from_storage_dir(temp_dir.path(), "default")
.await
.expect("install test environment store should load")
.expect("default environment should be seeded")
}
fn assert_no_legacy_environment_dir(temp_dir: &tempfile::TempDir) {
assert!(
!temp_dir.path().join("environments").exists(),
"install should not write legacy environments/*.toml files"
);
}
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method(GET)
.path("/sandbox/paginated")
.query_param("page", "1")
.query_param("limit", "1");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"items": [],
"total": 0,
"page": 1,
"totalPages": 0
}));
})
.await
}
async fn mock_daytona_current_key<'a>(
server: &'a MockServer,
permissions: Vec<&'static str>,
) -> httpmock::Mock<'a> {
server
.mock_async(move |when, then| {
when.method(GET)
.path("/api-keys/current")
.header("authorization", "Bearer dtn_test");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"name": "delete-only",
"value": "dtn_****",
"createdAt": "2026-05-01T00:00:00Z",
"permissions": permissions,
"lastUsedAt": null,
"expiresAt": null,
"userId": "user_123"
}));
})
.await
}
async fn mock_anthropic_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method("POST")
.path("/v1/messages")
.header("x-api-key", "anthropic-test-key");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"id": "msg_test_123",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "OK"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 1}
}));
})
.await
}
async fn mock_moonshot_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method("POST")
.path("/v1/chat/completions")
.header("authorization", "Bearer kimi-test-key");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"id": "chatcmpl_test_123",
"model": "kimi-k2.5",
"choices": [
{
"message": {"content": "OK"},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 1}
}));
})
.await
}
#[derive(Default)]
struct EventCapture {
fields: Vec<(String, String)>,
}
impl Visit for EventCapture {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
}
struct CaptureLayer {
lines: Arc<StdMutex<Vec<String>>>,
}
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if !event
.metadata()
.target()
.starts_with("fabro_server::install")
{
return;
}
let mut capture = EventCapture::default();
event.record(&mut capture);
let mut line = event.metadata().level().to_string();
for (field, value) in capture.fields {
let _ = write!(line, " {field}={value}");
}
self.lines.lock().unwrap().push(line);
}
}
async fn put_install_server(app: &axum::Router, token: &str, canonical_url: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"canonical_url":"{canonical_url}"}}"#
)))
.expect("server install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/server").await;
}
async fn put_install_llm(app: &axum::Router, token: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
))
.expect("LLM install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
async fn put_install_llm_skipped(app: &axum::Router, token: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"providers":[]}"#))
.expect("skipped LLM install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/github/token")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"token":"ghp_test_token","username":"{username}"}}"#
)))
.expect("GitHub token install request should build"),
)
.await
.unwrap();
response_status(
response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
}
async fn put_install_object_store(app: &axum::Router, token: &str, body: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/object-store")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("object-store install request should build"),
)
.await
.unwrap();
response_status(
response,
StatusCode::NO_CONTENT,
"PUT /install/object-store",
)
.await;
}
async fn put_install_object_store_local(app: &axum::Router, token: &str) {
put_install_object_store(app, token, r#"{"provider":"local"}"#).await;
}
async fn put_install_sandbox(app: &axum::Router, token: &str, body: &str) {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/sandbox")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("sandbox install request should build"),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "PUT /install/sandbox").await;
}
async fn put_install_sandbox_docker(app: &axum::Router, token: &str) {
put_install_sandbox(app, token, r#"{"provider":"docker"}"#).await;
}
async fn configure_token_install(app: &axum::Router, token: &str) {
put_install_server(app, token, "https://fabro.example.com").await;
put_install_object_store_local(app, token).await;
put_install_sandbox_docker(app, token).await;
put_install_llm(app, token).await;
put_install_github_token(app, token, "brynary").await;
}
#[tokio::test]
async fn install_router_isolated_from_normal_api_surface() {
let app = build_install_router(
InstallAppState::for_test("test-install-token").with_static_asset_root(spa_fixture_root()),
);
let health_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
assert_eq!(health_body["status"], "ok");
assert_eq!(health_body["mode"], "install");
let root_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/")
.header("accept", "text/html,application/xhtml+xml")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
assert!(
root_html.contains("__FABRO_MODE__ = \"install\""),
"install shell should mark the SPA boot mode"
);
let api_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/v1/auth/me")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(api_response, StatusCode::NOT_FOUND, "GET /api/v1/auth/me").await;
}
#[tokio::test]
async fn install_session_requires_valid_install_token() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let unauthorized = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
unauthorized,
StatusCode::UNAUTHORIZED,
"GET /install/session",
)
.await;
let authorized = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.header("x-forwarded-proto", "https")
.header("x-forwarded-host", "fabro.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(authorized, StatusCode::OK, "GET /install/session").await;
assert_eq!(
body["prefill"]["canonical_url"],
"https://fabro.example.com"
);
}
#[tokio::test]
async fn install_session_sanitizes_wildcard_host_prefill() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.header("host", "0.0.0.0:32276")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response, StatusCode::OK, "GET /install/session").await;
assert_eq!(body["prefill"]["canonical_url"], "http://localhost:32276");
}
#[tokio::test]
async fn install_endpoints_reject_missing_and_wrong_tokens() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let cases = [
("GET", "/install/session", None),
(
"POST",
"/install/llm/test",
Some(r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#),
),
(
"PUT",
"/install/llm",
Some(r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#),
),
(
"PUT",
"/install/server",
Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
),
(
"POST",
"/install/object-store/test",
Some(r#"{"provider":"local"}"#),
),
(
"PUT",
"/install/object-store",
Some(r#"{"provider":"local"}"#),
),
(
"POST",
"/install/sandbox/test",
Some(r#"{"provider":"docker"}"#),
),
("PUT", "/install/sandbox", Some(r#"{"provider":"docker"}"#)),
(
"POST",
"/install/github/token/test",
Some(r#"{"token":"ghp_test_token"}"#),
),
(
"PUT",
"/install/github/token",
Some(r#"{"token":"ghp_test_token","username":"octocat"}"#),
),
(
"POST",
"/install/github/app/manifest",
Some(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro","allowed_username":"octocat"}"#,
),
),
("POST", "/install/finish", None),
];
for (method, path, body) in cases {
let mut missing_token = Request::builder().method(method).uri(path);
let mut wrong_token = Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer wrong-token");
if body.is_some() {
missing_token = missing_token.header("content-type", "application/json");
wrong_token = wrong_token.header("content-type", "application/json");
}
let missing_token_response = app
.clone()
.oneshot(
missing_token
.body(Body::from(body.unwrap_or_default().to_string()))
.unwrap(),
)
.await
.unwrap();
response_status(
missing_token_response,
StatusCode::UNAUTHORIZED,
format!("{method} {path} without install token"),
)
.await;
let wrong_token_response = app
.clone()
.oneshot(
wrong_token
.body(Body::from(body.unwrap_or_default().to_string()))
.unwrap(),
)
.await
.unwrap();
response_status(
wrong_token_response,
StatusCode::UNAUTHORIZED,
format!("{method} {path} with wrong install token"),
)
.await;
}
}
#[tokio::test]
async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session?token=test-install-token")
.header("authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(response, StatusCode::OK, "GET /install/session?token=...").await;
}
#[tokio::test]
async fn object_store_local_validation_and_save_update_install_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let validation_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/object-store/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"local"}"#))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::OK,
"POST /install/object-store/test",
)
.await;
assert_eq!(validation_body["ok"], true);
put_install_object_store_local(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "local");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "object_store")
);
}
#[tokio::test]
async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoing_secrets() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let access_key_id = "AKIA_RUNTIME_SHOULD_NOT_LEAK";
let secret_access_key = "runtime-secret-should-not-leak";
let validation_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/object-store/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/object-store/test",
)
.await;
assert_eq!(
validation_body["errors"][0]["detail"],
"AWS access key fields are only allowed when using manual AWS access key credentials."
);
let rendered = validation_body.to_string();
assert!(!rendered.contains(access_key_id));
assert!(!rendered.contains(secret_access_key));
}
#[tokio::test]
async fn install_finish_requires_object_store_step() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["errors"][0]["detail"],
"install step 'object_store' is incomplete"
);
}
#[tokio::test]
async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_TEST_VALUE","secret_access_key":"secret-test-value"}"#,
)
.await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "s3");
assert_eq!(session_body["object_store"]["bucket"], "fabro-data");
assert_eq!(session_body["object_store"]["region"], "us-east-1");
assert_eq!(
session_body["object_store"]["credential_mode"],
"access_key"
);
assert_eq!(
session_body["object_store"]["manual_credentials_saved"],
true
);
let rendered_session = session_body.to_string();
assert!(!rendered_session.contains("AKIA_TEST_VALUE"));
assert!(!rendered_session.contains("secret-test-value"));
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key"}"#,
)
.await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(server_env.contains("AWS_ACCESS_KEY_ID=AKIA_TEST_VALUE"));
assert!(server_env.contains("AWS_SECRET_ACCESS_KEY=secret-test-value"));
}
#[tokio::test]
async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_SWITCH_ME","secret_access_key":"switch-secret-value"}"#,
)
.await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["object_store"]["provider"], "s3");
assert_eq!(session_body["object_store"]["credential_mode"], "runtime");
assert_eq!(
session_body["object_store"]["manual_credentials_saved"],
false
);
let rendered_session = session_body.to_string();
assert!(!rendered_session.contains("AKIA_SWITCH_ME"));
assert!(!rendered_session.contains("switch-secret-value"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
}
#[tokio::test]
async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked_entries() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let storage = Storage::new(temp_dir.path());
std::fs::write(
storage.runtime_directory().env_path(),
format!(
"AWS_ACCESS_KEY_ID=operator-id\n# {OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
),
)
.unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains("AWS_ACCESS_KEY_ID=operator-id"));
assert!(!server_env.contains("managed-secret"));
assert!(server_env.contains("KEEP_ME=1"));
}
#[tokio::test]
async fn token_install_finish_persists_settings_env_and_vault() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
assert!(
finish_body["dev_token"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("https://fabro.example.com"));
assert!(settings.contains("strategy = \"token\""));
assert!(
settings.contains("[run.environment]"),
"settings.toml should contain [run.environment]"
);
assert!(
!settings.contains("[environments"),
"settings.toml should not contain environment catalog entries"
);
assert_sandbox_provider_policy(&settings, true, true, false);
assert_no_legacy_environment_dir(&temp_dir);
let default_environment = seeded_default_environment(&temp_dir).await;
assert_eq!(default_environment.settings.provider.to_string(), "docker");
assert_eq!(
default_environment.settings.image.docker.as_deref(),
Some("buildpack-deps:noble")
);
let resolved = ServerSettingsBuilder::from_toml(&settings)
.expect("settings should resolve")
.server;
assert_eq!(
match resolved.listen {
fabro_types::settings::server::ServerListenSettings::Tcp { address, .. } => {
address.to_string()
}
fabro_types::settings::server::ServerListenSettings::Unix { .. } => {
String::new()
}
},
"127.0.0.1:32276"
);
let storage = fabro_config::Storage::new(temp_dir.path());
let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains_key(fabro_static::EnvVars::SESSION_SECRET));
assert!(server_env.contains_key(fabro_static::EnvVars::FABRO_DEV_TOKEN));
assert!(!server_env.contains_key(fabro_static::EnvVars::AWS_ACCESS_KEY_ID));
assert!(!server_env.contains_key(fabro_static::EnvVars::AWS_SECRET_ACCESS_KEY));
let finish_dev_token = finish_body["dev_token"]
.as_str()
.expect("token install should return a dev token");
let storage_dev_token =
fabro_util::dev_token::read_dev_token_file(&storage.runtime_directory().dev_token_path())
.expect("token install should write the storage dev token");
assert_eq!(storage_dev_token, finish_dev_token);
assert_eq!(
server_env
.get(fabro_static::EnvVars::FABRO_DEV_TOKEN)
.map(String::as_str),
Some(finish_dev_token)
);
let vault = load_secret_snapshot(temp_dir.path()).await;
assert!(vault.get("ANTHROPIC_API_KEY").is_some());
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}
#[tokio::test]
async fn install_llm_accepts_empty_providers_as_explicit_skip() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
put_install_llm_skipped(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "llm"),
"skipped LLM step should still count as completed"
);
assert_eq!(
session_body["llm"]["providers"],
serde_json::json!([]),
"skipped LLM step should expose an empty providers list"
);
}
#[tokio::test]
async fn browser_install_finish_with_skipped_llm_persists_no_llm_credentials() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm_skipped(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(settings.contains("https://fabro.example.com"));
assert!(settings.contains("strategy = \"token\""));
let server_env = std::fs::read_to_string(
fabro_config::Storage::new(temp_dir.path())
.runtime_directory()
.env_path(),
)
.unwrap();
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
let vault = load_secret_snapshot(temp_dir.path()).await;
assert!(
vault.get("OPENAI_API_KEY").is_none() && vault.get("OPENAI_CODEX").is_none(),
"skipped LLM install should not write any OpenAI vault entries"
);
assert_eq!(
vault.get("GITHUB_TOKEN"),
Some("ghp_test_token"),
"GitHub secrets still persist when the LLM step is skipped"
);
}
#[tokio::test]
async fn token_install_finish_invokes_finish_hook_before_response_returns() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let observed = Arc::new(StdMutex::new(None));
let observed_for_hook = Arc::clone(&observed);
let hook: InstallFinishHook = Arc::new(move |info: &InstallFinishInfo| {
*observed_for_hook.lock().unwrap() =
Some((info.canonical_url.clone(), info.dev_token.clone()));
Ok(())
});
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_finish_hook(hook),
);
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let Some((canonical_url, dev_token)) = observed.lock().unwrap().clone() else {
panic!("finish hook should run before response returns");
};
assert_eq!(canonical_url, "https://fabro.example.com");
assert_eq!(
dev_token.as_deref(),
finish_body["dev_token"].as_str(),
"hook receives the same token returned to the browser"
);
}
#[tokio::test]
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let home = Home::new(home_root.path().join(".fabro"));
let config_path = temp_dir.path().join("settings.toml");
let github_mock = MockServer::start_async().await;
github_mock
.mock_async(|when, then| {
when.method("POST")
.path("/app-manifests/stub-code/conversions");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"id": 42,
"slug": "fabro-test-app",
"client_id": "Iv1.test-client-id",
"client_secret": "test-client-secret",
"webhook_secret": "test-webhook-secret",
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
}"#,
);
})
.await;
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_home(home.clone())
.with_github_api_base_url(github_mock.url("")),
);
let llm_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox_docker(&app, "test-install-token").await;
let manifest_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let state = manifest_body["state"]
.as_str()
.expect("state should be present on manifest response")
.to_owned();
assert_eq!(
manifest_body["manifest"]["redirect_url"],
"https://fabro.example.com/install/github/app/redirect",
"redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
);
let callback_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
callback_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
assert!(
finish_body.get("dev_token").is_none(),
"App installs must not expose a dev token"
);
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
assert!(
!home.root().join("dev-token").exists(),
"home dev token file should not be created for App installs"
);
assert!(
!Storage::new(temp_dir.path())
.runtime_directory()
.dev_token_path()
.exists(),
"storage dev token file should not be created for App installs"
);
}
#[tokio::test]
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let callback_invoked = Arc::new(AtomicBool::new(false));
let callback_flag = Arc::clone(&callback_invoked);
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_home(Home::new(home_root.path().join(".fabro")))
.with_finish_callback(Arc::new(move || {
callback_flag.store(true, Ordering::Release);
})),
);
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert!(!callback_invoked.load(Ordering::Acquire));
sleep(Duration::from_millis(650)).await;
assert!(callback_invoked.load(Ordering::Acquire));
}
#[tokio::test]
async fn install_llm_accepts_catalog_openai_compatible_provider() {
let llm_mock = MockServer::start_async().await;
mock_moonshot_install_validation(&llm_mock).await;
let app = build_install_router(
InstallAppState::for_test("test-install-token").with_provider_base_url(
ProviderId::new("moonshot"),
format!("{}/v1", llm_mock.url("")),
),
);
let test_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/llm/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"provider":"moonshot","api_key":"kimi-test-key"}"#,
))
.unwrap(),
)
.await
.unwrap();
let body = response_json(test_response, StatusCode::OK, "POST /install/llm/test").await;
assert_eq!(body["ok"], true);
let put_response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"moonshot","api_key":"kimi-test-key"}]}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(put_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}
#[tokio::test]
async fn install_validation_endpoints_validate_credentials_and_github_token() {
let llm_mock = MockServer::start_async().await;
mock_anthropic_install_validation(&llm_mock).await;
let github_mock = MockServer::start_async().await;
github_mock
.mock_async(|when, then| {
when.method("GET").path("/user");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"login":"octocat"}"#);
})
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_provider_base_url(
fabro_types::provider_ids::anthropic(),
format!("{}/v1", llm_mock.url("")),
)
.with_github_api_base_url(github_mock.url("")),
);
let llm_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/llm/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#,
))
.unwrap(),
)
.await
.unwrap();
let llm_body = response_json(llm_response, StatusCode::OK, "POST /install/llm/test").await;
assert_eq!(llm_body["ok"], true);
let github_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/token/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"token":"ghp_test_token"}"#))
.unwrap(),
)
.await
.unwrap();
let github_body = response_json(
github_response,
StatusCode::OK,
"POST /install/github/token/test",
)
.await;
assert_eq!(github_body["username"], "octocat");
}
#[tokio::test]
async fn github_app_manifest_round_trip_updates_install_session() {
let github_mock = MockServer::start_async().await;
let conversion_mock = github_mock
.mock_async(|when, then| {
when.method("POST")
.path("/app-manifests/stub-code/conversions");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"id": 42,
"slug": "fabro-test-app",
"client_id": "Iv1.test-client-id",
"client_secret": "test-client-secret",
"webhook_secret": "test-webhook-secret",
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
}"#,
);
})
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_github_api_base_url(github_mock.url("")),
);
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
assert_eq!(
manifest_body["github_form_action"],
"https://github.com/settings/apps/new"
);
assert_eq!(
manifest_body["manifest"]["callback_urls"][0],
"https://fabro.example.com/auth/callback/github"
);
let state = manifest_body["state"]
.as_str()
.expect("state should be present on manifest response")
.to_owned();
assert_eq!(
manifest_body["manifest"]["redirect_url"],
"https://fabro.example.com/install/github/app/redirect",
"redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
);
let callback_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
assert_eq!(
callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/install/github/done?token=test-install-token")
);
conversion_mock.assert_async().await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["github"]["strategy"], "app");
assert_eq!(session_body["github"]["slug"], "fabro-test-app");
assert_eq!(session_body["github"]["allowed_username"], "octocat");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "github")
);
}
#[tokio::test]
async fn github_app_manifest_retry_replaces_pending_and_preserves_prior_token_strategy() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let github_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/github/token")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"token":"ghp_test_token","username":"brynary"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
github_response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let first_body = response_json(
first_response,
StatusCode::OK,
"POST /install/github/app/manifest (initial)",
)
.await;
let first_state = first_body["state"]
.as_str()
.expect("state should be present on manifest response")
.to_owned();
let retry_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Retry","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let retry_body = response_json(
retry_response,
StatusCode::OK,
"POST /install/github/app/manifest (retry)",
)
.await;
let retry_state = retry_body["state"]
.as_str()
.expect("state should be present on retry manifest response")
.to_owned();
assert_ne!(
first_state, retry_state,
"retry must mint a fresh state token so the old callback is invalidated"
);
// A late callback using the now-discarded first-attempt state must not
// complete the install.
let stale_callback = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={first_state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
stale_callback,
StatusCode::FOUND,
"GET /install/github/app/redirect with stale state",
)
.await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["github"]["strategy"], "token");
assert_eq!(session_body["github"]["username"], "brynary");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "github")
);
}
#[tokio::test]
async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_session() {
let github_mock = MockServer::start_async().await;
let conversion_mock = github_mock
.mock_async(|when, then| {
when.method("POST")
.path("/app-manifests/stub-code/conversions");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"id": 42,
"slug": "fabro-test-app",
"client_id": "Iv1.test-client-id",
"client_secret": "test-client-secret",
"webhook_secret": "test-webhook-secret",
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
}"#,
);
})
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_github_api_base_url(github_mock.url("")),
);
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let state = manifest_body["state"]
.as_str()
.expect("state should be present on manifest response")
.to_owned();
assert_eq!(
manifest_body["manifest"]["redirect_url"],
"https://fabro.example.com/install/github/app/redirect",
"redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
);
let wrong_state_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=wrong-state",
)
.await;
assert_eq!(
wrong_state_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/install/github?token=test-install-token&error=invalid-install-github-app-state")
);
conversion_mock.assert_calls_async(0).await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(session_body["github"].is_null());
assert!(
!session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "github")
);
let missing_state_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code",
)
.await;
assert_eq!(
missing_state_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/install/github?token=test-install-token&error=missing-install-github-app-state")
);
conversion_mock.assert_calls_async(0).await;
let valid_state_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
valid_state_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
conversion_mock.assert_calls_async(1).await;
}
#[tokio::test]
async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pending_state() {
let github_mock = MockServer::start_async().await;
let conversion_mock = github_mock
.mock_async(|when, then| {
when.method("POST")
.path("/app-manifests/stub-code/conversions");
then.status(502).body("upstream exploded");
})
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_github_api_base_url(github_mock.url("")),
);
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let state = manifest_body["state"]
.as_str()
.expect("state should be present on manifest response")
.to_owned();
assert_eq!(
manifest_body["manifest"]["redirect_url"],
"https://fabro.example.com/install/github/app/redirect",
"redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
);
let callback_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
assert_eq!(
callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some(
"/install/github?token=test-install-token&error=github-app-manifest-conversion-failed"
)
);
conversion_mock.assert_calls_async(1).await;
let retry_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
retry_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
conversion_mock.assert_calls_async(2).await;
}
#[tokio::test]
async fn install_server_rejects_trailing_slash_canonical_urls() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com/"}"#,
))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/server",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"canonical_url must not end with a trailing slash"
);
}
#[tokio::test]
async fn install_server_rejects_wildcard_canonical_urls() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
for canonical_url in [
"http://0.0.0.0:32276",
"http://[::]:32276",
"http://0:32276",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"canonical_url":"{canonical_url}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/server",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"canonical_url must not use a wildcard host"
);
}
}
#[tokio::test]
async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let callback_invoked = Arc::new(AtomicBool::new(false));
let callback_flag = Arc::clone(&callback_invoked);
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_home(Home::new(home_root.path().join(".fabro")))
.with_finish_callback(Arc::new(move || {
callback_flag.store(true, Ordering::Release);
})),
);
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
assert!(
finish_body["errors"][0]["detail"]
.as_str()
.is_some_and(|value| value.contains("persisting install outputs directly"))
);
let leftover_env_keys = finish_body["leftover_env_keys"]
.as_array()
.expect("leftover_env_keys should be present");
assert!(
leftover_env_keys
.iter()
.any(|value| value == "SESSION_SECRET")
);
assert!(
leftover_env_keys
.iter()
.any(|value| value == "FABRO_DEV_TOKEN")
);
assert_eq!(
std::fs::read_to_string(&config_path).unwrap(),
"_version = 1\n[project]\nname = \"keep\"\n"
);
assert_eq!(
std::fs::read_to_string(&vault_path).unwrap(),
"{ not valid json"
);
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
assert!(!callback_invoked.load(Ordering::Acquire));
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "github")
);
}
#[tokio::test(flavor = "current_thread")]
async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
let access_key_id = "AKIA_FINISH_SHOULD_NOT_LEAK";
let secret_access_key = "finish-secret-should-not-leak";
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
&format!(
r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
),
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let lines = Arc::new(StdMutex::new(Vec::new()));
let subscriber = Registry::default().with(CaptureLayer {
lines: Arc::clone(&lines),
});
let _guard = tracing::subscriber::set_default(subscriber);
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
let rendered = finish_body.to_string();
assert!(!rendered.contains(access_key_id));
assert!(!rendered.contains(secret_access_key));
let captured = lines.lock().unwrap().join("\n");
assert!(
captured.contains("install persistence failed"),
"expected finish failure logs to be captured, got: {captured}"
);
assert!(!captured.contains(access_key_id));
assert!(!captured.contains(secret_access_key));
}
#[tokio::test]
async fn install_finish_failure_reports_only_env_keys_actually_removed() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let env_path = storage.runtime_directory().env_path();
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
std::fs::write(
&env_path,
format!(
"#{OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
),
)
.unwrap();
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store(
&app,
"test-install-token",
r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["removed_env_keys"]
.as_array()
.expect("removed_env_keys should be present"),
&vec![serde_json::Value::String(
"AWS_SECRET_ACCESS_KEY".to_string()
)]
);
let server_env = std::fs::read_to_string(env_path).unwrap();
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY=managed-secret"));
assert!(server_env.contains("KEEP_ME=1"));
}
#[tokio::test]
async fn install_finish_failure_does_not_create_dev_token_files() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let home = Home::new(home_root.path().join(".fabro"));
let config_path = temp_dir.path().join("settings.toml");
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
let storage = Storage::new(temp_dir.path());
let vault_path = storage.secrets_path();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, "{ not valid json").unwrap();
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_home(home.clone()),
);
configure_token_install(&app, "test-install-token").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
assert!(
!home.root().join("dev-token").exists(),
"home dev token file should not be created"
);
assert!(
!storage.runtime_directory().dev_token_path().exists(),
"storage dev token file should not be created when persistence fails"
);
let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
assert!(
server_env
.get(fabro_static::EnvVars::FABRO_DEV_TOKEN)
.is_some_and(|value| !value.is_empty())
);
}
#[tokio::test]
async fn sandbox_docker_save_records_explicit_provider_in_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let validation_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"docker"}"#))
.unwrap(),
)
.await
.unwrap();
let validation_body = response_json(
validation_response,
StatusCode::OK,
"POST /install/sandbox/test",
)
.await;
assert_eq!(validation_body["ok"], true);
put_install_sandbox_docker(&app, "test-install-token").await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "docker");
assert!(
session_body["completed_steps"]
.as_array()
.unwrap()
.iter()
.any(|value| value == "sandbox")
);
}
#[tokio::test]
async fn sandbox_daytona_without_api_key_is_rejected() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/sandbox")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/sandbox without api_key",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"api_key is required for daytona"
);
}
#[tokio::test]
async fn sandbox_daytona_test_endpoint_without_api_key_is_rejected() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/sandbox/test without api_key",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"api_key is required for daytona"
);
}
#[tokio::test]
async fn sandbox_daytona_resave_without_api_key_preserves_saved_key() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
let api_key = "dtn_keep_me";
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
put_install_sandbox(&app, "test-install-token", r#"{"provider":"daytona"}"#).await;
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "daytona");
assert_eq!(session_body["sandbox"]["api_key_saved"], true);
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let vault = load_secret_snapshot(temp_dir.path()).await;
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}
#[tokio::test]
async fn sandbox_switching_from_daytona_to_docker_drops_saved_key() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
put_install_sandbox(
&app,
"test-install-token",
r#"{"provider":"daytona","api_key":"dtn_will_be_dropped"}"#,
)
.await;
put_install_sandbox_docker(&app, "test-install-token").await;
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(
settings.contains("[run.environment]"),
"settings.toml should select the default environment"
);
assert_no_legacy_environment_dir(&temp_dir);
let default_environment = seeded_default_environment(&temp_dir).await;
assert_eq!(default_environment.settings.provider.to_string(), "docker");
let vault = load_secret_snapshot(temp_dir.path()).await;
assert_eq!(vault.get("DAYTONA_API_KEY"), None);
}
#[tokio::test]
async fn sandbox_daytona_save_redacts_api_key_in_session() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let api_key = "dtn_should_not_leak";
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
let session_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/install/session")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["sandbox"]["provider"], "daytona");
assert_eq!(session_body["sandbox"]["api_key_saved"], true);
let rendered = session_body.to_string();
assert!(!rendered.contains(api_key));
}
#[tokio::test]
async fn install_finish_requires_sandbox_step() {
let app = build_install_router(InstallAppState::for_test("test-install-token"));
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let finish_body = response_json(
finish_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/finish",
)
.await;
assert_eq!(
finish_body["errors"][0]["detail"],
"install step 'sandbox' is incomplete"
);
}
#[tokio::test]
async fn daytona_install_finish_writes_settings_and_vault_secret() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("settings.toml");
let app = build_install_router(InstallAppState::for_test_with_paths(
"test-install-token",
temp_dir.path(),
&config_path,
));
let api_key = "dtn_test_secret";
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
put_install_object_store_local(&app, "test-install-token").await;
put_install_sandbox(
&app,
"test-install-token",
&format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
)
.await;
put_install_llm(&app, "test-install-token").await;
put_install_github_token(&app, "test-install-token", "brynary").await;
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
let settings = std::fs::read_to_string(&config_path).unwrap();
assert!(
settings.contains("[run.environment]"),
"settings.toml should contain [run.environment]"
);
assert!(
!settings.contains("[environments"),
"settings.toml should not contain environment catalog entries"
);
assert_sandbox_provider_policy(&settings, true, false, true);
assert_no_legacy_environment_dir(&temp_dir);
let default_environment = seeded_default_environment(&temp_dir).await;
assert_eq!(default_environment.settings.provider.to_string(), "daytona");
assert!(matches!(
default_environment.settings.image.dockerfile.as_ref(),
Some(fabro_types::settings::run::DockerfileSource::Inline(content))
if content.contains("buildpack-deps:noble")
));
let vault = load_secret_snapshot(temp_dir.path()).await;
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}
#[tokio::test]
async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() {
let server = MockServer::start_async().await;
let auth = mock_daytona_auth_probe(&server).await;
let current_key = mock_daytona_current_key(&server, vec![
"delete:snapshots",
"delete:sandboxes",
"delete:volumes",
])
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_daytona_api_base_url(server.base_url()),
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona","api_key":"dtn_test"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/sandbox/test under-scoped daytona",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
);
auth.assert_async().await;
current_key.assert_async().await;
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn sandbox_daytona_test_endpoint_validates_real_api_key() {
let api_key = std::env::var(fabro_static::EnvVars::DAYTONA_API_KEY)
.expect("DAYTONA_API_KEY must be set for live test");
let app = build_install_router(InstallAppState::for_test("test-install-token"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"provider":"daytona","api_key":"{api_key}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::OK,
"POST /install/sandbox/test (live daytona)",
)
.await;
assert_eq!(body["ok"], true);
}