mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Normalize bind-address wildcards before presenting install URLs, reject wildcard public origins at CLI and server install boundaries, and surface recovery guidance in the installer and doctor output.
384 lines
13 KiB
Rust
384 lines
13 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "integration tests stage fixtures and subprocess env with sync test infrastructure"
|
|
)]
|
|
|
|
use fabro_config::{Storage, envfile};
|
|
use fabro_test::{EnvVars, fabro_snapshot, test_context};
|
|
use fabro_vault::{SecretType, Vault};
|
|
|
|
#[test]
|
|
fn help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.install();
|
|
cmd.arg("--help");
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
Set up the Fabro environment (LLMs, certs, GitHub)
|
|
|
|
Usage: fabro install [OPTIONS] [COMMAND]
|
|
|
|
Commands:
|
|
github Configure GitHub integration (token or GitHub App)
|
|
help Print this message or the help of the given subcommand(s)
|
|
|
|
Options:
|
|
--json Output as JSON [env: FABRO_JSON=]
|
|
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
|
|
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
|
--web-url <WEB_URL> Base URL for the web UI (used for OAuth callback URLs and generated settings) [default: http://127.0.0.1:32276]
|
|
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
|
--non-interactive Run install without prompts; use hidden scripted flags for inputs
|
|
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
|
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
|
-h, --help Print help
|
|
----- stderr -----
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn github_help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.install();
|
|
cmd.args(["github", "--help"]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
Configure GitHub integration (token or GitHub App)
|
|
|
|
Usage: fabro install github [OPTIONS]
|
|
|
|
Options:
|
|
--json Output as JSON [env: FABRO_JSON=]
|
|
--strategy <STRATEGY> GitHub authentication strategy (requires --non-interactive) [possible values: token, app]
|
|
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
|
--owner <OWNER> GitHub App owner: 'personal' or 'org:<slug>' (app only, requires --non-interactive)
|
|
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
|
--non-interactive Run install without prompts; use hidden scripted flags for inputs
|
|
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
|
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
|
-h, --help Print help
|
|
----- stderr -----
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn install_json_requires_non_interactive() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args(["--json", "install"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("--json is only supported for install with --non-interactive"));
|
|
}
|
|
|
|
#[test]
|
|
fn install_json_non_interactive_is_not_rejected_as_unsupported() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args(["--json", "install", "--non-interactive"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("Non-interactive install requires additional flags"));
|
|
assert!(!stderr.contains("--json is not supported for this command"));
|
|
}
|
|
|
|
#[test]
|
|
fn install_json_non_interactive_allows_github_app_strategy() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.env_remove("MISSING_ANTHROPIC_API_KEY")
|
|
.args([
|
|
"--json",
|
|
"install",
|
|
"--non-interactive",
|
|
"--llm-provider",
|
|
"anthropic",
|
|
"--llm-api-key-env",
|
|
"MISSING_ANTHROPIC_API_KEY",
|
|
"--github-strategy",
|
|
"app",
|
|
"--github-owner",
|
|
"personal",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(!stderr.contains("GitHub App setup is not supported with --non-interactive"));
|
|
assert!(!stderr.contains("requires --github-username"));
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(stdout.trim()).expect("install JSON error should parse");
|
|
assert_eq!(value["event"], "install_error");
|
|
assert_eq!(value["status"], "error");
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_without_inputs_prints_scripted_usage_and_fails() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args(["install", "--non-interactive"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("Non-interactive install requires additional flags"));
|
|
assert!(stderr.contains("--llm-provider"));
|
|
assert!(stderr.contains("--github-strategy"));
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_wildcard_web_url_before_collecting_inputs() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args([
|
|
"install",
|
|
"--web-url",
|
|
"http://0.0.0.0:32276",
|
|
"--non-interactive",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("--web-url must not use a wildcard host"));
|
|
assert!(
|
|
!stderr.contains("Non-interactive install requires additional flags"),
|
|
"wildcard web URL should be rejected before scripted input validation: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_non_interactive_args_require_non_interactive() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args(["install", "--llm-provider", "anthropic"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("requires --non-interactive"));
|
|
}
|
|
|
|
#[test]
|
|
fn github_requires_prior_install() {
|
|
let context = test_context!();
|
|
std::fs::remove_file(context.home_dir.join(".fabro/settings.toml")).unwrap();
|
|
let output = context
|
|
.command()
|
|
.args(["install", "github"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("No settings.toml found. Run `fabro install` first."));
|
|
}
|
|
|
|
#[test]
|
|
fn github_scripted_flags_require_non_interactive() {
|
|
let context = test_context!();
|
|
context.write_home(".fabro/settings.toml", "_version = 1\n");
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["install", "github", "--strategy", "token"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("--strategy requires --non-interactive"));
|
|
}
|
|
|
|
#[test]
|
|
fn github_non_interactive_requires_strategy() {
|
|
let context = test_context!();
|
|
context.write_home(".fabro/settings.toml", "_version = 1\n");
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["install", "github", "--non-interactive"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("install github --non-interactive requires --strategy"));
|
|
}
|
|
|
|
#[test]
|
|
fn github_non_interactive_token_reconfigures_existing_app_install() {
|
|
let mut context = test_context!();
|
|
let storage_dir = context.home_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "{}"
|
|
|
|
[server.auth]
|
|
methods = ["dev-token", "github"]
|
|
|
|
[server.auth.github]
|
|
allowed_usernames = ["alice"]
|
|
|
|
[server.integrations.github]
|
|
strategy = "app"
|
|
app_id = "123"
|
|
slug = "alice-fabro"
|
|
client_id = "client-id"
|
|
|
|
[project.metadata]
|
|
mode = "keep-me"
|
|
"#,
|
|
storage_dir.display()
|
|
),
|
|
);
|
|
|
|
let server_env_path = Storage::new(&storage_dir).runtime_directory().env_path();
|
|
envfile::write_env_file(
|
|
&server_env_path,
|
|
&std::collections::HashMap::from([
|
|
("GITHUB_APP_PRIVATE_KEY".to_string(), "private".to_string()),
|
|
(
|
|
"GITHUB_APP_CLIENT_SECRET".to_string(),
|
|
"client-secret".to_string(),
|
|
),
|
|
(
|
|
"GITHUB_APP_WEBHOOK_SECRET".to_string(),
|
|
"webhook-secret".to_string(),
|
|
),
|
|
("KEEP_ME".to_string(), "1".to_string()),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
|
|
let fake_bin = context.temp_dir.join("fake-bin");
|
|
std::fs::create_dir_all(&fake_bin).unwrap();
|
|
let fake_gh = fake_bin.join("gh");
|
|
std::fs::write(
|
|
&fake_gh,
|
|
"#!/bin/sh\nif [ \"$1\" = \"auth\" ] && [ \"$2\" = \"token\" ]; then\n printf 'token-from-gh\\n'\n exit 0\nfi\nexit 1\n",
|
|
)
|
|
.unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
std::fs::set_permissions(&fake_gh, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
|
|
let path = format!(
|
|
"{}:{}",
|
|
fake_bin.display(),
|
|
std::env::var(EnvVars::PATH).unwrap()
|
|
);
|
|
let output = context
|
|
.command()
|
|
.env(EnvVars::PATH, path)
|
|
.args([
|
|
"install",
|
|
"github",
|
|
"--non-interactive",
|
|
"--strategy",
|
|
"token",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(output.status.success(), "{output:?}");
|
|
|
|
let settings = std::fs::read_to_string(context.home_dir.join(".fabro/settings.toml")).unwrap();
|
|
let parsed: toml::Value = toml::from_str(&settings).unwrap();
|
|
let github = parsed
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("integrations"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|integrations| integrations.get("github"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server.integrations.github should exist");
|
|
assert_eq!(
|
|
github.get("strategy").and_then(toml::Value::as_str),
|
|
Some("token")
|
|
);
|
|
assert!(!github.contains_key("app_id"));
|
|
assert!(!github.contains_key("slug"));
|
|
assert!(!github.contains_key("client_id"));
|
|
|
|
let methods = parsed
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("auth"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|auth| auth.get("methods"))
|
|
.and_then(toml::Value::as_array)
|
|
.expect("server.auth.methods should exist");
|
|
assert_eq!(
|
|
methods
|
|
.iter()
|
|
.map(|value| value.as_str().expect("auth method should be a string"))
|
|
.collect::<Vec<_>>(),
|
|
vec!["dev-token"]
|
|
);
|
|
assert!(
|
|
parsed
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("auth"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|auth| auth.get("github"))
|
|
.is_none(),
|
|
"server.auth.github should be removed"
|
|
);
|
|
assert_eq!(
|
|
parsed
|
|
.get("project")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|project| project.get("metadata"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|metadata| metadata.get("mode"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("keep-me")
|
|
);
|
|
|
|
let server_env = envfile::read_env_file(&server_env_path).unwrap();
|
|
assert!(!server_env.contains_key("GITHUB_APP_PRIVATE_KEY"));
|
|
assert!(!server_env.contains_key("GITHUB_APP_CLIENT_SECRET"));
|
|
assert!(!server_env.contains_key("GITHUB_APP_WEBHOOK_SECRET"));
|
|
assert_eq!(server_env.get("KEEP_ME").map(String::as_str), Some("1"));
|
|
|
|
let vault = Vault::load(Storage::new(&storage_dir).secrets_path()).unwrap();
|
|
assert_eq!(vault.get("GITHUB_TOKEN"), Some("token-from-gh"));
|
|
assert_eq!(
|
|
vault
|
|
.get_entry("GITHUB_TOKEN")
|
|
.map(|entry| entry.secret_type),
|
|
Some(SecretType::Environment)
|
|
);
|
|
}
|