Add an interactive first-run setup wizard to the name authority
A complement to the existing FLOONET_* env config, never a replacement. When FLOONET_DOMAIN is already set (docker compose and the systemd EnvironmentFile both set it) the binary runs headless exactly as before, including validate() fail-fast. Only when nothing is configured AND stdin/stdout is an interactive TTY does it prompt for the essentials (domain, bind address, data dir, pay mode, name transfers), derive base_url and relays from the domain so validate() always passes, write a conventional env file, load it, and continue starting up. A non-TTY with no config stays headless. The env file is written where the service convention consumes it: FLOONET_ENV_FILE if set, else /etc/floonet-authority.env when writable (the systemd default), else ./.env (the docker-compose default). On startup the binary also loads the first existing candidate non-overriding, so a wizard-written file is picked up on this run and subsequent runs while the real systemd/docker environment always wins. Tests in tests/setup.rs cover the headless guarantee (config present never runs the wizard), the prompt flow, env-file rendering/parsing, and non-overriding load.
This commit is contained in:
@@ -20,6 +20,7 @@ pub mod node;
|
||||
pub mod paid;
|
||||
pub mod proof;
|
||||
pub mod ratelimit;
|
||||
pub mod setup;
|
||||
pub mod util;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// POST /api/v1/transfer/claim. Strictly non-custodial, no GoblinPay
|
||||
// involvement. See the README and docs-notes/name-transfer-spec.md.
|
||||
|
||||
use floonet_name_authority::{handlers, App, Config};
|
||||
use floonet_name_authority::{handlers, setup, App, Config};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -29,6 +29,31 @@ async fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
// Pick up a conventional env file if one exists (non-overriding), so a
|
||||
// direct run reuses prior configuration. Under systemd/docker the real
|
||||
// environment is already set, so this changes nothing there.
|
||||
if let Some(path) = setup::load_first_existing() {
|
||||
tracing::info!("loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
// First-run wizard: only when nothing is configured AND we are on a TTY.
|
||||
// Existing deploys always set FLOONET_DOMAIN (compose/systemd) and are not
|
||||
// interactive, so they never reach this branch.
|
||||
if setup::decide_wizard(setup::config_present(), setup::is_interactive()) {
|
||||
match setup::run_first_run_wizard() {
|
||||
Ok(path) => {
|
||||
if let Err(e) = setup::load_env_file(&path) {
|
||||
eprintln!("could not load the file just written ({}): {e}", path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("setup wizard failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = match Config::from_env() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// First-run interactive setup wizard, a complement to the env-var config.
|
||||
//
|
||||
// The authority is normally configured entirely through FLOONET_* environment
|
||||
// variables (see config.rs and .env.example): docker compose injects them and
|
||||
// the systemd unit loads them from an EnvironmentFile, so those deploys are
|
||||
// fully headless and this module never runs. But an operator who just runs the
|
||||
// binary by hand, with nothing configured, would otherwise start on the
|
||||
// `floonet.example` placeholders. For that case only, when stdin/stdout is an
|
||||
// interactive terminal, we prompt for the handful of essentials, write a
|
||||
// conventional env file, load it, and continue starting up.
|
||||
//
|
||||
// The rules, in order:
|
||||
// * FLOONET_DOMAIN already set (real deploy) -> no wizard, headless as today.
|
||||
// * nothing set AND an interactive TTY -> guided wizard.
|
||||
// * nothing set AND no TTY -> no wizard, headless as today.
|
||||
//
|
||||
// The env file we write is the same file the service convention consumes: the
|
||||
// path in FLOONET_ENV_FILE if set, else /etc/floonet-authority.env when we can
|
||||
// write it (the systemd EnvironmentFile default), else ./.env in the working
|
||||
// directory (the docker-compose default). On the next run load_first_existing()
|
||||
// finds it, sets FLOONET_DOMAIN, and the wizard stays out of the way.
|
||||
|
||||
use std::io::{self, BufRead, IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The bare-metal / systemd convention: deploy/systemd/floonet-authority.service
|
||||
/// loads this exact file via `EnvironmentFile=`.
|
||||
pub const DEFAULT_ENV_FILE: &str = "/etc/floonet-authority.env";
|
||||
|
||||
/// Env files we will load on startup, in priority order. The first one that
|
||||
/// exists wins. This spans both deployment conventions (the systemd
|
||||
/// `/etc` file and the docker-compose `./.env`) plus an explicit override.
|
||||
pub fn env_file_candidates() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(p) = std::env::var("FLOONET_ENV_FILE") {
|
||||
if !p.is_empty() {
|
||||
out.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
out.push(PathBuf::from(DEFAULT_ENV_FILE));
|
||||
out.push(PathBuf::from(".env"));
|
||||
out
|
||||
}
|
||||
|
||||
/// Load the first existing candidate env file into the process environment
|
||||
/// (non-overriding). Returns the path loaded, if any. Under systemd/docker the
|
||||
/// real environment is already populated, so this is a harmless no-op there.
|
||||
pub fn load_first_existing() -> Option<PathBuf> {
|
||||
for path in env_file_candidates() {
|
||||
if path.is_file() {
|
||||
// A parse/read hiccup on an optional file must not stop startup;
|
||||
// config validation still runs afterwards.
|
||||
let _ = load_env_file(&path);
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Is the authority already configured? True when the one variable every real
|
||||
/// deployment sets, FLOONET_DOMAIN, is present in the environment. Call this
|
||||
/// after load_first_existing() so a previously written env file counts.
|
||||
pub fn config_present() -> bool {
|
||||
std::env::var("FLOONET_DOMAIN").map(|v| !v.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Are we attached to an interactive terminal on both stdin and stdout?
|
||||
pub fn is_interactive() -> bool {
|
||||
io::stdin().is_terminal() && io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// The single decision: run the wizard only when nothing is configured AND we
|
||||
/// are interactive. Pulled out as a pure function so the headless guarantee
|
||||
/// (config present => never a wizard, regardless of TTY) is directly testable.
|
||||
pub fn decide_wizard(config_present: bool, interactive: bool) -> bool {
|
||||
!config_present && interactive
|
||||
}
|
||||
|
||||
/// Where the wizard writes its env file. Honors FLOONET_ENV_FILE, else the
|
||||
/// systemd `/etc` convention when that directory is writable (a root install),
|
||||
/// else `./.env` in the working directory (the docker-compose convention).
|
||||
pub fn wizard_target() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("FLOONET_ENV_FILE") {
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let etc = PathBuf::from(DEFAULT_ENV_FILE);
|
||||
match etc.parent() {
|
||||
Some(dir) if dir_writable(dir) => etc,
|
||||
_ => PathBuf::from(".env"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether we can create files in `dir` by writing and removing a temp
|
||||
/// file. Cheap and reliable across the root/unprivileged split, no extra deps.
|
||||
fn dir_writable(dir: &Path) -> bool {
|
||||
let probe = dir.join(format!(".floonet-setup-probe-{}", std::process::id()));
|
||||
match std::fs::File::create(&probe) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse env-file text into ordered key/value pairs. Blank lines and `#`
|
||||
/// comments are skipped, a leading `export ` is tolerated, and a single layer
|
||||
/// of matching single or double quotes around the value is stripped. Pure (no
|
||||
/// environment side effects) so it can be unit tested directly.
|
||||
pub fn parse_env_file(text: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line = line.strip_prefix("export ").unwrap_or(line);
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = unquote(value.trim());
|
||||
out.push((key.to_string(), value));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unquote(v: &str) -> String {
|
||||
let bytes = v.as_bytes();
|
||||
if v.len() >= 2
|
||||
&& ((bytes[0] == b'"' && bytes[v.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[v.len() - 1] == b'\''))
|
||||
{
|
||||
v[1..v.len() - 1].to_string()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load an env file into the process environment WITHOUT overriding variables
|
||||
/// already set. Returns how many variables it set. Non-overriding is what keeps
|
||||
/// this safe alongside systemd/docker: the real environment always wins.
|
||||
pub fn load_env_file(path: &Path) -> io::Result<usize> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut set = 0;
|
||||
for (key, value) in parse_env_file(&text) {
|
||||
if std::env::var(&key).is_err() {
|
||||
std::env::set_var(&key, &value);
|
||||
set += 1;
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// The answers the wizard collects. Kept separate from rendering so the render
|
||||
/// step is a pure, testable function.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Answers {
|
||||
pub domain: String,
|
||||
pub bind_addr: String,
|
||||
pub data_dir: String,
|
||||
pub pay_mode: String,
|
||||
pub price_grin: String,
|
||||
pub goblinpay_url: String,
|
||||
pub goblinpay_token: String,
|
||||
pub transfers: bool,
|
||||
pub grin_node_url: String,
|
||||
}
|
||||
|
||||
/// Render the answers into env-file text. Only emits the keys the answers touch,
|
||||
/// mirroring .env.example wording. base_url and relays are DERIVED from the
|
||||
/// domain so they always satisfy Config::validate() (base_url host must equal
|
||||
/// the domain), which is why the wizard does not prompt for them separately.
|
||||
pub fn render_env(a: &Answers) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("# Generated by the floonet-name-authority first-run setup wizard.\n");
|
||||
s.push_str("# Edit freely; every key maps to a FLOONET_* variable (see .env.example).\n\n");
|
||||
s.push_str(&format!("FLOONET_DOMAIN={}\n", a.domain));
|
||||
s.push_str(&format!("FLOONET_BASE_URL=https://{}\n", a.domain));
|
||||
s.push_str(&format!("FLOONET_RELAYS=wss://{}\n", a.domain));
|
||||
s.push_str(&format!("FLOONET_NAMES_BIND={}\n", a.bind_addr));
|
||||
s.push_str(&format!(
|
||||
"FLOONET_NAMES_DB={}\n",
|
||||
db_path_in(&a.data_dir)
|
||||
));
|
||||
s.push_str(&format!("FLOONET_PAY_MODE={}\n", a.pay_mode));
|
||||
match a.pay_mode.as_str() {
|
||||
"name" => s.push_str(&format!("FLOONET_NAME_PRICE_GRIN={}\n", a.price_grin)),
|
||||
"write" => s.push_str(&format!("FLOONET_WRITE_PRICE_GRIN={}\n", a.price_grin)),
|
||||
_ => {}
|
||||
}
|
||||
if a.pay_mode != "off" {
|
||||
s.push_str(&format!("GOBLINPAY_URL={}\n", a.goblinpay_url));
|
||||
s.push_str(&format!("GOBLINPAY_TOKEN={}\n", a.goblinpay_token));
|
||||
}
|
||||
if a.transfers {
|
||||
s.push_str("FLOONET_TRANSFERS=true\n");
|
||||
s.push_str(&format!("FLOONET_GRIN_NODE_URL={}\n", a.grin_node_url));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Join a data directory with the fixed `names.db` filename.
|
||||
pub fn db_path_in(dir: &str) -> String {
|
||||
Path::new(dir).join("names.db").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Drive the interactive prompts over the given reader/writer, returning the
|
||||
/// collected answers. Generic over the streams so tests can feed canned input.
|
||||
pub fn collect_answers<R: BufRead, W: Write>(
|
||||
r: &mut R,
|
||||
w: &mut W,
|
||||
) -> io::Result<Answers> {
|
||||
writeln!(w, "\nFloonet name authority: first-run setup")?;
|
||||
writeln!(w, "No configuration found. A few questions and we are running.")?;
|
||||
writeln!(w, "Press Enter to accept each [default].\n")?;
|
||||
|
||||
let domain = prompt(r, w, "Domain for names (the @domain in name@domain)", "floonet.example")?;
|
||||
let bind_addr = prompt(r, w, "HTTP bind address", "127.0.0.1:8191")?;
|
||||
let data_dir = prompt(r, w, "Data directory (SQLite database lives here)", "/var/lib/floonet-authority")?;
|
||||
|
||||
writeln!(w, "\nCharging GRIN is optional; leave as `off` for a free authority.")?;
|
||||
let pay_mode = prompt_choice(r, w, "Pay mode (off/name/write)", "off", &["off", "name", "write"])?;
|
||||
|
||||
let (mut price_grin, mut goblinpay_url, mut goblinpay_token) =
|
||||
(String::new(), String::new(), String::new());
|
||||
if pay_mode != "off" {
|
||||
let price_q = if pay_mode == "name" {
|
||||
"Price to claim a name, in GRIN"
|
||||
} else {
|
||||
"Price for write access, in GRIN"
|
||||
};
|
||||
// A paid mode with a zero price fails validation, so default to 1.
|
||||
price_grin = prompt(r, w, price_q, "1")?;
|
||||
goblinpay_url = prompt(r, w, "GoblinPay server URL", "http://127.0.0.1:8192")?;
|
||||
goblinpay_token = prompt(r, w, "GoblinPay API token", "")?;
|
||||
}
|
||||
|
||||
writeln!(w, "\nName transfers are the optional non-custodial name marketplace.")?;
|
||||
let transfers = prompt_yes_no(r, w, "Enable name transfers?", false)?;
|
||||
let mut grin_node_url = String::new();
|
||||
if transfers {
|
||||
// Required when transfers are on, or the authority refuses to start.
|
||||
grin_node_url = prompt(r, w, "Grin node foreign-API URL", "https://api.grin.money/v2/foreign")?;
|
||||
}
|
||||
|
||||
Ok(Answers {
|
||||
domain,
|
||||
bind_addr,
|
||||
data_dir,
|
||||
pay_mode,
|
||||
price_grin,
|
||||
goblinpay_url,
|
||||
goblinpay_token,
|
||||
transfers,
|
||||
grin_node_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect answers over the streams, render, and write to `target`. Returns the
|
||||
/// path written. Split from the process-env plumbing so it is fully testable.
|
||||
pub fn run_wizard<R: BufRead, W: Write>(
|
||||
r: &mut R,
|
||||
w: &mut W,
|
||||
target: &Path,
|
||||
) -> io::Result<PathBuf> {
|
||||
let answers = collect_answers(r, w)?;
|
||||
let body = render_env(&answers);
|
||||
if let Some(dir) = target.parent() {
|
||||
if !dir.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(target, body)?;
|
||||
writeln!(w, "\nWrote {}. Starting up...\n", target.display())?;
|
||||
Ok(target.to_path_buf())
|
||||
}
|
||||
|
||||
/// Convenience entry point for main: pick the conventional target, run the
|
||||
/// wizard over the real stdin/stdout, and return the file written.
|
||||
pub fn run_first_run_wizard() -> io::Result<PathBuf> {
|
||||
let target = wizard_target();
|
||||
let stdin = io::stdin();
|
||||
let mut reader = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut writer = stdout.lock();
|
||||
run_wizard(&mut reader, &mut writer, &target)
|
||||
}
|
||||
|
||||
fn prompt<R: BufRead, W: Write>(
|
||||
r: &mut R,
|
||||
w: &mut W,
|
||||
question: &str,
|
||||
default: &str,
|
||||
) -> io::Result<String> {
|
||||
if default.is_empty() {
|
||||
write!(w, "{question}: ")?;
|
||||
} else {
|
||||
write!(w, "{question} [{default}]: ")?;
|
||||
}
|
||||
w.flush()?;
|
||||
let mut line = String::new();
|
||||
// EOF (0 bytes) falls back to the default rather than looping forever.
|
||||
if r.read_line(&mut line)? == 0 {
|
||||
return Ok(default.to_string());
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_choice<R: BufRead, W: Write>(
|
||||
r: &mut R,
|
||||
w: &mut W,
|
||||
question: &str,
|
||||
default: &str,
|
||||
choices: &[&str],
|
||||
) -> io::Result<String> {
|
||||
// Re-ask a bounded number of times on an unrecognized answer, then accept
|
||||
// the default so a piped/misused stream can never wedge startup.
|
||||
for _ in 0..5 {
|
||||
let answer = prompt(r, w, question, default)?;
|
||||
let lower = answer.to_lowercase();
|
||||
if choices.contains(&lower.as_str()) {
|
||||
return Ok(lower);
|
||||
}
|
||||
writeln!(w, " Please answer one of: {}", choices.join(", "))?;
|
||||
}
|
||||
Ok(default.to_string())
|
||||
}
|
||||
|
||||
fn prompt_yes_no<R: BufRead, W: Write>(
|
||||
r: &mut R,
|
||||
w: &mut W,
|
||||
question: &str,
|
||||
default: bool,
|
||||
) -> io::Result<bool> {
|
||||
let shown = if default { "Y/n" } else { "y/N" };
|
||||
let answer = prompt(r, w, question, shown)?;
|
||||
Ok(match answer.trim().to_lowercase().as_str() {
|
||||
"y" | "yes" => true,
|
||||
"n" | "no" => false,
|
||||
// Anything else (including the echoed default token) keeps the default.
|
||||
_ => default,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Tests for the first-run setup wizard (src/setup.rs). The wizard is a
|
||||
// COMPLEMENT to the env-var config: it must never fire for a configured
|
||||
// (headless) deploy, and when it does fire it must write an env file whose
|
||||
// values satisfy Config::validate(). These tests avoid mutating the shared
|
||||
// process environment except through uniquely named keys, so they are safe to
|
||||
// run in parallel with the rest of the suite.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use floonet_name_authority::setup::{
|
||||
self, collect_answers, db_path_in, load_env_file, parse_env_file, render_env, run_wizard,
|
||||
Answers,
|
||||
};
|
||||
|
||||
/// The headless guarantee: whenever configuration is present, the wizard is
|
||||
/// never chosen, regardless of whether we are on a TTY. This is the core
|
||||
/// "config-present means the wizard never triggers" test.
|
||||
#[test]
|
||||
fn config_present_never_runs_wizard() {
|
||||
assert!(!setup::decide_wizard(true, true), "present + tty must skip wizard");
|
||||
assert!(!setup::decide_wizard(true, false), "present + no tty must skip wizard");
|
||||
}
|
||||
|
||||
/// The only case that runs the wizard: nothing configured AND an interactive
|
||||
/// terminal. A non-TTY with no config stays headless (today's behavior).
|
||||
#[test]
|
||||
fn wizard_only_when_absent_and_interactive() {
|
||||
assert!(setup::decide_wizard(false, true), "absent + tty runs wizard");
|
||||
assert!(!setup::decide_wizard(false, false), "absent + no tty stays headless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_env_file_handles_comments_quotes_and_export() {
|
||||
let text = "\
|
||||
# a comment
|
||||
FLOONET_DOMAIN=names.example
|
||||
|
||||
export FLOONET_BASE_URL=https://names.example
|
||||
QUOTED=\"a value\"
|
||||
SINGLE='b value'
|
||||
# trailing comment
|
||||
BLANKVAL=
|
||||
=nokey
|
||||
";
|
||||
let pairs = parse_env_file(text);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![
|
||||
("FLOONET_DOMAIN".to_string(), "names.example".to_string()),
|
||||
("FLOONET_BASE_URL".to_string(), "https://names.example".to_string()),
|
||||
("QUOTED".to_string(), "a value".to_string()),
|
||||
("SINGLE".to_string(), "b value".to_string()),
|
||||
("BLANKVAL".to_string(), "".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_env_derives_base_url_and_relays_from_domain() {
|
||||
let a = Answers {
|
||||
domain: "names.example".into(),
|
||||
bind_addr: "127.0.0.1:8191".into(),
|
||||
data_dir: "/var/lib/floonet-authority".into(),
|
||||
pay_mode: "off".into(),
|
||||
price_grin: String::new(),
|
||||
goblinpay_url: String::new(),
|
||||
goblinpay_token: String::new(),
|
||||
transfers: false,
|
||||
grin_node_url: String::new(),
|
||||
};
|
||||
let body = render_env(&a);
|
||||
// base_url and relays are derived so the validate() host==domain invariant
|
||||
// always holds; off-mode emits no price or goblinpay keys.
|
||||
assert!(body.contains("FLOONET_DOMAIN=names.example\n"));
|
||||
assert!(body.contains("FLOONET_BASE_URL=https://names.example\n"));
|
||||
assert!(body.contains("FLOONET_RELAYS=wss://names.example\n"));
|
||||
assert!(body.contains("FLOONET_NAMES_DB=/var/lib/floonet-authority/names.db\n"));
|
||||
assert!(body.contains("FLOONET_PAY_MODE=off\n"));
|
||||
assert!(!body.contains("GOBLINPAY_URL"));
|
||||
assert!(!body.contains("FLOONET_TRANSFERS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_env_paid_name_and_transfers() {
|
||||
let a = Answers {
|
||||
domain: "names.example".into(),
|
||||
bind_addr: "127.0.0.1:8191".into(),
|
||||
data_dir: "/data".into(),
|
||||
pay_mode: "name".into(),
|
||||
price_grin: "1".into(),
|
||||
goblinpay_url: "http://127.0.0.1:8192".into(),
|
||||
goblinpay_token: "tok".into(),
|
||||
transfers: true,
|
||||
grin_node_url: "https://api.grin.money/v2/foreign".into(),
|
||||
};
|
||||
let body = render_env(&a);
|
||||
assert!(body.contains("FLOONET_PAY_MODE=name\n"));
|
||||
assert!(body.contains("FLOONET_NAME_PRICE_GRIN=1\n"));
|
||||
assert!(body.contains("GOBLINPAY_URL=http://127.0.0.1:8192\n"));
|
||||
assert!(body.contains("GOBLINPAY_TOKEN=tok\n"));
|
||||
assert!(body.contains("FLOONET_TRANSFERS=true\n"));
|
||||
assert!(body.contains("FLOONET_GRIN_NODE_URL=https://api.grin.money/v2/foreign\n"));
|
||||
// write-only key must not leak into a name-mode file.
|
||||
assert!(!body.contains("FLOONET_WRITE_PRICE_GRIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_joins_names_db() {
|
||||
assert_eq!(db_path_in("/var/lib/floonet-authority"), "/var/lib/floonet-authority/names.db");
|
||||
}
|
||||
|
||||
/// Drive the interactive prompts with canned input over in-memory streams and
|
||||
/// confirm the collected answers reflect the mix of typed values and defaults.
|
||||
#[test]
|
||||
fn collect_answers_uses_input_then_defaults() {
|
||||
// domain typed; bind + data-dir Enter (defaults); pay mode off; transfers no.
|
||||
let input = b"names.example\n\n\noff\nn\n";
|
||||
let mut reader = Cursor::new(&input[..]);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let a = collect_answers(&mut reader, &mut out).expect("wizard prompts");
|
||||
assert_eq!(a.domain, "names.example");
|
||||
assert_eq!(a.bind_addr, "127.0.0.1:8191");
|
||||
assert_eq!(a.data_dir, "/var/lib/floonet-authority");
|
||||
assert_eq!(a.pay_mode, "off");
|
||||
assert!(!a.transfers);
|
||||
}
|
||||
|
||||
/// End to end over the file: run the wizard with canned input into a temp path,
|
||||
/// then parse it back and confirm it is a config-present file (FLOONET_DOMAIN
|
||||
/// set) with the derived, validate()-satisfying base_url/relays.
|
||||
#[test]
|
||||
fn run_wizard_writes_loadable_env_file() {
|
||||
let input = b"names.example\n0.0.0.0:9000\n/tmp/floonet-data\noff\nn\n";
|
||||
let mut reader = Cursor::new(&input[..]);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
||||
let target: PathBuf =
|
||||
std::env::temp_dir().join(format!("floonet-setup-test-{}.env", std::process::id()));
|
||||
let written = run_wizard(&mut reader, &mut out, &target).expect("write env file");
|
||||
assert_eq!(written, target);
|
||||
|
||||
let text = std::fs::read_to_string(&target).expect("read back");
|
||||
let pairs = parse_env_file(&text);
|
||||
let get = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
|
||||
|
||||
assert_eq!(get("FLOONET_DOMAIN").as_deref(), Some("names.example"));
|
||||
assert_eq!(get("FLOONET_BASE_URL").as_deref(), Some("https://names.example"));
|
||||
assert_eq!(get("FLOONET_RELAYS").as_deref(), Some("wss://names.example"));
|
||||
assert_eq!(get("FLOONET_NAMES_BIND").as_deref(), Some("0.0.0.0:9000"));
|
||||
assert_eq!(get("FLOONET_NAMES_DB").as_deref(), Some("/tmp/floonet-data/names.db"));
|
||||
|
||||
let _ = std::fs::remove_file(&target);
|
||||
}
|
||||
|
||||
/// load_env_file is non-overriding: it fills variables that are unset but never
|
||||
/// clobbers an already-set one (this is what keeps it safe next to the real
|
||||
/// systemd/docker environment). Uses uniquely named keys to avoid touching
|
||||
/// FLOONET_* and racing other tests.
|
||||
#[test]
|
||||
fn load_env_file_is_non_overriding() {
|
||||
let pid = std::process::id();
|
||||
let unset_key = format!("FLOONET_SETUP_TEST_UNSET_{pid}");
|
||||
let preset_key = format!("FLOONET_SETUP_TEST_PRESET_{pid}");
|
||||
|
||||
std::env::set_var(&preset_key, "original");
|
||||
|
||||
let target = std::env::temp_dir().join(format!("floonet-load-test-{pid}.env"));
|
||||
std::fs::write(
|
||||
&target,
|
||||
format!("{unset_key}=from_file\n{preset_key}=from_file\n"),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let set = load_env_file(&target).expect("load");
|
||||
assert_eq!(set, 1, "only the previously unset var should be set");
|
||||
assert_eq!(std::env::var(&unset_key).unwrap(), "from_file");
|
||||
assert_eq!(std::env::var(&preset_key).unwrap(), "original", "preset must not be overridden");
|
||||
|
||||
let _ = std::fs::remove_file(&target);
|
||||
std::env::remove_var(&unset_key);
|
||||
std::env::remove_var(&preset_key);
|
||||
}
|
||||
Reference in New Issue
Block a user