Skip to main content

ch_rds/dev/
btcpay.rs

1//! Local BTCPay Server bootstrap helpers for development runners.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::env;
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result, anyhow, bail, ensure};
12use base64::Engine;
13use reqwest::StatusCode;
14use reqwest::blocking::{Client, RequestBuilder};
15use serde::Deserialize;
16use serde_json::{Value, json};
17use uuid::Uuid;
18
19const BTCPAY_LATEST_STABLE_RELEASE_URL: &str =
20    "https://api.github.com/repos/btcpayserver/btcpayserver/releases/latest";
21const BTCPAY_STABLE_FALLBACK_IMAGE: &str = "btcpayserver/btcpayserver:2.4.3";
22
23#[derive(Deserialize)]
24struct GithubRelease {
25    tag_name: String,
26    draft: bool,
27    prerelease: bool,
28}
29
30/// Project-owned metadata used by the local BTCPay development adapter.
31#[derive(Clone, Eq, PartialEq)]
32pub struct BtcpayDevProfile {
33    display_name: String,
34    store_name: String,
35    api_key_label: String,
36    rate_source: String,
37    webhook_path: String,
38    default_admin_email: String,
39    public_url: String,
40    local_docker: LocalDockerConfig,
41    default_admin_password: Option<String>,
42    default_payment_signing_key: Option<String>,
43}
44
45impl BtcpayDevProfile {
46    /// Creates a local BTCPay profile with conservative development defaults.
47    pub fn new(
48        display_name: impl Into<String>,
49        default_admin_email: impl Into<String>,
50        public_url: impl Into<String>,
51    ) -> Self {
52        let display_name = display_name.into();
53        Self {
54            store_name: display_name.clone(),
55            api_key_label: format!("{display_name} local dev"),
56            rate_source: "kraken".to_owned(),
57            webhook_path: "/api/payments/webhooks/btcpay".to_owned(),
58            default_admin_email: default_admin_email.into(),
59            public_url: public_url.into(),
60            local_docker: LocalDockerConfig::new(display_name.to_ascii_lowercase()),
61            display_name,
62            default_admin_password: None,
63            default_payment_signing_key: None,
64        }
65    }
66
67    /// Overrides the local reverse-proxy ports.
68    pub fn with_local_ports(mut self, http: u16, https: u16) -> Self {
69        self.local_docker.reverseproxy_http_port = http;
70        self.local_docker.reverseproxy_https_port = https;
71        self
72    }
73
74    /// Overrides the generated BTCPay Compose project name.
75    pub fn with_compose_project_name(mut self, name: impl Into<String>) -> Self {
76        self.local_docker.compose_project_name = name.into();
77        self
78    }
79
80    /// Overrides the BTCPay webhook path used by the application.
81    pub fn with_webhook_path(mut self, path: impl Into<String>) -> Self {
82        self.webhook_path = path.into();
83        self
84    }
85
86    pub(super) fn validate(&self) -> Result<()> {
87        for (field, value) in [
88            ("display_name", self.display_name.as_str()),
89            ("store_name", self.store_name.as_str()),
90            ("api_key_label", self.api_key_label.as_str()),
91            ("rate_source", self.rate_source.as_str()),
92            ("default_admin_email", self.default_admin_email.as_str()),
93            ("public_url", self.public_url.as_str()),
94        ] {
95            ensure!(
96                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
97                "payment profile {field} must be a non-empty single-line value"
98            );
99        }
100        ensure!(
101            self.webhook_path.starts_with('/')
102                && !self.webhook_path.chars().any(char::is_whitespace),
103            "payment profile webhook_path must be an absolute path without whitespace"
104        );
105        let public_url = url::Url::parse(&self.public_url)
106            .context("payment profile public_url must be an absolute URL")?;
107        ensure!(
108            matches!(public_url.scheme(), "http" | "https")
109                && public_url.host_str().is_some()
110                && public_url.username().is_empty()
111                && public_url.password().is_none(),
112            "payment profile public_url must be an HTTP(S) URL without credentials"
113        );
114        self.local_docker.validate()?;
115        Ok(())
116    }
117
118    pub(super) fn display_name(&self) -> &str {
119        &self.display_name
120    }
121}
122
123#[derive(Clone, Debug)]
124pub(super) struct LocalBtcpayBootstrapOptions {
125    profile: BtcpayDevProfile,
126    project_root: PathBuf,
127    env_file: PathBuf,
128}
129
130impl LocalBtcpayBootstrapOptions {
131    pub(super) fn new(profile: BtcpayDevProfile, project_root: impl Into<PathBuf>) -> Self {
132        let project_root = project_root.into();
133        Self {
134            profile,
135            env_file: project_root.join(".env"),
136            project_root,
137        }
138    }
139}
140
141fn btcpay_checkout_root(project_root: &Path) -> Result<PathBuf> {
142    let checkout_root = project_root
143        .parent()
144        .ok_or_else(|| anyhow!("app checkout has no parent directory"))?;
145    Ok(checkout_root.join("third-party").join("btcpay"))
146}
147
148impl fmt::Debug for BtcpayDevProfile {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter
151            .debug_struct("BtcpayDevProfile")
152            .field("display_name", &self.display_name)
153            .field("store_name", &self.store_name)
154            .field("api_key_label", &self.api_key_label)
155            .field("rate_source", &self.rate_source)
156            .field("webhook_path", &self.webhook_path)
157            .field("default_admin_email", &self.default_admin_email)
158            .field("public_url", &self.public_url)
159            .field("local_docker", &self.local_docker)
160            .field(
161                "default_admin_password",
162                &self.default_admin_password.as_ref().map(|_| "[REDACTED]"),
163            )
164            .field(
165                "default_payment_signing_key",
166                &self
167                    .default_payment_signing_key
168                    .as_ref()
169                    .map(|_| "[REDACTED]"),
170            )
171            .finish()
172    }
173}
174
175#[derive(Clone, Debug)]
176pub(super) struct LocalBtcpayBootstrapSummary {
177    /// Human-facing app name.
178    pub app_display_name: String,
179    /// Local BTCPay base URL.
180    pub url: String,
181    /// Store id written to the app `.env`.
182    pub store_id: String,
183    /// Masked API key for logs.
184    pub api_key_masked: String,
185    /// Masked webhook secret for logs.
186    pub webhook_secret_masked: String,
187    /// App `.env` path updated by the bootstrap.
188    pub env_file: PathBuf,
189}
190
191pub(super) fn bootstrap_local_btcpay(
192    options: &LocalBtcpayBootstrapOptions,
193) -> Result<LocalBtcpayBootstrapSummary> {
194    let profile = &options.profile;
195    let btcpay_root = btcpay_checkout_root(&options.project_root)?;
196    ensure!(
197        btcpay_root.is_dir(),
198        "BTCPay checkout not found at {}",
199        btcpay_root.display()
200    );
201    ensure!(
202        options.project_root.join("Cargo.toml").is_file(),
203        "app checkout not found at {}",
204        options.project_root.display()
205    );
206    let mut app_env = EnvFile::read(&options.env_file)?;
207    let public_url = app_env
208        .effective_value("PUBLIC_URL")
209        .filter(|value| !value.trim().is_empty())
210        .unwrap_or_else(|| profile.public_url.clone());
211
212    let docker_env = docker_env_values(&profile.local_docker, &app_env)?;
213    write_btcpay_env(&btcpay_root.join(".env"), &docker_env)?;
214    run_build_script(&btcpay_root, &docker_env)?;
215    patch_local_btcpay_compose(&btcpay_root, &docker_env)?;
216    docker_compose_up(&btcpay_root, &docker_env)?;
217
218    let url = format!(
219        "http://{}:{}",
220        profile.local_docker.host, profile.local_docker.reverseproxy_http_port
221    );
222    let admin_email = app_env
223        .effective_value("LOCAL_BTCPAY_ADMIN_EMAIL")
224        .filter(|value| !value.trim().is_empty())
225        .unwrap_or_else(|| profile.default_admin_email.to_string());
226    let admin_password_key = "LOCAL_BTCPAY_ADMIN_PASSWORD";
227    let admin_password = app_env
228        .effective_value(admin_password_key)
229        .filter(|value| !value.trim().is_empty())
230        .or_else(|| {
231            profile
232                .default_admin_password
233                .as_deref()
234                .map(str::trim)
235                .filter(|value| !value.is_empty())
236                .map(ToOwned::to_owned)
237        })
238        .unwrap_or_else(generate_local_secret);
239    app_env.set(admin_password_key, admin_password.as_str());
240    app_env.write(&options.env_file)?;
241
242    let client = BtcpayClient::new(profile.clone(), url.clone(), admin_email, admin_password)?;
243    client.wait_until_ready()?;
244    let store_id_key = "BTCPAY_STORE_ID";
245    let api_key_key = "BTCPAY_API_KEY";
246    let webhook_secret_key = "BTCPAY_WEBHOOK_SECRET";
247    let current_store_id = app_env
248        .effective_value(store_id_key)
249        .filter(|value| !is_placeholder_value(value));
250    let current_api_key = app_env.effective_value(api_key_key);
251    let reusable_store_access = match (current_store_id.as_deref(), current_api_key.as_deref()) {
252        (Some(store_id), Some(api_key)) if !is_placeholder_value(api_key) => {
253            client.api_key_has_permissions(api_key, &required_api_permissions(store_id))?
254        }
255        _ => false,
256    };
257    let (store_id, api_key) = if reusable_store_access {
258        (
259            current_store_id.expect("validated reusable store id"),
260            current_api_key.expect("validated reusable API key"),
261        )
262    } else {
263        client.ensure_admin_user()?;
264        let store_id = client.ensure_store(&public_url)?;
265        let api_key = client.ensure_api_key(&store_id, current_api_key.as_deref())?;
266        (store_id, api_key)
267    };
268    client.ensure_onchain_wallet(&store_id, &api_key)?;
269    client.ensure_rate_settings(&store_id, &api_key)?;
270    let current_webhook_secret = app_env.effective_value(webhook_secret_key);
271    let webhook_secret = client.ensure_webhook(
272        &store_id,
273        &public_url,
274        &api_key,
275        current_webhook_secret.as_deref(),
276    )?;
277
278    app_env.set(store_id_key, store_id.as_str());
279    app_env.set(api_key_key, api_key.as_str());
280    app_env.set(webhook_secret_key, webhook_secret.as_str());
281    let signing_key = app_env
282        .effective_value("PAYMENT_SIGNING_KEY")
283        .filter(|value| !value.trim().is_empty())
284        .or_else(|| {
285            profile
286                .default_payment_signing_key
287                .as_deref()
288                .map(str::trim)
289                .filter(|value| !value.is_empty())
290                .map(ToOwned::to_owned)
291        })
292        .unwrap_or_else(generate_local_secret);
293    app_env.set("PAYMENT_SIGNING_KEY", signing_key.as_str());
294    app_env.write(&options.env_file)?;
295
296    Ok(LocalBtcpayBootstrapSummary {
297        app_display_name: profile.display_name().to_string(),
298        url,
299        store_id,
300        api_key_masked: mask_secret(&api_key),
301        webhook_secret_masked: mask_secret(&webhook_secret),
302        env_file: options.env_file.clone(),
303    })
304}
305
306fn generate_local_secret() -> String {
307    format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
308}
309
310#[derive(Clone, Debug, Eq, PartialEq)]
311struct LocalDockerConfig {
312    host: String,
313    reverseproxy_http_port: u16,
314    reverseproxy_https_port: u16,
315    nbitcoin_network: String,
316    btcpaygen_crypto1: String,
317    btcpaygen_reverseproxy: String,
318    btcpaygen_subname: String,
319    compose_project_name: String,
320}
321
322impl LocalDockerConfig {
323    fn new(compose_project_name: String) -> Self {
324        Self {
325            host: "127.0.0.1".to_owned(),
326            reverseproxy_http_port: 23_005,
327            reverseproxy_https_port: 23_006,
328            nbitcoin_network: "regtest".to_owned(),
329            btcpaygen_crypto1: "btc".to_owned(),
330            btcpaygen_reverseproxy: "nginx".to_owned(),
331            btcpaygen_subname: "btcpay".to_owned(),
332            compose_project_name,
333        }
334    }
335
336    fn validate(&self) -> Result<()> {
337        for (field, value) in [
338            ("host", self.host.as_str()),
339            ("nbitcoin_network", self.nbitcoin_network.as_str()),
340            ("btcpaygen_crypto1", self.btcpaygen_crypto1.as_str()),
341            (
342                "btcpaygen_reverseproxy",
343                self.btcpaygen_reverseproxy.as_str(),
344            ),
345            ("btcpaygen_subname", self.btcpaygen_subname.as_str()),
346            ("compose_project_name", self.compose_project_name.as_str()),
347        ] {
348            ensure!(
349                !value.is_empty() && value.trim() == value && !value.chars().any(char::is_control),
350                "payment profile local_docker.{field} must be a non-empty single-line value"
351            );
352        }
353        ensure!(
354            self.reverseproxy_http_port > 0,
355            "payment profile local_docker.reverseproxy_http_port must be greater than zero"
356        );
357        ensure!(
358            self.reverseproxy_https_port > 0,
359            "payment profile local_docker.reverseproxy_https_port must be greater than zero"
360        );
361        Ok(())
362    }
363}
364
365#[derive(Clone, Debug, Default)]
366struct EnvFile {
367    lines: Vec<String>,
368    values: BTreeMap<String, String>,
369}
370
371impl EnvFile {
372    fn read(path: &Path) -> Result<Self> {
373        if !path.exists() {
374            return Ok(Self {
375                lines: vec![
376                    "# Local payment secrets and machine-specific BTCPay values.".to_string(),
377                    "# Local dev bootstrap is managed by ch-rds.".to_string(),
378                    String::new(),
379                ],
380                values: BTreeMap::new(),
381            });
382        }
383
384        let text = fs::read_to_string(path)
385            .with_context(|| format!("failed to read {}", path.display()))?;
386        let mut values = BTreeMap::new();
387        let lines = text
388            .lines()
389            .map(|line| {
390                if let Some((key, value)) = parse_env_line(line) {
391                    values.insert(key.to_string(), value.to_string());
392                }
393                line.to_string()
394            })
395            .collect();
396        Ok(Self { lines, values })
397    }
398
399    fn get(&self, key: &str) -> Option<&str> {
400        self.values.get(key).map(String::as_str)
401    }
402
403    fn effective_value(&self, key: &str) -> Option<String> {
404        env::var(key)
405            .ok()
406            .filter(|value| !value.trim().is_empty())
407            .or_else(|| self.get(key).map(str::to_string))
408    }
409
410    fn set(&mut self, key: &str, value: &str) {
411        self.values.insert(key.to_string(), value.to_string());
412    }
413
414    fn write(&self, path: &Path) -> Result<()> {
415        let mut seen = BTreeSet::new();
416        let mut output = Vec::new();
417        for line in &self.lines {
418            if let Some((key, _)) = parse_env_line(line)
419                && let Some(value) = self.values.get(key)
420            {
421                output.push(format!("{key}={value}"));
422                seen.insert(key.to_string());
423                continue;
424            }
425            output.push(line.clone());
426        }
427        for (key, value) in &self.values {
428            if seen.contains(key) {
429                continue;
430            }
431            if output.last().is_some_and(|line| !line.is_empty()) {
432                output.push(String::new());
433            }
434            output.push(format!("{key}={value}"));
435        }
436        if let Some(parent) = path.parent() {
437            fs::create_dir_all(parent)
438                .with_context(|| format!("failed to create {}", parent.display()))?;
439        }
440        fs::write(path, format!("{}\n", output.join("\n")))
441            .with_context(|| format!("failed to write {}", path.display()))
442    }
443}
444
445fn parse_env_line(line: &str) -> Option<(&str, &str)> {
446    let trimmed = line.trim_start();
447    if trimmed.is_empty() || trimmed.starts_with('#') {
448        return None;
449    }
450    let (key, value) = line.split_once('=')?;
451    let key = key.trim();
452    if key.is_empty() {
453        return None;
454    }
455    Some((key, value.trim()))
456}
457
458fn docker_env_values(
459    config: &LocalDockerConfig,
460    app_env: &EnvFile,
461) -> Result<BTreeMap<String, String>> {
462    let mut values = BTreeMap::new();
463    values.insert("BTCPAY_IMAGE".to_string(), resolve_btcpay_image(app_env)?);
464    values.insert("BTCPAY_HOST".to_string(), config.host.clone());
465    values.insert(
466        "REVERSEPROXY_HTTP_PORT".to_string(),
467        config.reverseproxy_http_port.to_string(),
468    );
469    values.insert(
470        "REVERSEPROXY_HTTPS_PORT".to_string(),
471        config.reverseproxy_https_port.to_string(),
472    );
473    values.insert(
474        "NBITCOIN_NETWORK".to_string(),
475        config.nbitcoin_network.clone(),
476    );
477    values.insert(
478        "BTCPAYGEN_CRYPTO1".to_string(),
479        config.btcpaygen_crypto1.clone(),
480    );
481    values.insert(
482        "BTCPAYGEN_REVERSEPROXY".to_string(),
483        config.btcpaygen_reverseproxy.clone(),
484    );
485    values.insert(
486        "BTCPAYGEN_SUBNAME".to_string(),
487        config.btcpaygen_subname.clone(),
488    );
489    values.insert(
490        "COMPOSE_PROJECT_NAME".to_string(),
491        config.compose_project_name.clone(),
492    );
493    values.insert(
494        "BTCPAY_ADDITIONAL_HOSTS".to_string(),
495        local_btcpay_additional_hosts(
496            config.host.as_str(),
497            app_env
498                .effective_value("BTCPAY_ADDITIONAL_HOSTS")
499                .as_deref()
500                .unwrap_or_default(),
501        ),
502    );
503    for key in [
504        "BTCPAY_SSHAUTHORIZEDKEYS",
505        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
506        "BTCPAY_SSHKEYFILE",
507    ] {
508        values.insert(
509            key.to_string(),
510            app_env.effective_value(key).unwrap_or_default(),
511        );
512    }
513    Ok(values)
514}
515
516fn resolve_btcpay_image(app_env: &EnvFile) -> Result<String> {
517    if let Some(image) = app_env.effective_value("BTCPAY_IMAGE") {
518        ensure!(
519            stable_btcpay_version_from_image(&image).is_some(),
520            "BTCPAY_IMAGE must use an official stable image such as btcpayserver/btcpayserver:2.4.3; release candidates are not allowed"
521        );
522        return Ok(image);
523    }
524
525    let client = Client::builder()
526        .timeout(Duration::from_secs(10))
527        .user_agent(concat!("ch-rds/", env!("CARGO_PKG_VERSION")))
528        .build()
529        .context("failed to create BTCPay release client")?;
530    let resolved = client
531        .get(BTCPAY_LATEST_STABLE_RELEASE_URL)
532        .send()
533        .and_then(reqwest::blocking::Response::error_for_status)
534        .and_then(reqwest::blocking::Response::json::<GithubRelease>)
535        .ok()
536        .and_then(|release| stable_btcpay_image_from_release(&release));
537
538    if let Some(image) = resolved {
539        return Ok(image);
540    }
541
542    tracing::warn!(
543        fallback_image = BTCPAY_STABLE_FALLBACK_IMAGE,
544        "Could not resolve the latest stable BTCPay release; using the pinned stable fallback"
545    );
546    Ok(BTCPAY_STABLE_FALLBACK_IMAGE.to_string())
547}
548
549fn stable_btcpay_image_from_release(release: &GithubRelease) -> Option<String> {
550    if release.draft || release.prerelease {
551        return None;
552    }
553    let version = release.tag_name.strip_prefix('v')?;
554    stable_btcpay_version(version).then(|| format!("btcpayserver/btcpayserver:{version}"))
555}
556
557fn stable_btcpay_version_from_image(image: &str) -> Option<&str> {
558    let version = image.strip_prefix("btcpayserver/btcpayserver:")?;
559    stable_btcpay_version(version).then_some(version)
560}
561
562fn stable_btcpay_version(version: &str) -> bool {
563    let mut components = version.split('.');
564    let valid_component = |component: &str| {
565        !component.is_empty()
566            && component.bytes().all(|byte| byte.is_ascii_digit())
567            && (component == "0" || !component.starts_with('0'))
568    };
569    let Some(major) = components.next() else {
570        return false;
571    };
572    let Some(minor) = components.next() else {
573        return false;
574    };
575    let Some(patch) = components.next() else {
576        return false;
577    };
578    components.next().is_none()
579        && valid_component(major)
580        && valid_component(minor)
581        && valid_component(patch)
582}
583
584fn write_btcpay_env(path: &Path, values: &BTreeMap<String, String>) -> Result<()> {
585    let mut lines = Vec::new();
586    for key in [
587        "BTCPAY_IMAGE",
588        "BTCPAY_HOST",
589        "REVERSEPROXY_HTTP_PORT",
590        "REVERSEPROXY_HTTPS_PORT",
591        "NBITCOIN_NETWORK",
592        "BTCPAYGEN_CRYPTO1",
593        "BTCPAYGEN_REVERSEPROXY",
594        "BTCPAYGEN_SUBNAME",
595        "COMPOSE_PROJECT_NAME",
596        "BTCPAY_ADDITIONAL_HOSTS",
597        "BTCPAY_SSHAUTHORIZEDKEYS",
598        "BTCPAY_SSHTRUSTEDFINGERPRINTS",
599        "BTCPAY_SSHKEYFILE",
600    ] {
601        lines.push(format!(
602            "{key}={}",
603            values.get(key).map(String::as_str).unwrap_or_default()
604        ));
605    }
606    fs::write(path, format!("{}\n", lines.join("\n")))
607        .with_context(|| format!("failed to write {}", path.display()))
608}
609
610fn run_build_script(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
611    let (program, args): (&str, Vec<String>) = if cfg!(windows) {
612        (
613            "powershell",
614            vec![
615                "-NoProfile".to_string(),
616                "-ExecutionPolicy".to_string(),
617                "Bypass".to_string(),
618                "-File".to_string(),
619                btcpay_root.join("build.ps1").display().to_string(),
620            ],
621        )
622    } else {
623        (
624            "bash",
625            vec![btcpay_root.join("build.sh").display().to_string()],
626        )
627    };
628    run_command("BTCPay build step", program, &args, btcpay_root, envs)
629}
630
631fn patch_local_btcpay_compose(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
632    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
633    let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
634    let compose_file = btcpay_root
635        .join("Generated")
636        .join(format!("docker-compose.{subname}.yml"));
637    let compose = fs::read_to_string(&compose_file)
638        .with_context(|| format!("failed to read {}", compose_file.display()))?;
639    let network = required_env_value(envs, "NBITCOIN_NETWORK")?;
640    let patched = namespace_local_container_names(
641        &enable_regtest_wallet_creation(
642            &compose.replace("\n        onion=tor:9050\n", "\n        onion=0\n"),
643            network,
644        ),
645        project_name,
646    );
647
648    let patched = configure_local_webhook_host(&patched)?;
649
650    if patched != compose {
651        fs::write(&compose_file, patched)
652            .with_context(|| format!("failed to write {}", compose_file.display()))?;
653    }
654
655    Ok(())
656}
657
658fn configure_local_webhook_host(compose: &str) -> Result<String> {
659    // The upstream entrypoint overwrites host.docker.internal with the bridge
660    // gateway. Docker Desktop needs the daemon-selected host gateway instead.
661    let service = "\n  btcpayserver:\n";
662    ensure!(
663        compose.contains(service),
664        "generated compose has no BTCPay service"
665    );
666    let configured =
667        "\n  btcpayserver:\n    extra_hosts:\n      - \"payment-host.internal:host-gateway\"\n";
668    if compose.contains(configured) {
669        return Ok(compose.to_owned());
670    }
671    Ok(compose.replace(service, configured))
672}
673
674fn enable_regtest_wallet_creation(compose: &str, network: &str) -> String {
675    if network != "regtest" {
676        return compose.to_owned();
677    }
678    compose.replace("CREATE_WALLET: \"false\"", "CREATE_WALLET: \"true\"")
679}
680
681fn namespace_local_container_names(compose: &str, project_name: &str) -> String {
682    let prefix = format!("{project_name}-");
683    let namespaced = compose
684        .lines()
685        .map(|line| {
686            let content = line.trim_start();
687            let indentation = &line[..line.len() - content.len()];
688            match content.strip_prefix("container_name: ") {
689                Some(name) if !name.starts_with(&prefix) => {
690                    format!("{indentation}container_name: {prefix}{name}")
691                }
692                _ => line.to_owned(),
693            }
694        })
695        .collect::<Vec<_>>()
696        .join("\n")
697        + if compose.ends_with('\n') { "\n" } else { "" };
698
699    namespaced
700        .replace(
701            "-notify-sighup nginx ",
702            &format!("-notify-sighup {prefix}nginx "),
703        )
704        .replace(
705            "-notify-sighup tor ",
706            &format!("-notify-sighup {prefix}tor "),
707        )
708        .replace(
709            "NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"",
710            &format!("NGINX_DOCKER_GEN_CONTAINER: \"{prefix}nginx-gen\""),
711        )
712        .replace(
713            "NGINX_PROXY_CONTAINER: \"nginx\"",
714            &format!("NGINX_PROXY_CONTAINER: \"{prefix}nginx\""),
715        )
716}
717
718fn docker_compose_up(btcpay_root: &Path, envs: &BTreeMap<String, String>) -> Result<()> {
719    let subname = required_env_value(envs, "BTCPAYGEN_SUBNAME")?;
720    let project_name = required_env_value(envs, "COMPOSE_PROJECT_NAME")?;
721    let compose_file = btcpay_root
722        .join("Generated")
723        .join(format!("docker-compose.{subname}.yml"));
724    ensure!(
725        compose_file.is_file(),
726        "generated BTCPay compose file is missing at {}",
727        compose_file.display()
728    );
729
730    let args = vec![
731        "compose".to_string(),
732        "--project-name".to_string(),
733        project_name.to_string(),
734        "--env-file".to_string(),
735        ".env".to_string(),
736        "-f".to_string(),
737        compose_file.display().to_string(),
738        "up".to_string(),
739        "-d".to_string(),
740    ];
741    run_command(
742        "BTCPay docker compose up",
743        "docker",
744        &args,
745        btcpay_root,
746        envs,
747    )
748}
749
750fn run_command(
751    label: &str,
752    program: &str,
753    args: &[String],
754    cwd: &Path,
755    envs: &BTreeMap<String, String>,
756) -> Result<()> {
757    let mut command = Command::new(program);
758    command.current_dir(cwd).args(args).envs(envs);
759    command.env("COMPOSE_CONVERT_WINDOWS_PATHS", "1");
760    let status = command
761        .status()
762        .with_context(|| format!("failed to start {label}"))?;
763    ensure!(status.success(), "{label} exited with status {status}");
764    Ok(())
765}
766
767struct BtcpayClient {
768    profile: BtcpayDevProfile,
769    base_url: String,
770    admin_email: String,
771    admin_password: String,
772    client: Client,
773}
774
775impl BtcpayClient {
776    fn new(
777        profile: BtcpayDevProfile,
778        base_url: String,
779        admin_email: String,
780        admin_password: String,
781    ) -> Result<Self> {
782        let client = Client::builder()
783            .cookie_store(true)
784            .timeout(Duration::from_secs(30))
785            .build()
786            .context("failed to create BTCPay HTTP client")?;
787        Ok(Self {
788            profile,
789            base_url: base_url.trim_end_matches('/').to_string(),
790            admin_email,
791            admin_password,
792            client,
793        })
794    }
795
796    fn wait_until_ready(&self) -> Result<()> {
797        let deadline = Instant::now() + Duration::from_secs(180);
798        let mut last_error = None;
799        while Instant::now() < deadline {
800            match self
801                .client
802                .get(format!("{}/api/v1/stores", self.base_url))
803                .send()
804            {
805                Ok(response)
806                    if matches!(
807                        response.status(),
808                        StatusCode::OK | StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
809                    ) =>
810                {
811                    return Ok(());
812                }
813                Ok(response) => {
814                    last_error = Some(anyhow!("BTCPay returned {}", response.status()));
815                }
816                Err(error) => {
817                    last_error = Some(error.into());
818                }
819            }
820            std::thread::sleep(Duration::from_secs(2));
821        }
822        Err(last_error.unwrap_or_else(|| anyhow!("BTCPay did not become ready in time")))
823    }
824
825    fn ensure_admin_user(&self) -> Result<()> {
826        if self.has_valid_admin_credentials()? {
827            return Ok(());
828        }
829
830        let response = self
831            .client
832            .post(format!("{}/api/v1/users", self.base_url))
833            .json(&json!({
834                "email": self.admin_email.as_str(),
835                "name": self.profile.display_name(),
836                "password": self.admin_password.as_str(),
837                "isAdministrator": true,
838                "sendInvitationEmail": false,
839            }))
840            .send()
841            .context("failed to create the local BTCPay administrator")?;
842        ensure!(
843            response.status() == StatusCode::CREATED || response.status() == StatusCode::OK,
844            "BTCPay administrator creation returned {}. The local BTCPay data may belong to different development credentials",
845            response.status()
846        );
847        ensure!(
848            self.has_valid_admin_credentials()?,
849            "BTCPay local admin bootstrap did not produce valid API credentials"
850        );
851        Ok(())
852    }
853
854    fn has_valid_admin_credentials(&self) -> Result<bool> {
855        let response = self
856            .admin_auth(
857                self.client
858                    .get(format!("{}/api/v1/users/me", self.base_url)),
859            )
860            .send()
861            .context("failed to check BTCPay admin credentials")?;
862        match response.status() {
863            StatusCode::OK => Ok(true),
864            StatusCode::UNAUTHORIZED | StatusCode::NOT_FOUND => Ok(false),
865            status => bail!("BTCPay admin credential check returned {status}"),
866        }
867    }
868
869    /// Reuse the named development store, or create it with administrator credentials.
870    fn ensure_store(&self, public_url: &str) -> Result<String> {
871        let path = "/api/v1/stores";
872        let response = self
873            .admin_auth(self.client.get(format!("{}{}", self.base_url, path)))
874            .send()
875            .with_context(|| format!("failed to request BTCPay {path}"))?;
876        let stores: Vec<Value> = read_btcpay_response(response, path)?;
877        for store in stores {
878            if store.get("name").and_then(Value::as_str) == Some(self.profile.store_name.as_str())
879                && let Some(id) = store.get("id").and_then(Value::as_str)
880            {
881                return Ok(id.to_string());
882            }
883        }
884
885        let payload = json!({
886            "name": self.profile.store_name.as_str(),
887            "website": public_url,
888            "defaultLang": "en",
889            "speedPolicy": "MediumSpeed",
890            "redirectAutomatically": false,
891            "anyoneCanCreateInvoice": false,
892        });
893        let response = self
894            .admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
895            .json(&payload)
896            .send()
897            .with_context(|| format!("failed to request BTCPay {path}"))?;
898        let created: Value = read_btcpay_response(response, path)?;
899        created
900            .get("id")
901            .and_then(Value::as_str)
902            .map(str::to_string)
903            .ok_or_else(|| anyhow!("BTCPay store creation returned an unexpected response"))
904    }
905
906    fn ensure_onchain_wallet(&self, store_id: &str, api_key: &str) -> Result<()> {
907        let has_wallet = |payload: &Value| {
908            payload.get("enabled").and_then(Value::as_bool) == Some(true)
909                && payload.get("paymentMethodId").and_then(Value::as_str) == Some("BTC-CHAIN")
910                && payload
911                    .pointer("/config/accountDerivation")
912                    .and_then(Value::as_str)
913                    .is_some_and(|derivation| !derivation.trim().is_empty())
914        };
915        let wallet_path =
916            format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC?includeConfig=true");
917        let generate_path =
918            format!("/api/v1/stores/{store_id}/payment-methods/OnChain/BTC/wallet/generate");
919        let deadline = Instant::now() + Duration::from_secs(180);
920
921        loop {
922            let response = self
923                .api_key_auth(
924                    api_key,
925                    self.client.get(format!("{}{}", self.base_url, wallet_path)),
926                )
927                .send()
928                .with_context(|| format!("failed to request BTCPay {wallet_path}"))?;
929            match response.status() {
930                status if status.is_success() => {
931                    let payload: Value = read_btcpay_response(response, wallet_path.as_str())?;
932                    if has_wallet(&payload) {
933                        return Ok(());
934                    }
935                }
936                StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => {}
937                _ => {
938                    let _: Value = read_btcpay_response(response, wallet_path.as_str())?;
939                }
940            }
941
942            let response = self
943                .api_key_auth(
944                    api_key,
945                    self.client
946                        .post(format!("{}{}", self.base_url, generate_path)),
947                )
948                .json(&json!({
949                    "label": self.profile.api_key_label.as_str(),
950                    "accountNumber": 0,
951                    "savePrivateKeys": false,
952                    "wordList": "English",
953                    "wordCount": 12,
954                    "scriptPubKeyType": "Segwit",
955                }))
956                .send()
957                .with_context(|| format!("failed to request BTCPay {generate_path}"))?;
958            if response.status() != StatusCode::SERVICE_UNAVAILABLE {
959                let payload: Value = read_btcpay_response(response, generate_path.as_str())?;
960                ensure!(
961                    has_wallet(&payload),
962                    "BTCPay on-chain wallet setup returned an unexpected response"
963                );
964                return Ok(());
965            }
966
967            ensure!(
968                Instant::now() < deadline,
969                "BTCPay on-chain services did not become ready in time"
970            );
971            std::thread::sleep(Duration::from_secs(2));
972        }
973    }
974
975    /// Apply the development exchange-rate settings through the store API key.
976    fn ensure_rate_settings(&self, store_id: &str, api_key: &str) -> Result<()> {
977        let path = format!("/api/v1/stores/{store_id}/rates/configuration/primary");
978        let settings = json!({
979            "spread": "0",
980            "preferredSource": self.profile.rate_source.as_str(),
981            "isCustomScript": false,
982            "effectiveScript": "",
983        });
984        let response = self
985            .api_key_auth(
986                api_key,
987                self.client.put(format!("{}{}", self.base_url, path)),
988            )
989            .json(&settings)
990            .send()
991            .with_context(|| format!("failed to request BTCPay {path}"))?;
992        let payload: Value = read_btcpay_response(response, &path)?;
993        ensure!(
994            payload.get("isCustomScript").and_then(Value::as_bool) == Some(false),
995            "BTCPay rate configuration returned an unexpected response"
996        );
997        Ok(())
998    }
999
1000    /// Preserve a valid store key or create one with exactly the required permissions.
1001    fn ensure_api_key(&self, store_id: &str, current_key: Option<&str>) -> Result<String> {
1002        let required_permissions = required_api_permissions(store_id);
1003        if let Some(key) = current_key
1004            && !is_placeholder_value(key)
1005            && self.api_key_has_permissions(key, &required_permissions)?
1006        {
1007            return Ok(key.to_string());
1008        }
1009
1010        let path = "/api/v1/api-keys";
1011        let payload = json!({
1012            "label": self.profile.api_key_label.as_str(),
1013            "permissions": required_permissions,
1014        });
1015        let response = self
1016            .admin_auth(self.client.post(format!("{}{}", self.base_url, path)))
1017            .json(&payload)
1018            .send()
1019            .with_context(|| format!("failed to request BTCPay {path}"))?;
1020        let created: Value = read_btcpay_response(response, path)?;
1021        created
1022            .get("apiKey")
1023            .and_then(Value::as_str)
1024            .map(str::to_string)
1025            .ok_or_else(|| anyhow!("BTCPay API key creation returned an unexpected response"))
1026    }
1027
1028    /// Reuse the configured webhook secret or register the payment-event subscription.
1029    fn ensure_webhook(
1030        &self,
1031        store_id: &str,
1032        public_url: &str,
1033        api_key: &str,
1034        current_secret: Option<&str>,
1035    ) -> Result<String> {
1036        let webhook_url = btcpay_webhook_url(public_url, self.profile.webhook_path.as_str())?;
1037        if let Some(secret) = current_secret
1038            && !is_placeholder_value(secret)
1039            && self.webhook_exists(store_id, api_key, webhook_url.as_str())?
1040        {
1041            return Ok(secret.to_string());
1042        }
1043
1044        let secret = format!("{}_{}", Uuid::new_v4(), Uuid::new_v4());
1045        let path = format!("/api/v1/stores/{store_id}/webhooks");
1046        let payload = json!({
1047            "enabled": true,
1048            "automaticRedelivery": true,
1049            "url": webhook_url,
1050            "secret": secret,
1051            "authorizedEvents": {
1052                "everything": false,
1053                "specificEvents": [
1054                    "InvoiceCreated",
1055                    "InvoiceReceivedPayment",
1056                    "InvoiceProcessing",
1057                    "InvoiceSettled",
1058                    "InvoiceExpired",
1059                    "InvoiceInvalid"
1060                ]
1061            }
1062        });
1063        let response = self
1064            .api_key_auth(
1065                api_key,
1066                self.client.post(format!("{}{}", self.base_url, path)),
1067            )
1068            .json(&payload)
1069            .send()
1070            .with_context(|| format!("failed to request BTCPay {path}"))?;
1071        let created: Value = read_btcpay_response(response, &path)?;
1072        Ok(created
1073            .get("secret")
1074            .and_then(Value::as_str)
1075            .unwrap_or(secret.as_str())
1076            .to_string())
1077    }
1078
1079    fn api_key_has_permissions(&self, key: &str, required_permissions: &[String]) -> Result<bool> {
1080        let response = self
1081            .api_key_auth(
1082                key,
1083                self.client
1084                    .get(format!("{}/api/v1/api-keys/current", self.base_url)),
1085            )
1086            .send()
1087            .context("failed to check BTCPay API key")?;
1088        if response.status() == StatusCode::UNAUTHORIZED {
1089            return Ok(false);
1090        }
1091        let payload: Value = read_btcpay_response(response, "BTCPay API key check")?;
1092        let Some(permissions) = payload.get("permissions").and_then(Value::as_array) else {
1093            return Ok(false);
1094        };
1095        let permissions = permissions
1096            .iter()
1097            .filter_map(Value::as_str)
1098            .collect::<BTreeSet<_>>();
1099        Ok(required_permissions
1100            .iter()
1101            .all(|permission| permissions.contains(permission.as_str())))
1102    }
1103
1104    /// Check existing subscriptions before registering another webhook.
1105    fn webhook_exists(&self, store_id: &str, api_key: &str, webhook_url: &str) -> Result<bool> {
1106        let path = format!("/api/v1/stores/{store_id}/webhooks");
1107        let response = self
1108            .api_key_auth(
1109                api_key,
1110                self.client.get(format!("{}{}", self.base_url, path)),
1111            )
1112            .send()
1113            .with_context(|| format!("failed to request BTCPay {path}"))?;
1114        let webhooks: Vec<Value> = read_btcpay_response(response, &path)?;
1115        Ok(webhooks
1116            .iter()
1117            .any(|webhook| webhook.get("url").and_then(Value::as_str) == Some(webhook_url)))
1118    }
1119
1120    fn admin_auth(&self, request: RequestBuilder) -> RequestBuilder {
1121        let token = base64::engine::general_purpose::STANDARD
1122            .encode(format!("{}:{}", self.admin_email, self.admin_password));
1123        request.header(reqwest::header::AUTHORIZATION, format!("Basic {token}"))
1124    }
1125
1126    fn api_key_auth(&self, api_key: &str, request: RequestBuilder) -> RequestBuilder {
1127        request.header(reqwest::header::AUTHORIZATION, format!("token {api_key}"))
1128    }
1129}
1130
1131fn required_api_permissions(store_id: &str) -> [String; 5] {
1132    [
1133        format!("btcpay.store.cancreateinvoice:{store_id}"),
1134        format!("btcpay.store.canviewinvoices:{store_id}"),
1135        format!("btcpay.store.webhooks.canmodifywebhooks:{store_id}"),
1136        format!("btcpay.store.canviewstoresettings:{store_id}"),
1137        format!("btcpay.store.canmodifystoresettings:{store_id}"),
1138    ]
1139}
1140
1141/// Decode a BTCPay response only after preserving its HTTP failure and body context.
1142fn read_btcpay_response<T: for<'de> Deserialize<'de>>(
1143    response: reqwest::blocking::Response,
1144    label: &str,
1145) -> Result<T> {
1146    let status = response.status();
1147    let text = response
1148        .text()
1149        .with_context(|| format!("failed to read {label} response"))?;
1150    ensure!(
1151        status.is_success(),
1152        "{label} returned {status}: {}",
1153        text.chars().take(500).collect::<String>()
1154    );
1155    serde_json::from_str(&text)
1156        .with_context(|| format!("{label} returned an unexpected JSON response"))
1157}
1158
1159fn btcpay_webhook_url(public_url: &str, webhook_path: &str) -> Result<String> {
1160    let parsed = url::Url::parse(public_url)
1161        .with_context(|| format!("invalid public payment URL `{public_url}`"))?;
1162    let host = match parsed.host_str() {
1163        Some("127.0.0.1" | "localhost") => "payment-host.internal",
1164        Some(host) => host,
1165        None => "payment-host.internal",
1166    };
1167    let mut netloc = host.to_string();
1168    if let Some(port) = parsed.port() {
1169        netloc = format!("{netloc}:{port}");
1170    }
1171    let scheme = if parsed.scheme().is_empty() {
1172        "http"
1173    } else {
1174        parsed.scheme()
1175    };
1176    Ok(format!("{scheme}://{netloc}{webhook_path}"))
1177}
1178
1179fn required_env_value<'a>(values: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1180    values
1181        .get(key)
1182        .map(String::as_str)
1183        .filter(|value| !value.trim().is_empty())
1184        .ok_or_else(|| anyhow!("missing required BTCPay env value {key}"))
1185}
1186
1187fn local_btcpay_additional_hosts(primary_host: &str, configured_hosts: &str) -> String {
1188    let mut hosts = configured_hosts
1189        .split(',')
1190        .map(str::trim)
1191        .filter(|host| !host.is_empty())
1192        .map(str::to_string)
1193        .collect::<Vec<_>>();
1194    if primary_host == "127.0.0.1" && !hosts.iter().any(|host| host == "localhost") {
1195        hosts.push("localhost".to_string());
1196    }
1197    if primary_host == "localhost" && !hosts.iter().any(|host| host == "127.0.0.1") {
1198        hosts.push("127.0.0.1".to_string());
1199    }
1200    hosts.join(",")
1201}
1202
1203fn is_placeholder_value(value: &str) -> bool {
1204    matches!(
1205        value.trim(),
1206        "" | "__change_me__" | "BTCPAY_STORE_ID_FROM_UI"
1207    )
1208}
1209
1210fn mask_secret(value: &str) -> String {
1211    if value.len() <= 8 {
1212        return "********".to_string();
1213    }
1214    format!("{}...{}", &value[..4], &value[value.len() - 4..])
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::{
1220        BtcpayClient, BtcpayDevProfile, GithubRelease, btcpay_checkout_root, btcpay_webhook_url,
1221        configure_local_webhook_host, enable_regtest_wallet_creation,
1222        namespace_local_container_names, stable_btcpay_image_from_release,
1223        stable_btcpay_version_from_image,
1224    };
1225    use base64::Engine as _;
1226    use serde_json::{Value, json};
1227    use std::{
1228        collections::BTreeMap,
1229        io::{BufRead, BufReader, Read, Write},
1230        net::TcpListener,
1231        path::Path,
1232        thread::JoinHandle,
1233        time::Duration,
1234    };
1235
1236    type CapturedRequest = (String, BTreeMap<String, String>, Option<Value>);
1237
1238    fn serve_btcpay_requests(replies: Vec<Value>) -> (String, JoinHandle<Vec<CapturedRequest>>) {
1239        let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1240        let base_url = format!("http://{}", listener.local_addr().unwrap());
1241        let server = std::thread::spawn(move || {
1242            let mut captured = Vec::new();
1243            for reply in replies {
1244                let (mut stream, _) = listener.accept().unwrap();
1245                stream
1246                    .set_read_timeout(Some(Duration::from_secs(5)))
1247                    .unwrap();
1248                let (line, headers, body) = {
1249                    let mut reader = BufReader::new(&mut stream);
1250                    let mut line = String::new();
1251                    reader.read_line(&mut line).unwrap();
1252                    let mut headers = BTreeMap::new();
1253                    loop {
1254                        let mut header = String::new();
1255                        reader.read_line(&mut header).unwrap();
1256                        if header == "\r\n" {
1257                            break;
1258                        }
1259                        let (name, value) = header.trim().split_once(':').unwrap();
1260                        headers.insert(name.to_ascii_lowercase(), value.trim().to_owned());
1261                    }
1262                    let length = headers
1263                        .get("content-length")
1264                        .map(|length| length.parse::<usize>().unwrap())
1265                        .unwrap_or(0);
1266                    let mut bytes = vec![0; length];
1267                    reader.read_exact(&mut bytes).unwrap();
1268                    let body = (!bytes.is_empty()).then(|| serde_json::from_slice(&bytes).unwrap());
1269                    (line.trim().to_owned(), headers, body)
1270                };
1271                captured.push((line, headers, body));
1272                let body = reply.to_string();
1273                write!(
1274                    stream,
1275                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1276                    body.len(),
1277                )
1278                .unwrap();
1279            }
1280            captured
1281        });
1282        (base_url, server)
1283    }
1284
1285    #[test]
1286    fn bootstrap_preserves_request_order_credentials_permissions_and_payment_events() {
1287        let (base_url, server) = serve_btcpay_requests(vec![
1288            json!([]),
1289            json!({"id": "store-a"}),
1290            json!({"permissions": []}),
1291            json!({"apiKey": "new-store-key"}),
1292            json!({"isCustomScript": false}),
1293            json!([]),
1294            json!({"secret": "registered-secret"}),
1295        ]);
1296        let public_url = "https://merchant.example.invalid";
1297        let profile = BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url);
1298        let client = BtcpayClient::new(
1299            profile,
1300            base_url,
1301            "admin@example.invalid".to_owned(),
1302            "fixture-password".to_owned(),
1303        )
1304        .unwrap();
1305        assert_eq!(client.ensure_store(public_url).unwrap(), "store-a");
1306        let key = client
1307            .ensure_api_key("store-a", Some("old-store-key"))
1308            .unwrap();
1309        assert_eq!(key, "new-store-key");
1310        client.ensure_rate_settings("store-a", &key).unwrap();
1311        assert_eq!(
1312            client
1313                .ensure_webhook("store-a", public_url, &key, Some("old-secret"))
1314                .unwrap(),
1315            "registered-secret",
1316        );
1317        let requests = server.join().unwrap();
1318        assert_eq!(
1319            requests
1320                .iter()
1321                .map(|request| request.0.as_str())
1322                .collect::<Vec<_>>(),
1323            [
1324                "GET /api/v1/stores HTTP/1.1",
1325                "POST /api/v1/stores HTTP/1.1",
1326                "GET /api/v1/api-keys/current HTTP/1.1",
1327                "POST /api/v1/api-keys HTTP/1.1",
1328                "PUT /api/v1/stores/store-a/rates/configuration/primary HTTP/1.1",
1329                "GET /api/v1/stores/store-a/webhooks HTTP/1.1",
1330                "POST /api/v1/stores/store-a/webhooks HTTP/1.1",
1331            ],
1332        );
1333        let admin = format!(
1334            "Basic {}",
1335            base64::engine::general_purpose::STANDARD
1336                .encode("admin@example.invalid:fixture-password"),
1337        );
1338        for index in [0, 1, 3] {
1339            assert_eq!(requests[index].1["authorization"], admin);
1340        }
1341        assert_eq!(requests[2].1["authorization"], "token old-store-key");
1342        for index in [4, 5, 6] {
1343            assert_eq!(requests[index].1["authorization"], "token new-store-key");
1344        }
1345        assert_eq!(
1346            requests[1].2,
1347            Some(json!({
1348                "name": "Fixture store", "website": public_url, "defaultLang": "en",
1349                "speedPolicy": "MediumSpeed", "redirectAutomatically": false,
1350                "anyoneCanCreateInvoice": false,
1351            }))
1352        );
1353        assert_eq!(
1354            requests[3].2,
1355            Some(json!({
1356                "label": "Fixture store local dev",
1357                "permissions": [
1358                    "btcpay.store.cancreateinvoice:store-a",
1359                    "btcpay.store.canviewinvoices:store-a",
1360                    "btcpay.store.webhooks.canmodifywebhooks:store-a",
1361                    "btcpay.store.canviewstoresettings:store-a",
1362                    "btcpay.store.canmodifystoresettings:store-a",
1363                ],
1364            }))
1365        );
1366        assert_eq!(
1367            requests[4].2,
1368            Some(json!({
1369                "spread": "0", "preferredSource": "kraken",
1370                "isCustomScript": false, "effectiveScript": "",
1371            }))
1372        );
1373        let webhook = requests[6].2.as_ref().unwrap();
1374        assert_eq!(
1375            webhook["url"],
1376            format!("{public_url}/api/payments/webhooks/btcpay")
1377        );
1378        assert_eq!(webhook["enabled"], true);
1379        assert_eq!(webhook["automaticRedelivery"], true);
1380        assert_eq!(
1381            webhook["authorizedEvents"],
1382            json!({
1383                "everything": false,
1384                "specificEvents": ["InvoiceCreated", "InvoiceReceivedPayment", "InvoiceProcessing",
1385                    "InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"],
1386            })
1387        );
1388        assert_eq!(webhook["secret"].as_str().unwrap().len(), 73);
1389    }
1390
1391    #[test]
1392    fn matching_store_is_reused_without_creating_another_store() {
1393        let (base_url, server) = serve_btcpay_requests(vec![json!([
1394            {"name": "Another store", "id": "other"},
1395            {"name": "Fixture store", "id": "existing"},
1396        ])]);
1397        let public_url = "https://merchant.example.invalid";
1398        let client = BtcpayClient::new(
1399            BtcpayDevProfile::new("Fixture store", "admin@example.invalid", public_url),
1400            base_url,
1401            "admin@example.invalid".to_owned(),
1402            "fixture-password".to_owned(),
1403        )
1404        .unwrap();
1405        assert_eq!(client.ensure_store(public_url).unwrap(), "existing");
1406        let requests = server.join().unwrap();
1407        assert_eq!(requests.len(), 1);
1408        assert_eq!(requests[0].0, "GET /api/v1/stores HTTP/1.1");
1409        assert!(requests[0].2.is_none());
1410    }
1411
1412    #[test]
1413    fn local_webhooks_use_daemon_host_without_upstream_hostname_collision() {
1414        let compose = "services:\n  btcpayserver:\n    image: btcpay\n  other:\n    image: other\n";
1415        let configured = configure_local_webhook_host(compose).unwrap();
1416        assert!(configured.contains("payment-host.internal:host-gateway"));
1417        assert!(configured.ends_with("  other:\n    image: other\n"));
1418        assert_eq!(
1419            configure_local_webhook_host(&configured).unwrap(),
1420            configured
1421        );
1422        assert!(configure_local_webhook_host("services:\n").is_err());
1423        for host in ["localhost", "127.0.0.1"] {
1424            assert_eq!(
1425                btcpay_webhook_url(&format!("http://{host}:4032"), "/webhook").unwrap(),
1426                "http://payment-host.internal:4032/webhook"
1427            );
1428        }
1429        assert_eq!(
1430            btcpay_webhook_url("https://example.org", "/webhook").unwrap(),
1431            "https://example.org/webhook"
1432        );
1433    }
1434
1435    #[test]
1436    fn resolves_btcpay_checkout_next_to_the_app_repository() {
1437        assert_eq!(
1438            btcpay_checkout_root(Path::new("/workspace/ch-chirora"))
1439                .expect("app checkout should have a parent"),
1440            Path::new("/workspace/third-party/btcpay")
1441        );
1442    }
1443
1444    #[test]
1445    fn namespaces_generated_container_names_with_the_compose_project() {
1446        let compose = concat!(
1447            "services:\n",
1448            "  nginx:\n",
1449            "    container_name: nginx\n",
1450            "  nginx-gen:\n",
1451            "    container_name: nginx-gen\n",
1452            "    entrypoint: docker-gen -notify-sighup nginx -watch\n",
1453            "  letsencrypt:\n",
1454            "    environment:\n",
1455            "      NGINX_DOCKER_GEN_CONTAINER: \"nginx-gen\"\n",
1456            "      NGINX_PROXY_CONTAINER: \"nginx\"\n",
1457            "  tor:\n",
1458            "    container_name: tor\n",
1459            "  tor-gen:\n",
1460            "    entrypoint: docker-gen -notify-sighup tor -watch\n",
1461        );
1462
1463        assert_eq!(
1464            namespace_local_container_names(compose, "chirora-local-btcpay"),
1465            concat!(
1466                "services:\n",
1467                "  nginx:\n",
1468                "    container_name: chirora-local-btcpay-nginx\n",
1469                "  nginx-gen:\n",
1470                "    container_name: chirora-local-btcpay-nginx-gen\n",
1471                "    entrypoint: docker-gen -notify-sighup chirora-local-btcpay-nginx -watch\n",
1472                "  letsencrypt:\n",
1473                "    environment:\n",
1474                "      NGINX_DOCKER_GEN_CONTAINER: \"chirora-local-btcpay-nginx-gen\"\n",
1475                "      NGINX_PROXY_CONTAINER: \"chirora-local-btcpay-nginx\"\n",
1476                "  tor:\n",
1477                "    container_name: chirora-local-btcpay-tor\n",
1478                "  tor-gen:\n",
1479                "    entrypoint: docker-gen -notify-sighup chirora-local-btcpay-tor -watch\n",
1480            )
1481        );
1482    }
1483
1484    #[test]
1485    fn enables_default_bitcoin_wallet_only_for_regtest() {
1486        let compose = concat!(
1487            "services:\n",
1488            "  bitcoind:\n",
1489            "    environment:\n",
1490            "      CREATE_WALLET: \"false\"\n",
1491        );
1492
1493        assert_eq!(
1494            enable_regtest_wallet_creation(compose, "regtest"),
1495            compose.replace("false", "true")
1496        );
1497        assert_eq!(enable_regtest_wallet_creation(compose, "mainnet"), compose);
1498    }
1499
1500    #[test]
1501    fn accepts_latest_stable_release() {
1502        let release = GithubRelease {
1503            tag_name: "v2.4.0".to_string(),
1504            draft: false,
1505            prerelease: false,
1506        };
1507
1508        assert_eq!(
1509            stable_btcpay_image_from_release(&release).as_deref(),
1510            Some("btcpayserver/btcpayserver:2.4.0")
1511        );
1512    }
1513
1514    #[test]
1515    fn rejects_release_candidates_and_drafts() {
1516        for release in [
1517            GithubRelease {
1518                tag_name: "v2.4.3-rc4".to_string(),
1519                draft: false,
1520                prerelease: true,
1521            },
1522            GithubRelease {
1523                tag_name: "v2.4.1".to_string(),
1524                draft: true,
1525                prerelease: false,
1526            },
1527        ] {
1528            assert!(stable_btcpay_image_from_release(&release).is_none());
1529        }
1530    }
1531
1532    #[test]
1533    fn accepts_only_official_stable_image_overrides() {
1534        assert_eq!(
1535            stable_btcpay_version_from_image("btcpayserver/btcpayserver:2.4.0"),
1536            Some("2.4.0")
1537        );
1538        for image in [
1539            "btcpayserver/btcpayserver:2.4.3-rc4",
1540            "btcpayserver/btcpayserver:latest",
1541            "example.invalid/btcpay:2.4.0",
1542            "btcpayserver/btcpayserver:02.4.0",
1543        ] {
1544            assert!(stable_btcpay_version_from_image(image).is_none());
1545        }
1546    }
1547}