fabro/lib/crates/fabro-cli/tests/it/cmd/install.rs
fabro-sh-0530[bot] e8f0aceee8
refactor: rationalize server secret scopes (vault-only for optional int… (#401)
## Summary

Separates Fabro server secrets into two explicit scopes: **bootstrap**
secrets that come from process env or `server.env`, and **optional
integration** secrets that come exclusively from the vault. This makes
secret resolution simple and predictable, and removes all `process env →
server.env` fallback paths for optional integrations such as GitHub App,
Slack, Daytona, Brave Search, and LLM provider keys.

## What changed

**New `ToolSecrets` struct in `fabro-agent`** — Brave Search API key is
now passed explicitly through `SessionOptions.tool_secrets` rather than
read from process env inside the tool. The standalone CLI reads the key
at the CLI boundary (with an explicit
`#[expect(clippy::disallowed_methods)]` annotation); the server will
read it from the vault. The error message changes from
`"BRAVE_SEARCH_API_KEY environment variable is not set"` to
`"BRAVE_SEARCH_API_KEY is not configured"`.

**`VaultCredentialSource::vault_only` constructor in `fabro-auth`** —
Adds a constructor that passes `|_| None` as the env lookup, ensuring
the server LLM credential source never resolves provider keys from
process env.

**GitHub App secrets move to vault in install flows** — Both the CLI
`fabro install github` path and the browser install finish handler now
write `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, and
`GITHUB_APP_WEBHOOK_SECRET` to the vault instead of `server.env`.
Switching strategies removes stale secrets from the other strategy's
storage location. The `vault_set` field type changes from `Vec<(String,
String)>` to `Vec<VaultSecretWrite>` to carry per-secret type metadata
(file vs. token).

**`fabro-vault` gains a `fabro-static` dependency** — Needed so the
vault crate can reference canonical env-var names from the shared
registry without a cycle.

**`GH_TOKEN` fallback removed** — `GITHUB_TOKEN` is now read from the
vault only; the changelog and `server-configuration.mdx` note drops
mention of `GH_TOKEN` as an accepted fallback.

**Version bump** — Workspace crates promoted from `0.244.0-nightly.0` to
`0.244.0`.

**Docs** — Internal strategy doc, public admin docs (Docker, Railway,
server-configuration, security, troubleshooting), and integration docs
(GitHub, Slack, Daytona, Brave Search, LiteLLM, tools reference, models)
all updated to reflect vault-only optional secrets and direct users to
`fabro secret set` rather than process env or `server.env`.

### Plan Summary

- **Task 1** (secret registry) — not yet present in this diff;
classification lives in the places that consume it.
- **Task 3–6** (vault-only lookups for GitHub, Slack, Daytona, LLM) —
implemented via `vault_only` constructor, `tool_secrets` threading, and
install-path changes.
- **Task 7** (Brave Search explicit injection) — `ToolSecrets`,
`register_core_tools` wiring, CLI boundary read.
- **Task 8** (install persistence) — GitHub App secrets written to
vault; token strategy writes `GITHUB_TOKEN` to vault and clears app
vault keys; app strategy clears `GITHUB_TOKEN` vault key.
- **Task 9** (docs) — all public and internal docs updated.


### Fabro Details

<details>
<summary>Ran 0 stages in 155m 26s for $60.85</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| **Total** | **155m 26s** | **$60.85** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-05-25 17:26:01 -04:00

919 lines
30 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);
#[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 existing_web_url = unused_loopback_web_url();
let requested_web_url = unused_loopback_web_url();
write_http_install_settings(&context, &storage_dir, &existing_web_url, "keep-me");
login_with_storage_dev_token(&context, &storage_dir, &existing_web_url);
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())
);
assert_eq!(
parsed
.get("cli")
.and_then(toml::Value::as_table)
.and_then(|cli| cli.get("target"))
.and_then(toml::Value::as_table)
.and_then(|target| target.get("url"))
.and_then(toml::Value::as_str),
Some(existing_web_url.as_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"));
}
#[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 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 = Vault::load(Storage::new(&storage_dir).secrets_path()).unwrap();
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 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}"
);
}
}