exonum 0.12.2

An extensible framework for blockchain software projects.
Documentation
// Copyright 2019 The Exonum Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// This is a regression test for exonum configuration.

#[macro_use]
extern crate pretty_assertions;
#[macro_use]
extern crate serde_derive;

use exonum::{
    api::backends::actix::AllowOrigin,
    crypto::{PublicKey, PUBLIC_KEY_LENGTH},
    helpers::{
        config::{ConfigFile, ConfigManager},
        fabric::NodeBuilder,
    },
    node::{ConnectInfo, ConnectListConfig, NodeConfig},
};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::{
    env,
    ffi::OsString,
    fs::{self, OpenOptions},
    panic,
    path::{Path, PathBuf},
};

#[derive(Debug)]
struct ConfigSpec {
    expected_root_dir: PathBuf,
    output_root_dir: tempfile::TempDir,
    validators_count: usize,
}

impl ConfigSpec {
    const CONFIG_TESTDATA_FOLDER: &'static str =
        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/testdata/config");

    fn new(root_dir: impl AsRef<Path>, validators_count: usize) -> Self {
        Self {
            expected_root_dir: root_dir.as_ref().to_owned(),
            output_root_dir: tempfile::tempdir().unwrap(),
            validators_count,
        }
    }

    fn new_without_pass() -> Self {
        let root_dir = PathBuf::from(Self::CONFIG_TESTDATA_FOLDER).join("without_pass");
        Self::new(root_dir, 4)
    }

    fn new_with_pass() -> Self {
        let root_dir = PathBuf::from(Self::CONFIG_TESTDATA_FOLDER).join("with_pass");
        Self::new(root_dir, 1)
    }

    fn new_more_validators() -> Self {
        let root_dir = PathBuf::from(Self::CONFIG_TESTDATA_FOLDER).join("more_validators");
        Self::new(root_dir, 4)
    }

    fn command(&self, name: &str) -> ArgsBuilder {
        ArgsBuilder {
            args: vec!["exonum-config-test".into(), name.into()],
        }
    }

    fn copy_node_config_to_output(&self, index: usize) {
        let src = self.expected_node_config_dir(index);
        let dest = self.output_node_config_dir(index);
        fs::create_dir_all(&dest).unwrap();

        [
            "pub.toml",
            "sec.toml",
            "service.key.toml",
            "consensus.key.toml",
        ]
        .iter()
        .try_for_each(|file| copy_secured(src.join(file), dest.join(file)))
        .expect("Can't copy file");
    }

    fn output_dir(&self) -> PathBuf {
        self.output_root_dir.as_ref().join("cfg")
    }

    fn output_template_file(&self) -> PathBuf {
        self.output_dir().join("template.toml")
    }

    fn output_node_config_dir(&self, index: usize) -> PathBuf {
        self.output_dir().join(index.to_string())
    }

    fn output_sec_config(&self, index: usize) -> PathBuf {
        self.output_node_config_dir(index).join("sec.toml")
    }

    fn output_pub_config(&self, index: usize) -> PathBuf {
        self.output_node_config_dir(index).join("pub.toml")
    }

    fn output_pub_configs(&self) -> Vec<PathBuf> {
        (0..self.validators_count)
            .map(|i| self.output_pub_config(i))
            .collect()
    }

    fn output_node_config(&self, index: usize) -> PathBuf {
        self.output_node_config_dir(index).join("node.toml")
    }

    fn expected_dir(&self) -> PathBuf {
        self.expected_root_dir.join("cfg")
    }

    fn expected_template_file(&self) -> PathBuf {
        self.expected_dir().join("template.toml")
    }

    fn expected_node_config_dir(&self, index: usize) -> PathBuf {
        self.expected_dir().join(index.to_string())
    }

    fn expected_node_config_file(&self, index: usize) -> PathBuf {
        self.expected_node_config_dir(index).join("node.toml")
    }

    fn expected_pub_config(&self, index: usize) -> PathBuf {
        self.expected_node_config_dir(index).join("pub.toml")
    }

    fn expected_pub_configs(&self) -> Vec<PathBuf> {
        (0..self.validators_count)
            .map(|i| self.expected_pub_config(i))
            .collect()
    }
}

#[derive(Debug)]
struct ArgsBuilder {
    args: Vec<OsString>,
}

impl ArgsBuilder {
    fn with_arg(mut self, arg: impl Into<OsString>) -> Self {
        self.args.push(arg.into());
        self
    }

    fn with_args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
        for arg in args {
            self.args.push(arg.into())
        }
        self
    }

    fn with_named_arg(mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> Self {
        self.args.push(name.into());
        self.args.push(value.into());
        self
    }

    fn run(self) -> Option<()> {
        log::trace!(
            "-> {}",
            self.args
                .iter()
                .map(|s| s.to_str().unwrap())
                .collect::<Vec<_>>()
                .join(" ")
        );
        if NodeBuilder::new().parse_cmd_string(self.args) {
            None
        } else {
            Some(())
        }
    }
}

