diff --git a/.github/workflows/impl_tests.yml b/.github/workflows/implementation-tests.yml similarity index 80% rename from .github/workflows/impl_tests.yml rename to .github/workflows/implementation-tests.yml index d740a2f6..4aa629c7 100644 --- a/.github/workflows/impl_tests.yml +++ b/.github/workflows/implementation-tests.yml @@ -7,13 +7,14 @@ concurrency: cancel-in-progress: true jobs: - test-postgres-backend: + postgres-backend-tests: strategy: fail-fast: false matrix: platform: [ ubuntu-latest ] toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV runs-on: ${{ matrix.platform }} + timeout-minutes: 15 services: postgres: @@ -35,6 +36,10 @@ jobs: uses: actions/checkout@v3 with: path: vss-server + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} - name: Run postgres backend test suite run: | diff --git a/.github/workflows/ldk-node-integration.yml b/.github/workflows/ldk-node-integration-tests.yml similarity index 76% rename from .github/workflows/ldk-node-integration.yml rename to .github/workflows/ldk-node-integration-tests.yml index 73ce8202..707905c5 100644 --- a/.github/workflows/ldk-node-integration.yml +++ b/.github/workflows/ldk-node-integration-tests.yml @@ -7,7 +7,7 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: + ldk-node-integration-tests: strategy: fail-fast: false matrix: @@ -41,12 +41,21 @@ jobs: with: repository: lightningdevkit/ldk-node path: ldk-node + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} - name: Build and Deploy VSS Server run: | cd vss-server/rust RUSTFLAGS="--cfg noop_authorizer" cargo build --no-default-features ./target/debug/vss-server server/vss-server-config.toml& + - name: Pin packages to allow for MSRV + if: "matrix.toolchain == '1.85.0'" + run: | + cd ldk-node + cargo update -p idna_adapter --precise "1.2.0" --verbose # idna_adapter 1.2.1 uses ICU4X 2.2.0, requiring 1.86 and newer - name: Run LDK Node Integration tests run: | cd ldk-node diff --git a/.github/workflows/build-and-deploy-rust.yml b/.github/workflows/ping-tests.yml similarity index 83% rename from .github/workflows/build-and-deploy-rust.yml rename to .github/workflows/ping-tests.yml index 1cc7aefa..10b7a22f 100644 --- a/.github/workflows/build-and-deploy-rust.yml +++ b/.github/workflows/ping-tests.yml @@ -1,4 +1,4 @@ -name: Ping Check +name: Ping Tests on: [push, pull_request] @@ -7,13 +7,14 @@ concurrency: cancel-in-progress: true jobs: - ping-check: + ping-tests: strategy: fail-fast: false matrix: platform: [ ubuntu-latest ] toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV runs-on: ${{ matrix.platform }} + timeout-minutes: 15 services: postgres: @@ -33,8 +34,12 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal --component rustfmt + rustup default ${{ matrix.toolchain }} - name: Check formatting - run: rustup component add rustfmt && cd rust && cargo fmt --all -- --check + run: cd rust && cargo fmt --all -- --check - name: Build and Deploy VSS Server run: | cd rust diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml new file mode 100644 index 00000000..c90b2dd3 --- /dev/null +++ b/.github/workflows/server-tests.yml @@ -0,0 +1,50 @@ +name: Server Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + server-tests: + strategy: + fail-fast: false + matrix: + platform: [ ubuntu-latest ] + toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV + runs-on: ${{ matrix.platform }} + timeout-minutes: 15 + + services: + postgres: + image: postgres:latest + ports: + - 5432:5432 + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} + - name: Build and Deploy VSS Server + run: | + cd rust + RUSTFLAGS="--cfg noop_authorizer" cargo build --no-default-features + ./target/debug/vss-server server/vss-server-config.toml& + - name: Run the server tests + run: | + sleep 5 + cd rust/server + cargo test diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9c67c152..0aab3dfa 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ "hex-conservative 1.0.1", "jsonwebtoken", "openssl", - "secp256k1", + "secp256k1 0.31.1", "serde", "serde_json", "tokio", @@ -83,21 +83,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1 0.29.1", +] + [[package]] name = "bitcoin-consensus-encoding" version = "1.0.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cd69023e5db2f3f7241672de6be29408373ba0ff407e7fda71d70d728bec05a" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-internals" version = "0.5.0" @@ -110,6 +149,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals 0.3.0", +] + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -127,7 +175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aaf7add9aa250546d4d7a0ad0755a25327f5205dc2d7eba6b6ec08cd864c79e" dependencies = [ "bitcoin-consensus-encoding", - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -136,6 +184,19 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bitreq" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df90cd78f0510165fd370574676aeb57dbec0ee3bfff68645bb7b0e9a65dbd5" +dependencies = [ + "rustls", + "rustls-webpki", + "tokio", + "tokio-rustls", + "webpki-roots", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -179,6 +240,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20-poly1305" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4b0fc281743d80256607bd65e8beedc42cb0787ea119c85b81b4c0eab85e5f" + [[package]] name = "chrono" version = "0.4.43" @@ -411,6 +478,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366fa3443ac84474447710ec17bb00b05dfbd096137817981e86f992f21a2793" +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hmac" version = "0.12.1" @@ -1149,6 +1222,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1170,6 +1277,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.5", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" @@ -1178,7 +1296,16 @@ checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes 0.14.1", "rand 0.9.2", - "secp256k1-sys", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", ] [[package]] @@ -1507,6 +1634,16 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1605,6 +1742,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vss-client-ng" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6334cb4940aba86a2e2aa9dde7c722a2510f55815422088a2a2ac24f46579e6a" +dependencies = [ + "async-trait", + "base64", + "bitcoin", + "bitcoin_hashes 0.14.1", + "bitreq", + "chacha20-poly1305", + "log", + "prost", + "prost-build", + "rand 0.8.5", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "vss-server" version = "0.1.0" @@ -1623,6 +1781,7 @@ dependencies = [ "serde", "tokio", "toml", + "vss-client-ng", ] [[package]] @@ -1713,6 +1872,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -1995,6 +2163,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.19" diff --git a/rust/server/Cargo.toml b/rust/server/Cargo.toml index c3a44698..ecb6c817 100644 --- a/rust/server/Cargo.toml +++ b/rust/server/Cargo.toml @@ -29,5 +29,8 @@ rand = { version = "0.9.2", default-features = false } [target.'cfg(noop_authorizer)'.dependencies] api = { path = "../api", features = ["_test_utils"] } +[dev-dependencies] +vss-client-v050 = { package = "vss-client-ng", version = "=0.5.0" } + [lints] workspace = true diff --git a/rust/server/tests/vss_client_v050_compatibility.rs b/rust/server/tests/vss_client_v050_compatibility.rs new file mode 100644 index 00000000..64fd17da --- /dev/null +++ b/rust/server/tests/vss_client_v050_compatibility.rs @@ -0,0 +1,326 @@ +//! Compatibility shakedown for the pinned vss-client-ng v0.5.0 dependency against current +//! vss-server master. This test assumes a no-auth VSS server is already running at +//! `localhost:8080` and exercises a full client lifecycle through the public v0.5.0 client API: +//! empty listing, missing-key reads, conditional and non-conditional writes, gets, conflict +//! handling, transactional put/delete, direct deletes, paginated listing, and cleanup. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use vss_client_v050::client::VssClient; +use vss_client_v050::error::VssError; +use vss_client_v050::types::{ + DeleteObjectRequest, GetObjectRequest, GetObjectResponse, KeyValue, ListKeyVersionsRequest, + PutObjectRequest, +}; +use vss_client_v050::util::retry::{ExponentialBackoffRetryPolicy, RetryPolicy}; + +const VSS_SERVER_BASE_URL: &str = "http://localhost:8080/vss"; +const KEY_ALPHA: &str = "compat/alpha"; +const KEY_BETA: &str = "compat/beta"; +const KEY_DELTA: &str = "compat/delta"; +const KEY_EPSILON: &str = "compat/epsilon"; +const KEY_GAMMA: &str = "compat/gamma"; +const KEY_OUTSIDE_PREFIX: &str = "outside-prefix"; +const KEY_STALE_GLOBAL: &str = "compat/stale-global"; +const KEY_THETA: &str = "compat/theta"; +const KEY_PREFIX: &str = "compat/"; +const GLOBAL_VERSION_KEY: &str = "global_version"; +const LIST_PAGE_SIZE: i32 = 2; + +#[tokio::test] +async fn test_vss_client_v050_compatibility() -> Result<(), VssError> { + let client = VssClient::new(VSS_SERVER_BASE_URL.to_string(), retry_policy()); + let store_id = unique_store_id(); + let mut global_version = 0; + + let empty_list = + client.list_key_versions(&list_request(&store_id, None, Some(10), None)).await?; + // A new store should report the initial global version. + assert_eq!(empty_list.global_version, Some(global_version)); + // A new store should not contain any key-version entries. + assert!(empty_list.key_versions.is_empty()); + // An empty result set should also be the final page. + assert_eq!(empty_list.next_page_token.as_deref(), Some("")); + + // Reading a key that has never been written should surface the protocol's missing-key error. + assert_no_such_key(client.get_object(&get_request(&store_id, "missing")).await, "missing"); + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_ALPHA, 0, b"alpha-v1"), kv(KEY_BETA, 0, b"beta-v1")], + vec![], + )) + .await?; + global_version += 1; + + // The first conditional write should make alpha readable at server-side version 1. + assert_key_value(&client, &store_id, KEY_ALPHA, 1, b"alpha-v1").await?; + // The first conditional write should make beta readable at server-side version 1. + assert_key_value(&client, &store_id, KEY_BETA, 1, b"beta-v1").await?; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![ + kv(KEY_ALPHA, 1, b"alpha-v2"), + kv(KEY_GAMMA, 0, b"gamma-v1"), + kv(KEY_OUTSIDE_PREFIX, 0, b"outside-prefix-v1"), + ], + vec![], + )) + .await?; + global_version += 1; + + // Updating alpha with the matching key version should advance alpha to version 2. + assert_key_value(&client, &store_id, KEY_ALPHA, 2, b"alpha-v2").await?; + // Creating gamma in the same request should make it readable at version 1. + assert_key_value(&client, &store_id, KEY_GAMMA, 1, b"gamma-v1").await?; + + let stale_put = client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_ALPHA, 1, b"stale-alpha")], + vec![], + )) + .await; + // Reusing alpha's old key version should be rejected as a conflict. + assert_conflict(stale_put); + // The rejected stale write must not change alpha's committed value. + assert_key_value(&client, &store_id, KEY_ALPHA, 2, b"alpha-v2").await?; + + let stale_global_version_put = client + .put_object(&put_request( + &store_id, + Some(global_version - 1), + vec![kv(KEY_STALE_GLOBAL, 0, b"stale-global-version")], + vec![], + )) + .await; + // Reusing an old global version should be rejected independently of key-level versions. + assert_conflict(stale_global_version_put); + // A failed global-version write must not create the requested key. + assert_no_such_key( + client.get_object(&get_request(&store_id, KEY_STALE_GLOBAL)).await, + KEY_STALE_GLOBAL, + ); + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_DELTA, -1, b"delta-v1")], + vec![], + )) + .await?; + global_version += 1; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_DELTA, -1, b"delta-v2")], + vec![], + )) + .await?; + global_version += 1; + + // Non-conditional writes should reset the server-side key version to 1 and keep the last value. + assert_key_value(&client, &store_id, KEY_DELTA, 1, b"delta-v2").await?; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_THETA, 0, b"theta-v1")], + vec![kv(KEY_BETA, 1, b"")], + )) + .await?; + global_version += 1; + + // A transaction mixing a put and delete should commit the put side. + assert_key_value(&client, &store_id, KEY_THETA, 1, b"theta-v1").await?; + // The same transaction should remove beta atomically. + assert_no_such_key(client.get_object(&get_request(&store_id, KEY_BETA)).await, KEY_BETA); + + client.delete_object(&delete_request(&store_id, KEY_GAMMA, 1)).await?; + client.delete_object(&delete_request(&store_id, KEY_GAMMA, 1)).await?; + // Repeating a direct delete should leave gamma deleted and exercise delete idempotency. + assert_no_such_key(client.get_object(&get_request(&store_id, KEY_GAMMA)).await, KEY_GAMMA); + + client + .put_object(&put_request(&store_id, None, vec![kv(KEY_EPSILON, 0, b"epsilon-v1")], vec![])) + .await?; + // A write without global-version checking should still create the key at version 1. + assert_key_value(&client, &store_id, KEY_EPSILON, 1, b"epsilon-v1").await?; + + let listed_versions = + list_all_key_versions(&client, &store_id, Some(KEY_PREFIX), global_version).await?; + let listed_keys: BTreeSet<&str> = listed_versions.keys().map(String::as_str).collect(); + // Prefix listing should include only the live keys under compat/ after deletes and conflicts. + assert_eq!(listed_keys, BTreeSet::from([KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA])); + // Listing should report alpha's latest key version. + assert_eq!(listed_versions[KEY_ALPHA], 2); + // Listing should report delta's non-conditional write version. + assert_eq!(listed_versions[KEY_DELTA], 1); + // Listing should report epsilon's no-global-version write version. + assert_eq!(listed_versions[KEY_EPSILON], 1); + // Listing should report theta's transactional write version. + assert_eq!(listed_versions[KEY_THETA], 1); + + let cleanup_keys = + [KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA, KEY_OUTSIDE_PREFIX, GLOBAL_VERSION_KEY]; + for key in cleanup_keys { + client.delete_object(&delete_request(&store_id, key, -1)).await?; + } + + let final_list = + client.list_key_versions(&list_request(&store_id, None, Some(10), None)).await?; + // Deleting the protocol global-version key should make the store report the default version. + assert_eq!(final_list.global_version, Some(0)); + // Cleanup should leave no key-version entries behind for this store. + assert!(final_list.key_versions.is_empty()); + // Cleanup should leave the final list response on its last page. + assert_eq!(final_list.next_page_token.as_deref(), Some("")); + + Ok(()) +} + +fn retry_policy() -> impl RetryPolicy { + ExponentialBackoffRetryPolicy::new(Duration::from_millis(10)).with_max_attempts(1) +} + +fn unique_store_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after UNIX epoch") + .as_nanos(); + format!("v050-compat-{nanos}") +} + +fn get_request(store_id: &str, key: &str) -> GetObjectRequest { + GetObjectRequest { store_id: store_id.to_string(), key: key.to_string() } +} + +fn put_request( + store_id: &str, global_version: Option, transaction_items: Vec, + delete_items: Vec, +) -> PutObjectRequest { + PutObjectRequest { + store_id: store_id.to_string(), + global_version, + transaction_items, + delete_items, + } +} + +fn delete_request(store_id: &str, key: &str, version: i64) -> DeleteObjectRequest { + DeleteObjectRequest { store_id: store_id.to_string(), key_value: Some(kv(key, version, b"")) } +} + +fn list_request( + store_id: &str, page_token: Option, page_size: Option, key_prefix: Option<&str>, +) -> ListKeyVersionsRequest { + ListKeyVersionsRequest { + store_id: store_id.to_string(), + key_prefix: key_prefix.map(str::to_string), + page_size, + page_token, + } +} + +fn kv(key: &str, version: i64, value: &[u8]) -> KeyValue { + KeyValue { key: key.to_string(), version, value: value.to_vec() } +} + +async fn assert_key_value( + client: &VssClient>, store_id: &str, key: &str, + expected_version: i64, expected_value: &[u8], +) -> Result<(), VssError> { + let response = client.get_object(&get_request(store_id, key)).await?; + let value = response_value(response, key); + // The server must echo the requested key in a successful get response. + assert_eq!(value.key, key); + // The key-level version must match the lifecycle step's expected version. + assert_eq!(value.version, expected_version); + // The stored bytes must round-trip unchanged through the v0.5.0 client. + assert_eq!(value.value, expected_value); + Ok(()) +} + +fn response_value(response: GetObjectResponse, key: &str) -> KeyValue { + // A successful get response must include a KeyValue payload. + response.value.unwrap_or_else(|| panic!("expected GetObjectResponse to include {key}")) +} + +fn assert_no_such_key(result: Result, key: &str) { + match result { + // The expected protocol error is the only accepted missing-key outcome. + Err(VssError::NoSuchKeyError(_)) => {}, + // Any other error would indicate the request failed for the wrong reason. + Err(e) => panic!("expected {key} to be missing, got {e}"), + // A successful get would mean the key unexpectedly exists. + Ok(_) => panic!("expected {key} to be missing"), + } +} + +fn assert_conflict(result: Result) { + match result { + // The expected protocol error is the only accepted conflict outcome. + Err(VssError::ConflictError(_)) => {}, + // Any other error would indicate the rejected write failed for the wrong reason. + Err(e) => panic!("expected conflict error, got {e}"), + // A successful write would mean conflict detection is not working. + Ok(_) => panic!("expected conflict error"), + } +} + +async fn list_all_key_versions( + client: &VssClient>, store_id: &str, key_prefix: Option<&str>, + expected_global_version: i64, +) -> Result, VssError> { + let mut page_token = None; + let mut key_versions = BTreeMap::new(); + let mut page_count = 0; + + loop { + let page = client + .list_key_versions(&list_request( + store_id, + page_token.take(), + Some(LIST_PAGE_SIZE), + key_prefix, + )) + .await?; + // Each paginated response must honor the requested maximum page size. + assert!(page.key_versions.len() <= LIST_PAGE_SIZE as usize); + + if page_count == 0 { + // Only the first page should include the store's global version. + assert_eq!(page.global_version, Some(expected_global_version)); + } else { + // Follow-up pages should omit the global version per the VSS protocol. + assert!(page.global_version.is_none()); + } + page_count += 1; + + for key_value in page.key_versions { + // List responses should include only key/version metadata, not stored values. + assert!(key_value.value.is_empty()); + key_versions.insert(key_value.key, key_value.version); + } + + match page.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + + // With four matching keys and a page size of two, this path must exercise pagination. + assert!(page_count > 1); + Ok(key_versions) +}