fabro/lib/apps/fabro-server/tests/it/api/install.rs
2026-07-31 09:00:52 -04:00

2818 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_model::ProviderId;
use fabro_server::install::{
InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_server::test_support::test_environment_from_storage_dir;
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(ProviderId::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);
}