add tests

This commit is contained in:
trinity-1686a 2024-12-22 15:20:09 +01:00
parent c9b733a4a6
commit 65e9dde8c9
3 changed files with 538 additions and 49 deletions

View file

@ -25,8 +25,28 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
suffix: Value(website.index_document.to_string()), suffix: Value(website.index_document.to_string()),
}), }),
redirect_all_requests_to: None, redirect_all_requests_to: None,
// TODO put the correct config here routing_rules: RoutingRules {
routing_rules: Vec::new(), rules: website
.routing_rules
.clone()
.into_iter()
.map(|rule| RoutingRule {
condition: rule.condition.map(|cond| Condition {
http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)),
prefix: cond.prefix.map(Value),
}),
redirect: Redirect {
hostname: rule.redirect.hostname.map(Value),
http_redirect_code: Some(IntValue(
rule.redirect.http_redirect_code as i64,
)),
protocol: rule.redirect.protocol.map(Value),
replace_full: rule.redirect.replace_key.map(Value),
replace_prefix: rule.redirect.replace_key_prefix.map(Value),
},
})
.collect(),
},
}; };
let xml = to_xml_with_header(&wc)?; let xml = to_xml_with_header(&wc)?;
Ok(Response::builder() Ok(Response::builder()
@ -105,19 +125,25 @@ pub struct WebsiteConfiguration {
#[serde( #[serde(
rename = "RoutingRules", rename = "RoutingRules",
default, default,
skip_serializing_if = "Vec::is_empty" skip_serializing_if = "RoutingRules::is_empty"
)] )]
pub routing_rules: Vec<RoutingRule>, pub routing_rules: RoutingRules,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct RoutingRules {
#[serde(rename = "RoutingRule")]
pub rules: Vec<RoutingRule>,
}
impl RoutingRules {
fn is_empty(&self) -> bool {
self.rules.is_empty()
}
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutingRule { pub struct RoutingRule {
#[serde(rename = "RoutingRule")]
pub inner: RoutingRuleInner,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutingRuleInner {
#[serde(rename = "Condition")] #[serde(rename = "Condition")]
pub condition: Option<Condition>, pub condition: Option<Condition>,
#[serde(rename = "Redirect")] #[serde(rename = "Redirect")]
@ -186,10 +212,10 @@ impl WebsiteConfiguration {
if let Some(ref rart) = self.redirect_all_requests_to { if let Some(ref rart) = self.redirect_all_requests_to {
rart.validate()?; rart.validate()?;
} }
for rr in &self.routing_rules { for rr in &self.routing_rules.rules {
rr.inner.validate()?; rr.validate()?;
} }
if self.routing_rules.len() > 1000 { if self.routing_rules.rules.len() > 1000 {
// we will do linear scans, best to avoid overly long configuration. The // we will do linear scans, best to avoid overly long configuration. The
// limit was choosen arbitrarily // limit was choosen arbitrarily
return Err(Error::bad_request( return Err(Error::bad_request(
@ -205,12 +231,6 @@ impl WebsiteConfiguration {
Err(Error::NotImplemented( Err(Error::NotImplemented(
"S3 website redirects are not currently implemented in Garage.".into(), "S3 website redirects are not currently implemented in Garage.".into(),
)) ))
/*
} else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) {
Err(Error::NotImplemented(
"S3 routing rules are not currently implemented in Garage.".into(),
))
*/
} else { } else {
Ok(WebsiteConfig { Ok(WebsiteConfig {
index_document: self index_document: self
@ -220,29 +240,27 @@ impl WebsiteConfiguration {
error_document: self.error_document.map(|x| x.key.0), error_document: self.error_document.map(|x| x.key.0),
routing_rules: self routing_rules: self
.routing_rules .routing_rules
.rules
.into_iter() .into_iter()
.map(|rule| { .map(|rule| {
bucket_table::RoutingRule { bucket_table::RoutingRule {
condition: rule.inner.condition.map(|condition| { condition: rule.condition.map(|condition| bucket_table::Condition {
bucket_table::Condition {
http_error_code: condition.http_error_code.map(|c| c.0 as u16), http_error_code: condition.http_error_code.map(|c| c.0 as u16),
prefix: condition.prefix.map(|p| p.0), prefix: condition.prefix.map(|p| p.0),
}
}), }),
redirect: bucket_table::Redirect { redirect: bucket_table::Redirect {
hostname: rule.inner.redirect.hostname.map(|h| h.0), hostname: rule.redirect.hostname.map(|h| h.0),
protocol: rule.inner.redirect.protocol.map(|p| p.0), protocol: rule.redirect.protocol.map(|p| p.0),
// aws default to 301, which i find punitive in case of // aws default to 301, which i find punitive in case of
// missconfiguration (can be permanently cached on the // missconfiguration (can be permanently cached on the
// user agent) // user agent)
http_redirect_code: rule http_redirect_code: rule
.inner
.redirect .redirect
.http_redirect_code .http_redirect_code
.map(|c| c.0 as u16) .map(|c| c.0 as u16)
.unwrap_or(302), .unwrap_or(302),
replace_key_prefix: rule.inner.redirect.replace_prefix.map(|k| k.0), replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0),
replace_key: rule.inner.redirect.replace_full.map(|k| k.0), replace_key: rule.redirect.replace_full.map(|k| k.0),
}, },
} }
}) })
@ -287,7 +305,7 @@ impl Target {
} }
} }
impl RoutingRuleInner { impl RoutingRule {
pub fn validate(&self) -> Result<(), Error> { pub fn validate(&self) -> Result<(), Error> {
if let Some(condition) = &self.condition { if let Some(condition) = &self.condition {
condition.validate()?; condition.validate()?;
@ -390,6 +408,15 @@ mod tests {
<ReplaceKeyWith>fullkey</ReplaceKeyWith> <ReplaceKeyWith>fullkey</ReplaceKeyWith>
</Redirect> </Redirect>
</RoutingRule> </RoutingRule>
<RoutingRule>
<Condition>
<KeyPrefixEquals></KeyPrefixEquals>
</Condition>
<Redirect>
<HttpRedirectCode>404</HttpRedirectCode>
<ReplaceKeyWith>missing</ReplaceKeyWith>
</Redirect>
</RoutingRule>
</RoutingRules> </RoutingRules>
</WebsiteConfiguration>"#; </WebsiteConfiguration>"#;
let conf: WebsiteConfiguration = from_str(message).unwrap(); let conf: WebsiteConfiguration = from_str(message).unwrap();
@ -405,8 +432,9 @@ mod tests {
hostname: Value("garage.tld".to_owned()), hostname: Value("garage.tld".to_owned()),
protocol: Some(Value("https".to_owned())), protocol: Some(Value("https".to_owned())),
}), }),
routing_rules: vec![RoutingRule { routing_rules: RoutingRules {
inner: RoutingRuleInner { rules: vec![
RoutingRule {
condition: Some(Condition { condition: Some(Condition {
http_error_code: Some(IntValue(404)), http_error_code: Some(IntValue(404)),
prefix: Some(Value("prefix1".to_owned())), prefix: Some(Value("prefix1".to_owned())),
@ -419,7 +447,21 @@ mod tests {
replace_full: Some(Value("fullkey".to_owned())), replace_full: Some(Value("fullkey".to_owned())),
}, },
}, },
}], RoutingRule {
condition: Some(Condition {
http_error_code: None,
prefix: Some(Value("".to_owned())),
}),
redirect: Redirect {
hostname: None,
protocol: None,
http_redirect_code: Some(IntValue(404)),
replace_prefix: None,
replace_full: Some(Value("missing".to_owned())),
},
},
],
},
}; };
assert_eq! { assert_eq! {
ref_value, ref_value,

View file

@ -5,7 +5,10 @@ use crate::json_body;
use assert_json_diff::assert_json_eq; use assert_json_diff::assert_json_eq;
use aws_sdk_s3::{ use aws_sdk_s3::{
primitives::ByteStream, primitives::ByteStream,
types::{CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, WebsiteConfiguration}, types::{
Condition, CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, Protocol, Redirect,
RoutingRule, WebsiteConfiguration,
},
}; };
use http::{Request, StatusCode}; use http::{Request, StatusCode};
use http_body_util::BodyExt; use http_body_util::BodyExt;
@ -505,3 +508,444 @@ async fn test_website_check_domain() {
}) })
); );
} }
#[tokio::test]
async fn test_website_redirect_full_bucket() {
const BCKT_NAME: &str = "my-redirect-full";
let ctx = common::context();
let bucket = ctx.create_bucket(BCKT_NAME);
let conf = WebsiteConfiguration::builder()
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().key_prefix_equals("").build())
.redirect(
Redirect::builder()
.protocol(Protocol::Https)
.host_name("other.tld")
.replace_key_prefix_with("")
.build(),
)
.build(),
)
.build();
ctx.client
.put_bucket_website()
.bucket(&bucket)
.website_configuration(conf)
.send()
.await
.unwrap();
let req = Request::builder()
.method("GET")
.uri(format!("http://127.0.0.1:{}/my-path", ctx.garage.web_port))
.header("Host", format!("{}.web.garage", BCKT_NAME))
.body(Body::new(Bytes::new()))
.unwrap();
let client = Client::builder(TokioExecutor::new()).build_http();
let resp = client.request(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers()
.get(hyper::header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"https://other.tld/my-path"
);
}
#[tokio::test]
async fn test_website_redirect() {
const BCKT_NAME: &str = "my-redirect";
let ctx = common::context();
let bucket = ctx.create_bucket(BCKT_NAME);
ctx.client
.put_object()
.bucket(&bucket)
.key("index.html")
.body(ByteStream::from_static(b"index"))
.send()
.await
.unwrap();
ctx.client
.put_object()
.bucket(&bucket)
.key("404.html")
.body(ByteStream::from_static(b"main 404"))
.send()
.await
.unwrap();
ctx.client
.put_object()
.bucket(&bucket)
.key("static-file")
.body(ByteStream::from_static(b"static file"))
.send()
.await
.unwrap();
let mut conf = WebsiteConfiguration::builder()
.index_document(
IndexDocument::builder()
.suffix("home.html")
.build()
.unwrap(),
)
.error_document(ErrorDocument::builder().key("404.html").build().unwrap());
for (prefix, condition) in [("unconditional", false), ("conditional", true)] {
let code = condition.then(|| "404".to_string());
conf = conf
// simple redirect
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/redirect-prefix/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("302")
.replace_key_prefix_with("other-prefix/")
.build(),
)
.build(),
)
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/redirect-prefix-307/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("307")
.replace_key_prefix_with("other-prefix/")
.build(),
)
.build(),
)
// simple redirect
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/redirect-fixed/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("302")
.replace_key_with("fixed_key")
.build(),
)
.build(),
)
// stream other file
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/stream-fixed/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("200")
.replace_key_with("static-file")
.build(),
)
.build(),
)
// stream other file as error
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/stream-404/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("404")
.replace_key_with("static-file")
.build(),
)
.build(),
)
// fail to stream other file
.routing_rules(
RoutingRule::builder()
.condition(
Condition::builder()
.set_http_error_code_returned_equals(code.clone())
.key_prefix_equals(format!("{prefix}/stream-missing/"))
.build(),
)
.redirect(
Redirect::builder()
.http_redirect_code("200")
.replace_key_with("missing-file")
.build(),
)
.build(),
);
}
let conf = conf.build();
ctx.client
.put_bucket_website()
.bucket(&bucket)
.website_configuration(conf.clone())
.send()
.await
.unwrap();
let stored_cfg = ctx
.client
.get_bucket_website()
.bucket(&bucket)
.send()
.await
.unwrap();
assert_eq!(stored_cfg.index_document, conf.index_document);
assert_eq!(stored_cfg.error_document, conf.error_document);
assert_eq!(stored_cfg.routing_rules, conf.routing_rules);
let req = |path| {
Request::builder()
.method("GET")
.uri(format!(
"http://127.0.0.1:{}/{}/path",
ctx.garage.web_port, path
))
.header("Host", format!("{}.web.garage", BCKT_NAME))
.body(Body::new(Bytes::new()))
.unwrap()
};
test_redirect_helper("unconditional", true, &req).await;
test_redirect_helper("conditional", true, &req).await;
for prefix in ["unconditional", "conditional"] {
for rule_path in [
"redirect-prefix",
"redirect-prefix-307",
"redirect-fixed",
"stream-fixed",
"stream-404",
"stream-missing",
] {
ctx.client
.put_object()
.bucket(&bucket)
.key(format!("{prefix}/{rule_path}/path"))
.body(ByteStream::from_static(b"i exist"))
.send()
.await
.unwrap();
}
}
test_redirect_helper("unconditional", true, &req).await;
test_redirect_helper("conditional", false, &req).await;
}
async fn test_redirect_helper(
prefix: &str,
should_see_redirect: bool,
req: impl Fn(String) -> Request<http_body_util::Full<Bytes>>,
) {
use http::header;
let client = Client::builder(TokioExecutor::new()).build_http();
let expected_body = b"i exist".as_ref();
let resp = client
.request(req(format!("{prefix}/redirect-prefix")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/other-prefix/path"
);
assert!(resp
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
let resp = client
.request(req(format!("{prefix}/redirect-prefix-307")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/other-prefix/path"
);
assert!(resp
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
let resp = client
.request(req(format!("{prefix}/redirect-fixed")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/fixed_key"
);
assert!(resp
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
let resp = client
.request(req(format!("{prefix}/stream-fixed")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
b"static file".as_ref(),
);
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
let resp = client
.request(req(format!("{prefix}/stream-404")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
b"static file".as_ref(),
);
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
let resp = client
.request(req(format!("{prefix}/stream-404")))
.await
.unwrap();
if should_see_redirect {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
b"static file".as_ref(),
);
} else {
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get(header::LOCATION).is_none());
assert_eq!(
resp.into_body().collect().await.unwrap().to_bytes(),
expected_body,
);
}
}
#[tokio::test]
async fn test_website_invalid_redirect() {
const BCKT_NAME: &str = "my-invalid-redirect";
let ctx = common::context();
let bucket = ctx.create_bucket(BCKT_NAME);
let conf = WebsiteConfiguration::builder()
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().key_prefix_equals("").build())
.redirect(
Redirect::builder()
.protocol(Protocol::Https)
.host_name("other.tld")
.replace_key_prefix_with("")
// we don't allow 200 with hostname
.http_redirect_code("200")
.build(),
)
.build(),
)
.build();
ctx.client
.put_bucket_website()
.bucket(&bucket)
.website_configuration(conf)
.send()
.await
.unwrap_err();
}

View file

@ -235,7 +235,7 @@ impl WebServer {
// Get path // Get path
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let index = &website_config.index_document; let index = &website_config.index_document;
let routing_result = path_to_keys(&path, index, &[])?; let routing_result = path_to_keys(&path, index, &website_config.routing_rules)?;
debug!( debug!(
"Selected bucket: \"{}\" {:?}, routing to {:?}", "Selected bucket: \"{}\" {:?}, routing to {:?}",
@ -518,7 +518,7 @@ fn path_to_keys<'a>(
} else { } else {
None None
}; };
let target = routing_rule.redirect.compute_target(suffix); let mut target = routing_rule.redirect.compute_target(suffix);
let query_alternative_key = let query_alternative_key =
status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND; status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND;
let redirect_on_error = let redirect_on_error =
@ -531,6 +531,8 @@ fn path_to_keys<'a>(
}) })
} }
(true, false) => { (true, false) => {
// we need to remove the leading /
target.remove(0);
if status_code == StatusCode::OK { if status_code == StatusCode::OK {
break target; break target;
} else { } else {
@ -549,6 +551,7 @@ fn path_to_keys<'a>(
}); });
} }
(true, true) => { (true, true) => {
target.remove(0);
return Ok(RoutingResult::LoadOrAlternativeError { return Ok(RoutingResult::LoadOrAlternativeError {
key, key,
redirect_key: target, redirect_key: target,
@ -573,14 +576,14 @@ fn path_to_keys<'a>(
} else { } else {
Ok(RoutingResult::LoadOrRedirect { Ok(RoutingResult::LoadOrRedirect {
redirect_if_exists: Some(format!("{key}/{index}")), redirect_if_exists: Some(format!("{key}/{index}")),
key,
// we can't use `path` because key might have changed substentially in case of // we can't use `path` because key might have changed substentially in case of
// routing rules // routing rules
redirect_url: percent_encoding::percent_encode( redirect_url: percent_encoding::percent_encode(
format!("{path}/").as_bytes(), format!("/{key}/").as_bytes(),
PATH_ENCODING_SET, PATH_ENCODING_SET,
) )
.to_string(), .to_string(),
key,
redirect_code: StatusCode::FOUND, redirect_code: StatusCode::FOUND,
}) })
} }