|
| 1 | +//! Provides an end-to-end test of `migrator` via the `run` function. This module is conditionally |
| 2 | +//! compiled for cfg(test) only. |
| 3 | +use crate::args::Args; |
| 4 | +use crate::run; |
| 5 | +use chrono::{DateTime, Utc}; |
| 6 | +use semver::Version; |
| 7 | +use std::fs; |
| 8 | +use std::fs::File; |
| 9 | +use std::io::Write; |
| 10 | +use std::path::{Path, PathBuf}; |
| 11 | +use tempfile::TempDir; |
| 12 | + |
| 13 | +/// Provides the path to a folder where test data files reside. |
| 14 | +fn test_data() -> PathBuf { |
| 15 | + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
| 16 | + p.pop(); |
| 17 | + p.join("migrator").join("tests").join("data") |
| 18 | +} |
| 19 | + |
| 20 | +/// Returns the filepath to a `root.json` file stored in tree for testing. This file declares |
| 21 | +/// an expiration date of `1970-01-01` to ensure success with an expired TUF repository. |
| 22 | +fn root() -> PathBuf { |
| 23 | + test_data() |
| 24 | + .join("expired-root.json") |
| 25 | + .canonicalize() |
| 26 | + .unwrap() |
| 27 | +} |
| 28 | + |
| 29 | +/// Returns the filepath to a private key, stored in tree and used only for testing. |
| 30 | +fn pem() -> PathBuf { |
| 31 | + test_data().join("snakeoil.pem").canonicalize().unwrap() |
| 32 | +} |
| 33 | + |
| 34 | +/// The name of a test migration. The prefix `b-` ensures we are not alphabetically sorting. |
| 35 | +const FIRST_MIGRATION: &str = "b-first-migration"; |
| 36 | + |
| 37 | +/// The name of a test migration. The prefix `a-` ensures we are not alphabetically sorting. |
| 38 | +const SECOND_MIGRATION: &str = "a-second-migration"; |
| 39 | + |
| 40 | +/// Creates a script that will serve as a migration during testing. The script writes its migrations |
| 41 | +/// name to a file named `result.txt` in the parent directory of the datastore. `pentacle` does not |
| 42 | +/// retain the name of the executing binary or script, so we take the `migration_name` as input, |
| 43 | +/// and 'hardcode' it into the script. |
| 44 | +fn create_test_migration<S: AsRef<str>>(migration_name: S) -> String { |
| 45 | + format!( |
| 46 | + r#"#!/usr/bin/env bash |
| 47 | +set -eo pipefail |
| 48 | +migration_name="{}" |
| 49 | +datastore_parent_dir="$(dirname "${{3}}")" |
| 50 | +outfile="${{datastore_parent_dir}}/result.txt" |
| 51 | +echo "${{migration_name}}:" "${{@}}" >> "${{outfile}}" |
| 52 | +"#, |
| 53 | + migration_name.as_ref() |
| 54 | + ) |
| 55 | +} |
| 56 | + |
| 57 | +/// Holds the lifetime of a `TempDir` inside which a datastore directory and links are held for |
| 58 | +/// testing. |
| 59 | +struct TestDatastore { |
| 60 | + tmp: TempDir, |
| 61 | + datastore: PathBuf, |
| 62 | +} |
| 63 | + |
| 64 | +impl TestDatastore { |
| 65 | + /// Creates a `TempDir`, sets up the datastore links needed to represent the `from_version` |
| 66 | + /// and returns a `TestDatastore` populated with this information. |
| 67 | + fn new(from_version: Version) -> Self { |
| 68 | + let tmp = TempDir::new().unwrap(); |
| 69 | + let datastore = storewolf::create_new_datastore(tmp.path(), Some(from_version)).unwrap(); |
| 70 | + TestDatastore { tmp, datastore } |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/// Represents a TUF repository, which is held in a tempdir. |
| 75 | +struct TestRepo { |
| 76 | + /// This field preserves the lifetime of the TempDir even though we never read it. When |
| 77 | + /// `TestRepo` goes out of scope, `TempDir` will remove the temporary directory. |
| 78 | + _tuf_dir: TempDir, |
| 79 | + metadata_path: PathBuf, |
| 80 | + targets_path: PathBuf, |
| 81 | +} |
| 82 | + |
| 83 | +/// LZ4 compresses `source` bytes to a new file at `destination`. |
| 84 | +fn compress(source: &[u8], destination: &Path) { |
| 85 | + let output_file = File::create(destination).unwrap(); |
| 86 | + let mut encoder = lz4::EncoderBuilder::new() |
| 87 | + .level(4) |
| 88 | + .build(output_file) |
| 89 | + .unwrap(); |
| 90 | + encoder.write_all(source).unwrap(); |
| 91 | + let (_output, result) = encoder.finish(); |
| 92 | + result.unwrap() |
| 93 | +} |
| 94 | + |
| 95 | +/// Creates a test repository with a couple of versions defined in the manifest and a couple of |
| 96 | +/// migrations. See the test description for for more info. |
| 97 | +fn create_test_repo() -> TestRepo { |
| 98 | + // This is where the signed TUF repo will exist when we are done. It is the |
| 99 | + // root directory of the `TestRepo` we will return when we are done. |
| 100 | + let test_repo_dir = TempDir::new().unwrap(); |
| 101 | + let metadata_path = test_repo_dir.path().join("metadata"); |
| 102 | + let targets_path = test_repo_dir.path().join("targets"); |
| 103 | + |
| 104 | + // This is where we will stage the TUF repository targets prior to signing them. We are using |
| 105 | + // symlinks from `tuf_indir` to `tuf_outdir/targets` so we keep both in the same `TempDir`. |
| 106 | + let tuf_indir = test_repo_dir.path(); |
| 107 | + |
| 108 | + // Create a Manifest and save it to the tuftool_indir for signing. |
| 109 | + let mut manifest = update_metadata::Manifest::default(); |
| 110 | + // insert the following migrations to the manifest. note that the first migration would sort |
| 111 | + // later than the second migration alphabetically. this is to help ensure that migrations |
| 112 | + // are running in their listed order (rather than sorted order as in previous |
| 113 | + // implementations). |
| 114 | + manifest.migrations.insert( |
| 115 | + (Version::new(0, 99, 0), Version::new(0, 99, 1)), |
| 116 | + vec![FIRST_MIGRATION.into(), SECOND_MIGRATION.into()], |
| 117 | + ); |
| 118 | + update_metadata::write_file(tuf_indir.join("manifest.json").as_path(), &manifest).unwrap(); |
| 119 | + |
| 120 | + // Create an script that we can use as the 'migration' that migrator will run. This script will |
| 121 | + // write its name and arguments to a file named result.txt in the directory that is the parent |
| 122 | + // of --source-datastore. result.txt can then be used to see what migrations ran, and in what |
| 123 | + // order. Note that tests are sensitive to the order and number of arguments passed. If |
| 124 | + // --source-datastore is given at a different position then the tests will fail and the script |
| 125 | + // will need to be updated. |
| 126 | + let migration_a = create_test_migration(FIRST_MIGRATION); |
| 127 | + let migration_b = create_test_migration(SECOND_MIGRATION); |
| 128 | + |
| 129 | + // Save lz4 compressed copies of the migration script into the tuftool_indir. |
| 130 | + compress(migration_a.as_bytes(), &tuf_indir.join(FIRST_MIGRATION)); |
| 131 | + compress(migration_b.as_bytes(), &tuf_indir.join(SECOND_MIGRATION)); |
| 132 | + |
| 133 | + // Create and sign the TUF repository. |
| 134 | + let mut editor = tough::editor::RepositoryEditor::new(root()).unwrap(); |
| 135 | + let long_ago: DateTime<Utc> = DateTime::parse_from_rfc3339("1970-01-01T00:00:00Z") |
| 136 | + .unwrap() |
| 137 | + .into(); |
| 138 | + let one = std::num::NonZeroU64::new(1).unwrap(); |
| 139 | + editor |
| 140 | + .targets_version(one) |
| 141 | + .targets_expires(long_ago) |
| 142 | + .snapshot_version(one) |
| 143 | + .snapshot_expires(long_ago) |
| 144 | + .timestamp_version(one) |
| 145 | + .timestamp_expires(long_ago); |
| 146 | + |
| 147 | + fs::read_dir(tuf_indir) |
| 148 | + .unwrap() |
| 149 | + .filter(|dir_entry_result| { |
| 150 | + if let Ok(dir_entry) = dir_entry_result { |
| 151 | + return dir_entry.path().is_file(); |
| 152 | + } |
| 153 | + false |
| 154 | + }) |
| 155 | + .for_each(|dir_entry_result| { |
| 156 | + let dir_entry = dir_entry_result.unwrap(); |
| 157 | + editor.add_target( |
| 158 | + dir_entry.file_name().to_str().unwrap().into(), |
| 159 | + tough::schema::Target::from_path(dir_entry.path()).unwrap(), |
| 160 | + ); |
| 161 | + }); |
| 162 | + let signed_repo = editor |
| 163 | + .sign(&[Box::new(tough::key_source::LocalKeySource { path: pem() })]) |
| 164 | + .unwrap(); |
| 165 | + signed_repo.link_targets(tuf_indir, &targets_path).unwrap(); |
| 166 | + signed_repo.write(&metadata_path).unwrap(); |
| 167 | + |
| 168 | + TestRepo { |
| 169 | + _tuf_dir: test_repo_dir, |
| 170 | + metadata_path, |
| 171 | + targets_path, |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +/// Tests the migrator program end-to-end using the `run` function. Creates a TUF repo in a |
| 176 | +/// tempdir which includes a `manifest.json` with a couple of migrations: |
| 177 | +/// ``` |
| 178 | +/// "(0.99.0, 0.99.1)": [ |
| 179 | +/// "b-first-migration", |
| 180 | +/// "a-second-migration" |
| 181 | +/// ] |
| 182 | +/// ``` |
| 183 | +/// |
| 184 | +/// The two 'migrations' are instances of the same bash script (see `create_test_repo`) which |
| 185 | +/// writes its name (i.e. the migration name) and its arguments to a file at `./result.txt` |
| 186 | +/// (i.e. since migrations run in the context of the datastore directory, `result.txt` is |
| 187 | +/// written one directory above the datastore.) We can then inspect the contents of `result.txt` |
| 188 | +/// to see that the expected migrations ran in the correct order. |
| 189 | +#[test] |
| 190 | +fn migrate_forward() { |
| 191 | + let from_version = Version::parse("0.99.0").unwrap(); |
| 192 | + let to_version = Version::parse("0.99.1").unwrap(); |
| 193 | + let test_datastore = TestDatastore::new(from_version); |
| 194 | + let test_repo = create_test_repo(); |
| 195 | + let args = Args { |
| 196 | + datastore_path: test_datastore.datastore.clone(), |
| 197 | + log_level: log::LevelFilter::Info, |
| 198 | + migration_directory: test_repo.targets_path.clone(), |
| 199 | + migrate_to_version: to_version, |
| 200 | + root_path: root(), |
| 201 | + metadata_directory: test_repo.metadata_path.clone(), |
| 202 | + }; |
| 203 | + run(&args).unwrap(); |
| 204 | + // the migrations should write to a file named result.txt. |
| 205 | + let output_file = test_datastore.tmp.path().join("result.txt"); |
| 206 | + let contents = std::fs::read_to_string(&output_file).unwrap(); |
| 207 | + let lines: Vec<&str> = contents.split('\n').collect(); |
| 208 | + assert_eq!(lines.len(), 3); |
| 209 | + let first_line = *lines.get(0).unwrap(); |
| 210 | + let want = format!("{}: --forward", FIRST_MIGRATION); |
| 211 | + let got: String = first_line.chars().take(want.len()).collect(); |
| 212 | + assert_eq!(got, want); |
| 213 | + let second_line = *lines.get(1).unwrap(); |
| 214 | + let want = format!("{}: --forward", SECOND_MIGRATION); |
| 215 | + let got: String = second_line.chars().take(want.len()).collect(); |
| 216 | + assert_eq!(got, want); |
| 217 | +} |
| 218 | + |
| 219 | +/// This test ensures that migrations run when migrating from a newer to an older version. |
| 220 | +/// See `migrate_forward` for a description of how these tests work. |
| 221 | +#[test] |
| 222 | +fn migrate_backward() { |
| 223 | + let from_version = Version::parse("0.99.1").unwrap(); |
| 224 | + let to_version = Version::parse("0.99.0").unwrap(); |
| 225 | + let test_datastore = TestDatastore::new(from_version); |
| 226 | + let test_repo = create_test_repo(); |
| 227 | + let args = Args { |
| 228 | + datastore_path: test_datastore.datastore.clone(), |
| 229 | + log_level: log::LevelFilter::Info, |
| 230 | + migration_directory: test_repo.targets_path.clone(), |
| 231 | + migrate_to_version: to_version, |
| 232 | + root_path: root(), |
| 233 | + metadata_directory: test_repo.metadata_path.clone(), |
| 234 | + }; |
| 235 | + run(&args).unwrap(); |
| 236 | + let output_file = test_datastore.tmp.path().join("result.txt"); |
| 237 | + let contents = std::fs::read_to_string(&output_file).unwrap(); |
| 238 | + let lines: Vec<&str> = contents.split('\n').collect(); |
| 239 | + assert_eq!(lines.len(), 3); |
| 240 | + let first_line = *lines.get(0).unwrap(); |
| 241 | + let want = format!("{}: --backward", SECOND_MIGRATION); |
| 242 | + let got: String = first_line.chars().take(want.len()).collect(); |
| 243 | + assert_eq!(got, want); |
| 244 | + let second_line = *lines.get(1).unwrap(); |
| 245 | + let want = format!("{}: --backward", FIRST_MIGRATION); |
| 246 | + let got: String = second_line.chars().take(want.len()).collect(); |
| 247 | + assert_eq!(got, want); |
| 248 | +} |
0 commit comments