mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
2818 lines
92 KiB
Rust
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);
|
|
}
|