From a892fd2aa68d0571f8d45b3ba82848c5ff09cbd1 Mon Sep 17 00:00:00 2001 From: Ryan Levick Date: Wed, 26 Jun 2024 13:38:08 +0200 Subject: [PATCH 1/3] Add redis tests Signed-off-by: Ryan Levick --- Cargo.lock | 8 + components/outbound-redis/Cargo.toml | 11 ++ components/outbound-redis/src/lib.rs | 174 ++++++++++++++++++ crates/conformance-tests/src/config.rs | 2 + tests/outbound-redis-no-permission/spin.toml | 14 ++ tests/outbound-redis-no-permission/test.json5 | 30 +++ .../spin.toml | 18 ++ .../test.json5 | 28 +++ tests/outbound-redis/spin.toml | 15 ++ tests/outbound-redis/test.json5 | 28 +++ 10 files changed, 328 insertions(+) create mode 100644 components/outbound-redis/Cargo.toml create mode 100644 components/outbound-redis/src/lib.rs create mode 100644 tests/outbound-redis-no-permission/spin.toml create mode 100644 tests/outbound-redis-no-permission/test.json5 create mode 100644 tests/outbound-redis-variable-permission/spin.toml create mode 100644 tests/outbound-redis-variable-permission/test.json5 create mode 100644 tests/outbound-redis/spin.toml create mode 100644 tests/outbound-redis/test.json5 diff --git a/Cargo.lock b/Cargo.lock index 32aa907..da6e6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,6 +832,14 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outbound-redis-test-component" +version = "0.1.0" +dependencies = [ + "anyhow", + "wit-bindgen", +] + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/components/outbound-redis/Cargo.toml b/components/outbound-redis/Cargo.toml new file mode 100644 index 0000000..23af389 --- /dev/null +++ b/components/outbound-redis/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "outbound-redis-test-component" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/outbound-redis/src/lib.rs b/components/outbound-redis/src/lib.rs new file mode 100644 index 0000000..94443bf --- /dev/null +++ b/components/outbound-redis/src/lib.rs @@ -0,0 +1,174 @@ +use anyhow::Context as _; +use bindings::{ + exports::wasi::http0_2_0::incoming_handler::Guest, + fermyon::spin2_0_0::redis, + wasi::http0_2_0::types::{ + Headers, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam, + }, +}; + +struct Component; + +mod bindings { + wit_bindgen::generate!({ + world: "http-trigger", + path: "../../wit", + }); + use super::Component; + export!(Component); +} + +impl Guest for Component { + fn handle(request: IncomingRequest, response_out: ResponseOutparam) { + let result = match handle(request) { + Err(e) => response(500, format!("{e}").as_bytes()), + Ok(r) => r, + }; + ResponseOutparam::set(response_out, Ok(result)) + } +} + +const REDIS_ADDRESS_HEADER: &str = "REDIS_ADDRESS"; + +fn handle(request: IncomingRequest) -> anyhow::Result { + let Some(address) = request.headers().entries().into_iter().find_map(|(k, v)| { + (k == REDIS_ADDRESS_HEADER) + .then_some(v) + .and_then(|v| String::from_utf8(v).ok()) + }) else { + // Otherwise, return a 400 Bad Request response. + return Ok(response(400, b"Bad Request")); + }; + let connection = redis::Connection::open(&address)?; + + connection.set("spin-example-get-set", &b"Eureka!".to_vec())?; + + let payload = connection + .get("spin-example-get-set")? + .context("missing value for 'spin-example-get-set'")?; + + anyhow::ensure!(String::from_utf8_lossy(&payload) == "Eureka!"); + + connection.set("spin-example-incr", &b"0".to_vec())?; + + let int_value = connection.incr("spin-example-incr")?; + + anyhow::ensure!(int_value == 1); + + let keys = vec!["spin-example-get-set".into(), "spin-example-incr".into()]; + + let del_keys = connection.del(&keys)?; + + anyhow::ensure!(del_keys == 2); + + connection.execute( + "set", + &[ + redis::RedisParameter::Binary(b"spin-example".to_vec()), + redis::RedisParameter::Binary(b"Eureka!".to_vec()), + ], + )?; + + connection.execute( + "append", + &[ + redis::RedisParameter::Binary(b"spin-example".to_vec()), + redis::RedisParameter::Binary(b" I've got it!".to_vec()), + ], + )?; + + let values = connection.execute( + "get", + &[redis::RedisParameter::Binary(b"spin-example".to_vec())], + )?; + + anyhow::ensure!(matches!( + values.as_slice(), + &[redis::RedisResult::Binary(ref b)] if b == b"Eureka! I've got it!")); + + connection.execute( + "set", + &[ + redis::RedisParameter::Binary(b"int-key".to_vec()), + redis::RedisParameter::Int64(0), + ], + )?; + + connection.execute( + "incr", + &[redis::RedisParameter::Binary(b"int-key".to_vec())], + )?; + + anyhow::ensure!(matches!(values.as_slice(), &[redis::RedisResult::Int64(1)])); + + let values = + connection.execute("get", &[redis::RedisParameter::Binary(b"int-key".to_vec())])?; + + anyhow::ensure!(matches!( + values.as_slice(), + &[redis::RedisResult::Binary(ref b)] if b == b"1" + )); + + connection.execute("del", &[redis::RedisParameter::Binary(b"foo".to_vec())])?; + + connection.execute( + "sadd", + &[ + redis::RedisParameter::Binary(b"foo".to_vec()), + redis::RedisParameter::Binary(b"bar".to_vec()), + redis::RedisParameter::Binary(b"baz".to_vec()), + ], + )?; + + let values = connection.execute( + "smembers", + &[redis::RedisParameter::Binary(b"foo".to_vec())], + )?; + let mut values = values + .iter() + .map(|v| match v { + redis::RedisResult::Binary(v) => Ok(v.as_slice()), + v => Err(anyhow::anyhow!("unexpected value: {v:?}")), + }) + .collect::>>()?; + // Ensure the values are always in a deterministic order + values.sort(); + + anyhow::ensure!(matches!(values.as_slice(), &[b"bar", b"baz",])); + + connection.execute( + "srem", + &[ + redis::RedisParameter::Binary(b"foo".to_vec()), + redis::RedisParameter::Binary(b"baz".to_vec()), + ], + )?; + + let values = connection.execute( + "smembers", + &[redis::RedisParameter::Binary(b"foo".to_vec())], + )?; + + anyhow::ensure!(matches!( + values.as_slice(), + &[redis::RedisResult::Binary(ref bar)] if bar == b"bar" + )); + + Ok(response(200, b"")) +} + +fn response(status: u16, body: &[u8]) -> OutgoingResponse { + let response = OutgoingResponse::new(Headers::new()); + response.set_status_code(status).unwrap(); + if !body.is_empty() { + assert!(body.len() <= 4096); + let outgoing_body = response.body().unwrap(); + { + let outgoing_stream = outgoing_body.write().unwrap(); + outgoing_stream.blocking_write_and_flush(body).unwrap(); + // The outgoing stream must be dropped before the outgoing body is finished. + } + OutgoingBody::finish(outgoing_body, None).unwrap(); + } + response +} diff --git a/crates/conformance-tests/src/config.rs b/crates/conformance-tests/src/config.rs index 4c6604c..603f0c3 100644 --- a/crates/conformance-tests/src/config.rs +++ b/crates/conformance-tests/src/config.rs @@ -174,6 +174,8 @@ pub enum Precondition { TcpEcho, /// The test expects a sqlite service to be available. Sqlite, + /// The test expects a Redis service to be available. + Redis, } #[derive(Debug, Clone, serde::Deserialize)] diff --git a/tests/outbound-redis-no-permission/spin.toml b/tests/outbound-redis-no-permission/spin.toml new file mode 100644 index 0000000..95e75b1 --- /dev/null +++ b/tests/outbound-redis-no-permission/spin.toml @@ -0,0 +1,14 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +environment = { REDIS_ADDRESS = "redis://localhost:6379" } diff --git a/tests/outbound-redis-no-permission/test.json5 b/tests/outbound-redis-no-permission/test.json5 new file mode 100644 index 0000000..4889d01 --- /dev/null +++ b/tests/outbound-redis-no-permission/test.json5 @@ -0,0 +1,30 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + } + ] + }, + "response": { + "status": 500, + "headers": [ + { + "name": "Content-Length", + "value": "0" + }, + { + "name": "Date", + "optional": true + } + ], + "body": "Error::InvalidAddress" + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file diff --git a/tests/outbound-redis-variable-permission/spin.toml b/tests/outbound-redis-variable-permission/spin.toml new file mode 100644 index 0000000..176e5b1 --- /dev/null +++ b/tests/outbound-redis-variable-permission/spin.toml @@ -0,0 +1,18 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[variables] +redis_host = { default = "localhost" } + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +environment = { REDIS_ADDRESS = "redis://localhost:%{port=6379}" } +allowed_outbound_hosts = ["redis://{{ redis_host }}:%{port=6379}"] diff --git a/tests/outbound-redis-variable-permission/test.json5 b/tests/outbound-redis-variable-permission/test.json5 new file mode 100644 index 0000000..ef06fcc --- /dev/null +++ b/tests/outbound-redis-variable-permission/test.json5 @@ -0,0 +1,28 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + } + ] + }, + "response": { + "headers": [ + { + "name": "Content-Length", + "value": "0" + }, + { + "name": "Date", + "optional": true + } + ], + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file diff --git a/tests/outbound-redis/spin.toml b/tests/outbound-redis/spin.toml new file mode 100644 index 0000000..15b54a3 --- /dev/null +++ b/tests/outbound-redis/spin.toml @@ -0,0 +1,15 @@ +spin_manifest_version = 2 + +[application] +name = "outbound-redis" +authors = ["Fermyon Engineering "] +version = "0.1.0" + +[[trigger.http]] +route = "/" +component = "test" + +[component.test] +source = "%{source=outbound-redis}" +environment = { REDIS_ADDRESS = "redis://localhost:%{port=6379}" } +allowed_outbound_hosts = ["redis://localhost:%{port=6379}"] diff --git a/tests/outbound-redis/test.json5 b/tests/outbound-redis/test.json5 new file mode 100644 index 0000000..f6398c1 --- /dev/null +++ b/tests/outbound-redis/test.json5 @@ -0,0 +1,28 @@ +{ + "invocations": [ + { + "request": { + "path": "/", + "headers": [ + { + "name": "Host", + "value": "example.com" + } + ] + }, + "response": { + "headers": [ + { + "name": "Content-Length", + "value": "0" + }, + { + "name": "Date", + "optional": true + } + ] + } + } + ], + "preconditions": [ { "kind": "redis" } ] +} \ No newline at end of file From 83b80afbc806eb7a86135328940590045cabfecf Mon Sep 17 00:00:00 2001 From: Ryan Levick Date: Wed, 26 Jun 2024 14:38:05 +0200 Subject: [PATCH 2/3] Fix tests Signed-off-by: Ryan Levick --- components/outbound-redis/src/lib.rs | 18 +++++++++++------- tests/outbound-redis-no-permission/test.json5 | 10 +++++++++- .../test.json5 | 4 ++++ tests/outbound-redis/spin.toml | 1 - tests/outbound-redis/test.json5 | 4 ++++ 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/components/outbound-redis/src/lib.rs b/components/outbound-redis/src/lib.rs index 94443bf..7739757 100644 --- a/components/outbound-redis/src/lib.rs +++ b/components/outbound-redis/src/lib.rs @@ -31,11 +31,12 @@ impl Guest for Component { const REDIS_ADDRESS_HEADER: &str = "REDIS_ADDRESS"; fn handle(request: IncomingRequest) -> anyhow::Result { - let Some(address) = request.headers().entries().into_iter().find_map(|(k, v)| { - (k == REDIS_ADDRESS_HEADER) - .then_some(v) - .and_then(|v| String::from_utf8(v).ok()) - }) else { + let Some(address) = request + .headers() + .get(&REDIS_ADDRESS_HEADER.to_owned()) + .pop() + .and_then(|v| String::from_utf8(v).ok()) + else { // Otherwise, return a 400 Bad Request response. return Ok(response(400, b"Bad Request")); }; @@ -94,12 +95,15 @@ fn handle(request: IncomingRequest) -> anyhow::Result { ], )?; - connection.execute( + let values = connection.execute( "incr", &[redis::RedisParameter::Binary(b"int-key".to_vec())], )?; - anyhow::ensure!(matches!(values.as_slice(), &[redis::RedisResult::Int64(1)])); + anyhow::ensure!( + matches!(values.as_slice(), &[redis::RedisResult::Int64(1)]), + "call to `execute('incr')` returned unexpected result: {values:?} != &[redis::RedisResult::Int64(1)]" + ); let values = connection.execute("get", &[redis::RedisParameter::Binary(b"int-key".to_vec())])?; diff --git a/tests/outbound-redis-no-permission/test.json5 b/tests/outbound-redis-no-permission/test.json5 index 4889d01..38f9d62 100644 --- a/tests/outbound-redis-no-permission/test.json5 +++ b/tests/outbound-redis-no-permission/test.json5 @@ -7,6 +7,10 @@ { "name": "Host", "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", } ] }, @@ -15,7 +19,11 @@ "headers": [ { "name": "Content-Length", - "value": "0" + "optional": true, + }, + { + "name": "transfer-encoding", + "optional": true }, { "name": "Date", diff --git a/tests/outbound-redis-variable-permission/test.json5 b/tests/outbound-redis-variable-permission/test.json5 index ef06fcc..2d1f889 100644 --- a/tests/outbound-redis-variable-permission/test.json5 +++ b/tests/outbound-redis-variable-permission/test.json5 @@ -7,6 +7,10 @@ { "name": "Host", "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", } ] }, diff --git a/tests/outbound-redis/spin.toml b/tests/outbound-redis/spin.toml index 15b54a3..30c0d22 100644 --- a/tests/outbound-redis/spin.toml +++ b/tests/outbound-redis/spin.toml @@ -11,5 +11,4 @@ component = "test" [component.test] source = "%{source=outbound-redis}" -environment = { REDIS_ADDRESS = "redis://localhost:%{port=6379}" } allowed_outbound_hosts = ["redis://localhost:%{port=6379}"] diff --git a/tests/outbound-redis/test.json5 b/tests/outbound-redis/test.json5 index f6398c1..8c201c5 100644 --- a/tests/outbound-redis/test.json5 +++ b/tests/outbound-redis/test.json5 @@ -7,6 +7,10 @@ { "name": "Host", "value": "example.com" + }, + { + "name": "redis_address", + "value": "redis://localhost:%{port=6379}", } ] }, From a49bdd49ab582d78da5829fd1ea7e8d816789fbd Mon Sep 17 00:00:00 2001 From: Ryan Levick Date: Wed, 26 Jun 2024 17:19:17 +0200 Subject: [PATCH 3/3] No need to so thoroughly test the redis 'exec' implementation Signed-off-by: Ryan Levick --- components/outbound-redis/src/lib.rs | 71 ---------------------------- 1 file changed, 71 deletions(-) diff --git a/components/outbound-redis/src/lib.rs b/components/outbound-redis/src/lib.rs index 7739757..193b99b 100644 --- a/components/outbound-redis/src/lib.rs +++ b/components/outbound-redis/src/lib.rs @@ -87,77 +87,6 @@ fn handle(request: IncomingRequest) -> anyhow::Result { values.as_slice(), &[redis::RedisResult::Binary(ref b)] if b == b"Eureka! I've got it!")); - connection.execute( - "set", - &[ - redis::RedisParameter::Binary(b"int-key".to_vec()), - redis::RedisParameter::Int64(0), - ], - )?; - - let values = connection.execute( - "incr", - &[redis::RedisParameter::Binary(b"int-key".to_vec())], - )?; - - anyhow::ensure!( - matches!(values.as_slice(), &[redis::RedisResult::Int64(1)]), - "call to `execute('incr')` returned unexpected result: {values:?} != &[redis::RedisResult::Int64(1)]" - ); - - let values = - connection.execute("get", &[redis::RedisParameter::Binary(b"int-key".to_vec())])?; - - anyhow::ensure!(matches!( - values.as_slice(), - &[redis::RedisResult::Binary(ref b)] if b == b"1" - )); - - connection.execute("del", &[redis::RedisParameter::Binary(b"foo".to_vec())])?; - - connection.execute( - "sadd", - &[ - redis::RedisParameter::Binary(b"foo".to_vec()), - redis::RedisParameter::Binary(b"bar".to_vec()), - redis::RedisParameter::Binary(b"baz".to_vec()), - ], - )?; - - let values = connection.execute( - "smembers", - &[redis::RedisParameter::Binary(b"foo".to_vec())], - )?; - let mut values = values - .iter() - .map(|v| match v { - redis::RedisResult::Binary(v) => Ok(v.as_slice()), - v => Err(anyhow::anyhow!("unexpected value: {v:?}")), - }) - .collect::>>()?; - // Ensure the values are always in a deterministic order - values.sort(); - - anyhow::ensure!(matches!(values.as_slice(), &[b"bar", b"baz",])); - - connection.execute( - "srem", - &[ - redis::RedisParameter::Binary(b"foo".to_vec()), - redis::RedisParameter::Binary(b"baz".to_vec()), - ], - )?; - - let values = connection.execute( - "smembers", - &[redis::RedisParameter::Binary(b"foo".to_vec())], - )?; - - anyhow::ensure!(matches!( - values.as_slice(), - &[redis::RedisResult::Binary(ref bar)] if bar == b"bar" - )); - Ok(response(200, b"")) }