mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
983 lines
31 KiB
Rust
983 lines
31 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
clippy::disallowed_types,
|
|
reason = "integration tests stage fixtures, reserve ports, and subprocess env with sync test infrastructure"
|
|
)]
|
|
|
|
use std::net::TcpListener;
|
|
use std::time::Duration;
|
|
|
|
use fabro_config::{Storage, envfile};
|
|
use fabro_test::{EnvVars, fabro_snapshot, test_context};
|
|
use fabro_vault::{SecretType, Vault};
|
|
|
|
const INSTALL_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
async fn load_secret_snapshot(storage: &Storage) -> Vault {
|
|
fabro_vault::SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
|
|
.await
|
|
.expect("test secret snapshot should load")
|
|
.into_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 skip_llm_requires_non_interactive() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.command()
|
|
.args(["install", "--skip-llm"])
|
|
.output()
|
|
.expect("command should run");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(stderr.contains("--skip-llm requires --non-interactive"));
|
|
}
|
|
|
|
#[test]
|
|
fn skip_llm_conflicts_with_llm_credential_flags() {
|
|
let context = test_context!();
|
|
|
|
let provider_conflict = context
|
|
.command()
|
|
.args([
|
|
"install",
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--llm-provider",
|
|
"anthropic",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
assert!(!provider_conflict.status.success());
|
|
let stderr = String::from_utf8(provider_conflict.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("--skip-llm") && stderr.contains("--llm-provider"),
|
|
"expected a conflict error between --skip-llm and --llm-provider: {stderr}"
|
|
);
|
|
|
|
let stdin_conflict = context
|
|
.command()
|
|
.args([
|
|
"install",
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--llm-api-key-stdin",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
assert!(!stdin_conflict.status.success());
|
|
let stderr = String::from_utf8(stdin_conflict.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-stdin"),
|
|
"expected a conflict error between --skip-llm and --llm-api-key-stdin: {stderr}"
|
|
);
|
|
|
|
let env_conflict = context
|
|
.command()
|
|
.args([
|
|
"install",
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--llm-api-key-env",
|
|
"ANTHROPIC_API_KEY",
|
|
])
|
|
.output()
|
|
.expect("command should run");
|
|
assert!(!env_conflict.status.success());
|
|
let stderr = String::from_utf8(env_conflict.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-env"),
|
|
"expected a conflict error between --skip-llm and --llm-api-key-env: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_token_install_bootstraps_server_auth_for_secret_persistence() {
|
|
let mut context = test_context!();
|
|
std::fs::remove_file(context.home_dir.join(".fabro/settings.toml")).unwrap();
|
|
let storage_dir = context.temp_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
|
|
let path = fake_gh_path(&context, "ghp_install_bootstrap");
|
|
let web_url = unused_loopback_web_url();
|
|
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.env(EnvVars::PATH, &path)
|
|
.args([
|
|
"install",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
"--web-url",
|
|
&web_url,
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--github-strategy",
|
|
"token",
|
|
"--github-username",
|
|
"octocat",
|
|
"--overwrite-settings",
|
|
])
|
|
.output()
|
|
.expect("install command should run");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"install should bootstrap auth before persisting secrets\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!stderr.contains("Authentication required"),
|
|
"install should not require a separate auth login while bootstrapping: {stderr}"
|
|
);
|
|
|
|
let list_output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.args(["--json", "secret", "list"])
|
|
.output()
|
|
.expect("secret list command should run");
|
|
|
|
assert!(
|
|
list_output.status.success(),
|
|
"CLI auth saved by install should authenticate follow-up secret commands\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&list_output.stdout),
|
|
String::from_utf8_lossy(&list_output.stderr)
|
|
);
|
|
let secrets: serde_json::Value =
|
|
serde_json::from_slice(&list_output.stdout).expect("secret list JSON should parse");
|
|
assert!(
|
|
secrets
|
|
.as_array()
|
|
.expect("secret list should return an array")
|
|
.iter()
|
|
.any(|secret| secret["name"] == "GITHUB_TOKEN"),
|
|
"installed GitHub token should be persisted as a server-owned secret: {secrets}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keep_existing_settings_persists_secrets_without_rewriting_server_target() {
|
|
let mut context = test_context!();
|
|
let storage_dir = context.temp_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
let socket_path = context.temp_dir.join("install-storage.sock");
|
|
let existing_web_url = "https://existing.example.test".to_string();
|
|
let requested_web_url = "https://requested.example.test".to_string();
|
|
write_unix_install_settings(
|
|
&context,
|
|
&storage_dir,
|
|
&socket_path,
|
|
&existing_web_url,
|
|
"keep-me",
|
|
);
|
|
|
|
let path = fake_gh_path(&context, "ghp_keep_existing");
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.env(EnvVars::PATH, path)
|
|
.args([
|
|
"install",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
"--web-url",
|
|
&requested_web_url,
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--github-strategy",
|
|
"token",
|
|
"--keep-existing-settings",
|
|
])
|
|
.output()
|
|
.expect("install command should run");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"install should keep existing server settings while persisting secrets\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let settings = read_home_settings(&context);
|
|
let parsed: toml::Value = toml::from_str(&settings).unwrap();
|
|
assert_eq!(
|
|
parsed
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("web"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|web| web.get("url"))
|
|
.and_then(toml::Value::as_str),
|
|
Some(existing_web_url.as_str())
|
|
);
|
|
let target = parsed
|
|
.get("cli")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|cli| cli.get("target"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("cli.target should remain configured");
|
|
assert_eq!(
|
|
target.get("type").and_then(toml::Value::as_str),
|
|
Some("unix")
|
|
);
|
|
assert_eq!(
|
|
target.get("path").and_then(toml::Value::as_str),
|
|
socket_path.to_str()
|
|
);
|
|
assert!(
|
|
!settings.contains(&requested_web_url),
|
|
"--keep-existing-settings should not rewrite settings to the requested web URL"
|
|
);
|
|
assert_secret_list_contains(&context, &["GITHUB_TOKEN"]);
|
|
}
|
|
|
|
#[test]
|
|
fn install_against_running_authenticated_server_persists_secrets_and_leaves_server_running() {
|
|
let mut context = test_context!();
|
|
let storage_dir = context.temp_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
let web_url = unused_loopback_web_url();
|
|
write_http_install_settings(&context, &storage_dir, &web_url, "running-server");
|
|
login_with_storage_dev_token(&context, &storage_dir, &web_url);
|
|
|
|
let start_output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.args([
|
|
"server",
|
|
"start",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("server start command should run");
|
|
assert!(
|
|
start_output.status.success(),
|
|
"server start should succeed before install\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&start_output.stdout),
|
|
String::from_utf8_lossy(&start_output.stderr)
|
|
);
|
|
|
|
let path = fake_gh_path(&context, "ghp_running_server");
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.env(EnvVars::PATH, path)
|
|
.args([
|
|
"install",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
"--web-url",
|
|
&web_url,
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--github-strategy",
|
|
"token",
|
|
"--github-username",
|
|
"octocat",
|
|
"--overwrite-settings",
|
|
])
|
|
.output()
|
|
.expect("install command should run");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"install should persist secrets through the already-running server\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let status_output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.args([
|
|
"server",
|
|
"status",
|
|
"--json",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("server status command should run");
|
|
assert!(
|
|
status_output.status.success(),
|
|
"server should still be running after install\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&status_output.stdout),
|
|
String::from_utf8_lossy(&status_output.stderr)
|
|
);
|
|
let status: serde_json::Value =
|
|
serde_json::from_slice(&status_output.stdout).expect("server status JSON should parse");
|
|
assert_eq!(status["status"].as_str(), Some("running"));
|
|
assert_secret_list_contains(&context, &["GITHUB_TOKEN"]);
|
|
}
|
|
|
|
#[test]
|
|
fn install_json_non_interactive_success_emits_complete_event() {
|
|
let mut context = test_context!();
|
|
std::fs::remove_file(context.home_dir.join(".fabro/settings.toml")).unwrap();
|
|
let storage_dir = context.temp_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
let web_url = unused_loopback_web_url();
|
|
login_with_storage_dev_token(&context, &storage_dir, &web_url);
|
|
|
|
let path = fake_gh_path(&context, "ghp_json_success");
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.env(EnvVars::PATH, path)
|
|
.args([
|
|
"--json",
|
|
"install",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
"--web-url",
|
|
&web_url,
|
|
"--non-interactive",
|
|
"--skip-llm",
|
|
"--github-strategy",
|
|
"token",
|
|
"--github-username",
|
|
"octocat",
|
|
"--overwrite-settings",
|
|
])
|
|
.output()
|
|
.expect("install command should run");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"JSON install should succeed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let events = stdout
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(events, vec![serde_json::json!({
|
|
"event": "install_complete",
|
|
"status": "success"
|
|
})]);
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
|
fn install_with_anthropic_api_key_persists_llm_and_github_secrets() {
|
|
let mut context = test_context!();
|
|
std::fs::remove_file(context.home_dir.join(".fabro/settings.toml")).unwrap();
|
|
let storage_dir = context.temp_dir.join("install-storage");
|
|
context.manage_storage_dir(&storage_dir);
|
|
let web_url = unused_loopback_web_url();
|
|
login_with_storage_dev_token(&context, &storage_dir, &web_url);
|
|
|
|
let path = fake_gh_path(&context, "ghp_anthropic");
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.env(EnvVars::PATH, path)
|
|
.args([
|
|
"install",
|
|
"--storage-dir",
|
|
storage_dir.to_str().unwrap(),
|
|
"--web-url",
|
|
&web_url,
|
|
"--non-interactive",
|
|
"--llm-provider",
|
|
"anthropic",
|
|
"--llm-api-key-env",
|
|
"ANTHROPIC_API_KEY",
|
|
"--github-strategy",
|
|
"token",
|
|
"--github-username",
|
|
"octocat",
|
|
"--overwrite-settings",
|
|
])
|
|
.output()
|
|
.expect("install command should run");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"install should validate and persist the scripted LLM API key\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert_secret_list_contains(&context, &["GITHUB_TOKEN", "ANTHROPIC_API_KEY"]);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async 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 mut stale_vault = Vault::load(Storage::new(&storage_dir).secrets_path()).unwrap();
|
|
stale_vault
|
|
.set("GITHUB_APP_PRIVATE_KEY", "private", SecretType::File, None)
|
|
.unwrap();
|
|
stale_vault
|
|
.set(
|
|
"GITHUB_APP_CLIENT_SECRET",
|
|
"client-secret",
|
|
SecretType::Token,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
stale_vault
|
|
.set(
|
|
"GITHUB_APP_WEBHOOK_SECRET",
|
|
"webhook-secret",
|
|
SecretType::Token,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let path = fake_gh_path(&context, "token-from-gh");
|
|
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 = load_secret_snapshot(&Storage::new(&storage_dir)).await;
|
|
assert_eq!(vault.get("GITHUB_TOKEN"), Some("token-from-gh"));
|
|
assert_eq!(vault.get("GITHUB_APP_PRIVATE_KEY"), None);
|
|
assert_eq!(vault.get("GITHUB_APP_CLIENT_SECRET"), None);
|
|
assert_eq!(vault.get("GITHUB_APP_WEBHOOK_SECRET"), None);
|
|
assert_eq!(
|
|
vault
|
|
.get_entry("GITHUB_TOKEN")
|
|
.map(|entry| entry.secret_type),
|
|
Some(SecretType::Token)
|
|
);
|
|
}
|
|
|
|
fn unused_loopback_web_url() -> String {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused loopback port");
|
|
let port = listener.local_addr().expect("read loopback addr").port();
|
|
drop(listener);
|
|
format!("http://127.0.0.1:{port}")
|
|
}
|
|
|
|
fn fake_gh_path(context: &fabro_test::TestContext, token: &str) -> String {
|
|
let fake_bin = context.temp_dir.join(format!("fake-bin-{token}"));
|
|
std::fs::create_dir_all(&fake_bin).expect("fake gh bin directory should be created");
|
|
let fake_gh = fake_bin.join("gh");
|
|
std::fs::write(
|
|
&fake_gh,
|
|
format!("#!/bin/sh\nif [ \"$1\" = \"auth\" ] && [ \"$2\" = \"token\" ]; then\n printf '{token}\\n'\n exit 0\nfi\nexit 1\n"),
|
|
)
|
|
.expect("fake gh script should be written");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
std::fs::set_permissions(&fake_gh, std::fs::Permissions::from_mode(0o755))
|
|
.expect("fake gh script should be executable");
|
|
}
|
|
|
|
format!(
|
|
"{}:{}",
|
|
fake_bin.display(),
|
|
std::env::var(EnvVars::PATH).expect("PATH should be set for install tests")
|
|
)
|
|
}
|
|
|
|
fn write_raw_home_settings(context: &fabro_test::TestContext, settings: &str) {
|
|
let settings_path = context.home_dir.join(".fabro/settings.toml");
|
|
std::fs::create_dir_all(
|
|
settings_path
|
|
.parent()
|
|
.expect("settings path should have a parent directory"),
|
|
)
|
|
.expect("settings directory should be created");
|
|
std::fs::write(settings_path, settings).expect("settings file should be written");
|
|
}
|
|
|
|
fn read_home_settings(context: &fabro_test::TestContext) -> String {
|
|
std::fs::read_to_string(context.home_dir.join(".fabro/settings.toml"))
|
|
.expect("settings file should be readable")
|
|
}
|
|
|
|
fn write_http_install_settings(
|
|
context: &fabro_test::TestContext,
|
|
storage_dir: &std::path::Path,
|
|
web_url: &str,
|
|
metadata_mode: &str,
|
|
) {
|
|
let address = web_url
|
|
.strip_prefix("http://")
|
|
.expect("test web URL should be an http URL");
|
|
write_raw_home_settings(
|
|
context,
|
|
&format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "{}"
|
|
|
|
[server.api]
|
|
url = "{}/api/v1"
|
|
|
|
[server.web]
|
|
enabled = true
|
|
url = "{}"
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
|
|
[server.listen]
|
|
type = "tcp"
|
|
address = "{}"
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "{}"
|
|
|
|
[project.metadata]
|
|
mode = "{}"
|
|
"#,
|
|
storage_dir.display(),
|
|
web_url,
|
|
web_url,
|
|
address,
|
|
web_url,
|
|
metadata_mode
|
|
),
|
|
);
|
|
}
|
|
|
|
fn write_unix_install_settings(
|
|
context: &fabro_test::TestContext,
|
|
storage_dir: &std::path::Path,
|
|
socket_path: &std::path::Path,
|
|
web_url: &str,
|
|
metadata_mode: &str,
|
|
) {
|
|
write_raw_home_settings(
|
|
context,
|
|
&format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "{}"
|
|
|
|
[server.api]
|
|
url = "{}/api/v1"
|
|
|
|
[server.web]
|
|
enabled = true
|
|
url = "{}"
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
|
|
[server.listen]
|
|
type = "unix"
|
|
path = "{}"
|
|
|
|
[cli.target]
|
|
type = "unix"
|
|
path = "{}"
|
|
|
|
[project.metadata]
|
|
mode = "{}"
|
|
"#,
|
|
storage_dir.display(),
|
|
web_url,
|
|
web_url,
|
|
socket_path.display(),
|
|
socket_path.display(),
|
|
metadata_mode
|
|
),
|
|
);
|
|
}
|
|
|
|
fn login_with_storage_dev_token(
|
|
context: &fabro_test::TestContext,
|
|
storage_dir: &std::path::Path,
|
|
web_url: &str,
|
|
) {
|
|
let token = fabro_util::dev_token::read_dev_token_file(
|
|
&Storage::new(storage_dir)
|
|
.runtime_directory()
|
|
.dev_token_path(),
|
|
)
|
|
.expect("storage dev token should be valid");
|
|
let output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.args(["auth", "login", "--server", web_url, "--dev-token", &token])
|
|
.output()
|
|
.expect("auth login command should run");
|
|
assert!(
|
|
output.status.success(),
|
|
"auth login should seed CLI auth for {web_url}\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn assert_secret_list_contains(context: &fabro_test::TestContext, expected_names: &[&str]) {
|
|
let list_output = context
|
|
.command()
|
|
.timeout(INSTALL_COMMAND_TIMEOUT)
|
|
.args(["--json", "secret", "list"])
|
|
.output()
|
|
.expect("secret list command should run");
|
|
assert!(
|
|
list_output.status.success(),
|
|
"secret list should succeed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&list_output.stdout),
|
|
String::from_utf8_lossy(&list_output.stderr)
|
|
);
|
|
let secrets: serde_json::Value =
|
|
serde_json::from_slice(&list_output.stdout).expect("secret list JSON should parse");
|
|
let array = secrets
|
|
.as_array()
|
|
.expect("secret list should return an array");
|
|
for expected_name in expected_names {
|
|
assert!(
|
|
array.iter().any(|secret| secret["name"] == *expected_name),
|
|
"secret list should include {expected_name}: {secrets}"
|
|
);
|
|
}
|
|
}
|