fn touch(path: impl AsRef<Path>) {
    OpenOptions::new()
        .create(true)
        .write(true)
        .open(path)
        .unwrap();
}

fn copy_secured(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<(), failure::Error> {
    let mut source_file = fs::File::open(&from)?;

    let mut destination_file = {
        let mut open_options = OpenOptions::new();
        open_options.create(true).write(true);
        #[cfg(unix)]
        open_options.mode(0o600);
        open_options.open(&to)?
    };

    std::io::copy(&mut source_file, &mut destination_file)?;
    Ok(())
}

fn load_node_config(path: impl AsRef<Path>) -> NodeConfig<PathBuf> {
    ConfigFile::load(path).expect("Can't load node config file")
}

fn assert_config_files_eq(path_1: impl AsRef<Path>, path_2: impl AsRef<Path>) {
    let cfg_1: toml::Value = ConfigFile::load(&path_1).unwrap();
    let cfg_2: toml::Value = ConfigFile::load(&path_2).unwrap();
    assert_eq!(
        cfg_1,
        cfg_2,
        "file {:?} doesn't match with {:?}",
        path_1.as_ref(),
        path_2.as_ref()
    );
}

// Special case for NodeConfig because it uses absolute paths for secret key files.
fn assert_node_config_files_eq(actual: impl AsRef<Path>, expected: impl AsRef<Path>) {
    let (actual, expected) = (actual.as_ref(), expected.as_ref());

    let config_dir = actual.parent().unwrap();
    let actual = load_node_config(actual);
    let mut expected = load_node_config(expected);
    expected.service_secret_key = config_dir.join(&expected.service_secret_key);
    expected.consensus_secret_key = config_dir.join(&expected.consensus_secret_key);

    assert_eq!(actual, expected);
}

#[test]
fn test_allow_origin_toml() {
    fn check(text: &str, allow_origin: AllowOrigin) {
        #[derive(Serialize, Deserialize)]
        struct Config {
            allow_origin: AllowOrigin,
        }
        let config_toml = format!("allow_origin = {}\n", text);
        let config: Config = ::toml::from_str(&config_toml).unwrap();
        assert_eq!(config.allow_origin, allow_origin);
        assert_eq!(::toml::to_string(&config).unwrap(), config_toml);
    }

    check(r#""*""#, AllowOrigin::Any);
    check(
        r#""http://example.com""#,
        AllowOrigin::Whitelist(vec!["http://example.com".to_string()]),
    );
    check(
        r#"["http://a.org", "http://b.org"]"#,
        AllowOrigin::Whitelist(vec!["http://a.org".to_string(), "http://b.org".to_string()]),
    );
}

#[test]
fn test_generate_template() {
    let env = ConfigSpec::new_without_pass();
    let output_template_file = env.output_template_file();
    env.command("generate-template")
        .with_arg(&output_template_file)
        .with_named_arg("--validators-count", env.validators_count.to_string())
        .run()
        .unwrap();
    assert_config_files_eq(&output_template_file, env.expected_template_file());
}

#[test]
fn test_generate_config_key_files() {
    let env = ConfigSpec::new_without_pass();
    env.command("generate-config")
        .with_arg(&env.expected_template_file())
        .with_arg(&env.output_node_config_dir(0))
        .with_named_arg("-a", "0.0.0.0:8000")
        .with_arg("--no-password")
        .run()
        .unwrap();

    let sec_cfg: toml::Value = ConfigFile::load(&env.output_sec_config(0)).unwrap();
    assert_eq!(sec_cfg["consensus_secret_key"], "consensus.key.toml".into());
    assert_eq!(sec_cfg["service_secret_key"], "service.key.toml".into());
}

#[test]
fn test_generate_config_ipv4() {
    let env = ConfigSpec::new_without_pass();
    env.command("generate-config")
        .with_arg(&env.expected_template_file())
        .with_arg(&env.output_node_config_dir(0))
        .with_named_arg("-a", "127.0.0.1")
        .with_arg("--no-password")
        .run()
        .unwrap()
}

#[test]
fn test_generate_config_ipv6() {
    let env = ConfigSpec::new_without_pass();
    env.command("generate-config")
        .with_arg(&env.expected_template_file())
        .with_arg(&env.output_node_config_dir(0))
        .with_named_arg("-a", "::1")
        .with_arg("--no-password")
        .run()
        .unwrap()
}

#[test]
fn test_finalize_run_without_pass() {
    let env = ConfigSpec::new_without_pass();
    for i in 0..env.validators_count {
        env.copy_node_config_to_output(i);
        let node_config = env.output_node_config(i);
        env.command("finalize")
            .with_arg(env.output_sec_config(i))
            .with_arg(&node_config)
            .with_arg("--public-configs")
            .with_args(env.expected_pub_configs())
            .run()
            .unwrap();
        assert_node_config_files_eq(&node_config, env.expected_node_config_file(i));

        let feedback = env
            .command("run")
            .with_named_arg("-c", &node_config)
            .with_named_arg("-d", env.output_dir().join("foo"))
            .with_named_arg("--service-key-pass", "pass:")
            .with_named_arg("--consensus-key-pass", "pass:")
            .run();
        assert!(feedback.is_none());
    }
}

#[test]
fn test_finalize_run_with_pass() {
    let env = ConfigSpec::new_with_pass();

    env::set_var("EXONUM_CONSENSUS_PASS", "some passphrase");
    env::set_var("EXONUM_SERVICE_PASS", "another passphrase");
    env.copy_node_config_to_output(0);
    let node_config = env.output_node_config(0);
    env.command("finalize")
        .with_arg(env.output_sec_config(0))
        .with_arg(&node_config)
        .with_arg("--public-configs")
        .with_args(env.expected_pub_configs())
        .run()
        .unwrap();
    assert_node_config_files_eq(&node_config, env.expected_node_config_file(0));

    let feedback = env
        .command("run")
        .with_named_arg("-c", &node_config)
        .with_named_arg("-d", env.output_dir().join("foo"))
        .with_named_arg("--service-key-pass", "env")
        .with_named_arg("--consensus-key-pass", "env")
        .run();
    assert!(feedback.is_none());
}

#[test]
#[should_panic(
    expected = "The number of validators configs does not match the number of validators keys."
)]
fn test_less_validators_count() {
    let env = ConfigSpec::new_more_validators();

    let node_config = env.output_node_config(0);
    env.copy_node_config_to_output(0);
    env.command("finalize")
        .with_arg(env.output_sec_config(0))
        .with_arg(&node_config)
        .with_arg("--public-configs")
        .with_args(env.expected_pub_configs().into_iter())
        .run()
        .unwrap();
}

