#![cfg(feature = "flags")]
use std::time::Duration;
use sofie_core::{Error, Result};
use sofie_db::Db;
use sofie_flags::{Feature, Features, Flags, Rule, Variants};
use sofie_http::{App, Gates, RouteFeature, Routes, Web};
use sofie_testing::TestApp;
const SPLIT_PAYMENTS: Feature = Feature::new("shop.split_payments");
const BETA: Feature = Feature::new("shop.beta");
const CHECKOUT_LAYOUT: Variants<Layout> = Variants::new("shop.checkout_layout");
#[derive(Clone, Copy)]
enum Layout {
Classic,
Compact,
}
impl sofie_core::Variants for Layout {
const TYPE: &'static str = "Layout";
const VARIANTS: &'static [(&'static str, Self)] =
&[("CLASSIC", Self::Classic), ("COMPACT", Self::Compact)];
}
const ROUTE_FEATURES: &[RouteFeature] = &[RouteFeature {
method: "GET",
path: "/beta",
feature: BETA,
}];
async fn ok() -> Result<()> {
Ok(())
}
async fn layout(features: Features) -> Result<()> {
if features.enabled(SPLIT_PAYMENTS) {
return Ok(());
}
Err(Error::Rule("payments are not split".to_owned()))
}
async fn checkout(features: Features) -> Result<()> {
sofie_telemetry::product_event!(checkout.opened);
let layout = features.variant(CHECKOUT_LAYOUT);
sofie_telemetry::product_event!(order.placed);
layout
.map(|_| ())
.ok_or_else(|| Error::Rule("no layout is chosen yet".to_owned()))
}
async fn browse() -> Result<()> {
sofie_telemetry::product_event!(page.viewed);
Ok(())
}
async fn listed() -> Result<Vec<()>> {
Ok(Vec::new())
}
fn routes(routes: &mut Routes<Web>) {
routes.get("/listed", listed);
routes.post("/pay", ok).feature(SPLIT_PAYMENTS);
routes.get("/beta", ok);
routes.get("/layout", layout);
routes.get("/checkout", checkout);
routes.get("/browse", browse);
}
async fn started() -> TestApp {
sofie_testing::start(
|db: Db| async move {
db.schema().migrate(&[]).await?;
let flags = Flags::start(&db, "test").await?;
let gates = Gates::default()
.features(flags)
.route_features(ROUTE_FEATURES);
Ok(App::new(db).gates(gates).area::<Web>("shop", routes))
},
sofie_db::worker::Worker::new,
)
.await
}
async fn switch_on(app: &TestApp, name: &str, path: &str, post: bool) {
switch(app, name, Rule::everyone(), path, post).await;
}
async fn switch(app: &TestApp, name: &str, rule: Rule, path: &str, post: bool) {
let elsewhere = Flags::start(app.db(), "test").await.unwrap();
elsewhere.set(name, rule).await.unwrap();
drop(elsewhere);
for _ in 0..50 {
let request = if post { app.post(path) } else { app.get(path) };
if request.accept_json().await.status == 204 {
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("{name} did not reach the app within five seconds");
}
#[tokio::test]
async fn a_route_behind_an_off_flag_is_not_found_until_it_is_switched_on() {
let _telemetry = sofie_telemetry::capture();
let app = started().await;
assert_eq!(app.post("/pay").accept_json().await.status, 404);
assert_eq!(app.get("/beta").accept_json().await.status, 404);
switch_on(&app, "shop.split_payments", "/pay", true).await;
switch_on(&app, "shop.beta", "/beta", false).await;
assert_eq!(app.post("/pay").accept_json().await.status, 204);
assert_eq!(app.get("/beta").accept_json().await.status, 204);
}
#[cfg(feature = "cors")]
#[tokio::test]
async fn a_preflight_to_a_flagged_route_is_answered_once_the_flag_is_on_for_someone() {
let _telemetry = sofie_telemetry::capture();
let app = sofie_testing::start(
|db: Db| async move {
db.schema().migrate(&[]).await?;
let gates = Gates::default().features(Flags::start(&db, "test").await?);
let cors = sofie_http::Cors::new().origin("https://app.example");
Ok(App::new(db)
.gates(gates)
.area::<Web>("shop", routes)
.cors::<Web>(cors))
},
sofie_db::worker::Worker::new,
)
.await;
let pay = sofie_http::RoutePath::new(sofie_http::Method::OPTIONS, "/pay".to_owned());
let answered = || async {
let preflight = app
.request(&pay)
.header("origin", "https://app.example")
.header("access-control-request-method", "POST")
.await;
preflight.status == 204
&& preflight
.headers
.get("access-control-allow-origin")
.is_some()
};
let rolled_out = |percentage| Rule {
percentage,
..Rule::everyone()
};
let hidden = answered().await;
let partly_on = flag_reached(&app, rolled_out(10), &answered, true).await;
let on_for_nobody = flag_reached(&app, rolled_out(0), &answered, false).await;
assert!(!hidden);
assert!(
partly_on,
"a preflight to a route on for some principals is answered"
);
assert!(on_for_nobody, "a preflight to a route on for nobody is not");
}
#[cfg(feature = "cors")]
async fn flag_reached<F, Answered>(app: &TestApp, rule: Rule, answered: &F, wanted: bool) -> bool
where
F: Fn() -> Answered,
Answered: std::future::Future<Output = bool>,
{
let elsewhere = Flags::start(app.db(), "test").await.unwrap();
elsewhere.set("shop.split_payments", rule).await.unwrap();
drop(elsewhere);
for _ in 0..50 {
if answered().await == wanted {
return true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
#[tokio::test]
async fn another_method_on_a_flagged_route_is_not_found_rather_than_naming_it() {
let _telemetry = sofie_telemetry::capture();
let app = started().await;
assert_eq!(app.get("/pay").accept_json().await.status, 404);
assert_eq!(app.post("/beta").accept_json().await.status, 404);
}
#[cfg(not(feature = "development"))]
#[tokio::test]
async fn a_switched_flag_changes_the_tag_a_browser_holds() {
let _telemetry = sofie_telemetry::capture();
let app = started().await;
let first = app.get("/listed").accept_json().await;
let tag = first.header("etag").to_owned();
let held = app
.get("/listed")
.header("if-none-match", &tag)
.accept_json()
.await;
assert_eq!(held.status, 304);
switch_on(&app, "shop.beta", "/beta", false).await;
let after = app
.get("/listed")
.header("if-none-match", &tag)
.accept_json()
.await;
assert_eq!(after.status, 200);
assert_ne!(after.header("etag"), tag);
}
#[tokio::test]
async fn a_handler_reads_the_flags_and_the_span_names_those_evaluated() {
let telemetry = sofie_telemetry::capture();
let app = started().await;
assert_eq!(app.get("/layout").accept_json().await.status, 422);
switch_on(&app, "shop.split_payments", "/pay", true).await;
assert_eq!(app.get("/layout").accept_json().await.status, 204);
let recorded: Vec<String> = telemetry
.spans()
.into_iter()
.filter(|span| span.name == "request")
.filter_map(|span| span.fields.get("feature_flags").cloned())
.collect();
assert!(
recorded.contains(&"shop.split_payments=off".to_owned())
&& recorded.contains(&"shop.split_payments=on".to_owned()),
"{recorded:?}"
);
}
#[tokio::test]
async fn a_product_event_carries_the_variants_evaluated_before_it() {
let telemetry = sofie_telemetry::capture();
let app = started().await;
let compact = Rule {
variants: vec![("COMPACT".to_owned(), 1)],
..Rule::everyone()
};
switch(&app, "shop.checkout_layout", compact, "/checkout", false).await;
assert_eq!(app.get("/browse").accept_json().await.status, 204);
let carried = |name: &str| {
telemetry
.product_events()
.into_iter()
.filter(|event| event.fields.get("product.event").map(String::as_str) == Some(name))
.map(|event| event.fields.get("feature_flags").cloned())
.next_back()
.flatten()
};
assert_eq!(
carried("order.placed").as_deref(),
Some("shop.checkout_layout=COMPACT")
);
assert_eq!(carried("checkout.opened"), None);
assert_eq!(carried("page.viewed"), None);
}
#[tokio::test]
async fn a_flagged_route_without_the_apps_flags_stops_the_app() {
let db = Db::new(sofie_testing::FakeDriver::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let served = App::new(db)
.area::<Web>("shop", routes)
.serving(|serving| serving.signing_keys(sofie_http::SigningKeys::generate().unwrap()))
.serve(listener, async {})
.await;
let error = served.expect_err("the app does not start");
assert!(error.to_string().contains("POST /pay"), "{error}");
}
#[cfg(all(feature = "toolbar", debug_assertions))]
#[tokio::test]
async fn a_toolbar_override_answers_a_flag_for_this_browser_only() {
let _telemetry = sofie_telemetry::capture();
let app = started().await;
let overriding = |flag: &'static str, answer: &'static str| {
app.post("/_sofie/toolbar")
.header("sec-fetch-site", "same-origin")
.form(&[("action", "flag"), ("flag", flag), ("answer", answer)])
};
let cookie_of = |answer: sofie_testing::TestResponse| {
let set = answer.header("set-cookie");
set.split(';').next().unwrap().to_owned()
};
let split = cookie_of(overriding("shop.split_payments", "on").await);
let beta = cookie_of(overriding("shop.beta", "on").await);
let compact = cookie_of(overriding("shop.checkout_layout", "COMPACT").await);
let get = |path: &str, cookie: &str| app.get(path).header("cookie", cookie).accept_json();
let layout = get("/layout", &split).await;
let beta = get("/beta", &beta).await;
let compact = get("/checkout", &compact).await;
let stored = app.get("/layout").accept_json().await;
layout.assert_status(204);
beta.assert_status(204);
compact.assert_status(204);
stored.assert_status(422);
}
#[cfg(feature = "parse")]
mod refreshed_pages {
use serde_json::{Value, json};
use sofie_core::{Error, Result};
use sofie_db::{Db, Outcome, ReadOnly, Reply};
use sofie_flags::Flags;
use sofie_http::{App, Gates, Routes, Web};
use sofie_testing::TestApp;
use super::BETA;
const SECRET: &str =
r#"<Section title="Secret"><Table id="notes" of={data} columns="text" /></Section>"#;
struct Listing(Value);
impl Outcome for Listing {
fn reply(self) -> Result<Reply> {
Ok(Reply::Json(self.0))
}
}
impl ReadOnly for Listing {}
async fn secret_notes() -> Result<Listing> {
Ok(Listing(json!([{ "text": "classified" }])))
}
async fn noted() -> Result<()> {
Ok(())
}
async fn refused() -> Result<()> {
Err(Error::invalid("text", "is required"))
}
fn routes(routes: &mut Routes<Web>) {
routes
.screen("/secret", "secret", secret_notes)
.feature(BETA);
routes
.post("/secret/note", noted)
.then_screen("secret", "notes");
routes.post("/secret", refused);
}
async fn started() -> TestApp {
sofie_testing::start(
|db: Db| async move {
db.schema().migrate(&[]).await?;
let flags = Flags::start(&db, "test").await?;
let screens = sofie_http::screens_from_sources(&[("shop/web/secret", SECRET)])?;
Ok(App::new(db)
.screens(screens)
.gates(Gates::default().features(flags))
.area::<Web>("shop", routes))
},
sofie_db::worker::Worker::new,
)
.await
}
#[tokio::test]
async fn an_action_shows_no_page_whose_own_feature_is_off() {
let _telemetry = sofie_telemetry::capture();
let app = started().await;
let page = app.get("/secret").header("accept", "text/html").await;
let refreshed = app
.post("/secret/note")
.header("sec-fetch-site", "same-origin")
.htmx("notes")
.await;
let refused = app
.post("/secret")
.header("sec-fetch-site", "same-origin")
.header("accept", "text/html")
.form(&[("text", "")])
.await;
page.assert_status(404);
assert!(!refreshed.body.contains("classified"), "{}", refreshed.body);
assert!(!refused.body.contains("classified"), "{}", refused.body);
}
}