#[test]
#[should_panic(
    expected = "The number of validators configs does not match the number of validators keys."
)]
fn test_more_validators_count() {
    let env = ConfigSpec::new_more_validators();

    let node_config = env.output_node_config(0);
    env.copy_node_config_to_output(0);
    env.command("finalize")
        .with_arg(env.output_sec_config(0))
        .with_arg(&node_config)
        .with_arg("--public-configs")
        .with_args(env.expected_pub_configs())
        .run()
        .unwrap();
}

#[test]
fn test_full_workflow() {
    let env = ConfigSpec::new("", 4);

    let output_template_file = env.output_template_file();
    env.command("generate-template")
        .with_arg(&output_template_file)
        .with_named_arg("--validators-count", env.validators_count.to_string())
        .run()
        .unwrap();

    for i in 0..env.validators_count {
        env.command("generate-config")
            .with_arg(&output_template_file)
            .with_arg(&env.output_node_config_dir(i))
            .with_named_arg("-a", format!("0.0.0.0:{}", 8000 + i))
            .with_named_arg("--service-key-pass", "pass:12345678")
            .with_named_arg("--consensus-key-pass", "pass:12345678")
            .run()
            .unwrap();
    }

    env::set_var("EXONUM_CONSENSUS_PASS", "12345678");
    env::set_var("EXONUM_SERVICE_PASS", "12345678");
    for i in 0..env.validators_count {
        let node_config = env.output_node_config(i);
        env.command("finalize")
            .with_arg(env.output_sec_config(i))
            .with_arg(&node_config)
            .with_arg("--public-configs")
            .with_args(env.output_pub_configs())
            .run()
            .unwrap();

        let feedback = env
            .command("run")
            .with_named_arg("-c", &node_config)
            .with_named_arg("-d", env.output_dir().join("foo"))
            .with_named_arg("--service-key-pass", "env")
            .with_named_arg("--consensus-key-pass", "env")
            .run();
        assert!(feedback.is_none());
    }
}

#[test]
fn test_run_dev() {
    let env = ConfigSpec::new_without_pass();

    let artifacts_dir = env.output_dir().join("artifacts");
    // Mocks existence of old DB files that are supposed to be cleaned up.
    let db_dir = artifacts_dir.join("db");
    fs::create_dir_all(&db_dir).unwrap();
    let old_db_file = db_dir.join("content.foo");
    touch(&old_db_file);
    // Checks run-dev command.
    let feedback = env
        .command("run-dev")
        .with_arg("-a")
        .with_arg(&artifacts_dir)
        .run();
    assert!(feedback.is_none());
    // Tests cleaning up.
    assert!(!old_db_file.exists());
}

#[test]
fn test_update_config() {
    let env = ConfigSpec::new_without_pass();
    let config_path = env.output_dir().join("node.toml");
    fs::create_dir(&config_path.parent().unwrap()).unwrap();
    fs::copy(&env.expected_node_config_file(0), &config_path).unwrap();

    // Test config update.
    let peer = ConnectInfo {
        address: "0.0.0.1:8080".to_owned(),
        public_key: PublicKey::new([1; PUBLIC_KEY_LENGTH]),
    };

    let connect_list = ConnectListConfig { peers: vec![peer] };

    ConfigManager::update_connect_list(connect_list.clone(), &config_path)
        .expect("Unable to update connect list");
    let config = load_node_config(&config_path);

    let new_connect_list = config.connect_list;
    assert_eq!(new_connect_list.peers, connect_list.peers);
}