use toasty::Db;
use super::*;
use crate::{Ability, Panel, ReadOnly, ResourceDef, lens, panel::test_support::mount};
#[tokio::test]
async fn options_endpoint_searches_and_gates() {
use http_body_util::BodyExt;
use crate::resource::Resource;
#[derive(Debug, toasty::Model, Clone)]
struct OptAuthor {
#[key]
#[auto]
id: uuid::Uuid,
name: String,
}
struct OptAuthorResource;
impl Resource for OptAuthorResource {
type Model = OptAuthor;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("opt-authors")
.policy(ReadOnly)
.table(crate::table::Table::new(
crate::table::TextColumn::new(lens!(OptAuthor.name)).searchable(),
))
}
}
#[derive(Debug, toasty::Model, Clone)]
struct OptPost {
#[key]
#[auto]
id: uuid::Uuid,
author_id: uuid::Uuid,
title: String,
}
struct OptPostResource;
impl Resource for OptPostResource {
type Model = OptPost;
type Form = OptPostForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("opt-posts")
.policy(ReadOnly)
.table(crate::table::Table::new(crate::table::TextColumn::new(
lens!(OptPost.title),
)))
.form(crate::schema::Schema::new(
crate::schema::Field::choice(OptPost::fields().author_id())
.relationship::<OptAuthorResource>(|a: &OptAuthor| a.name.clone())
.searchable(),
))
}
}
#[derive(crate::RecordForm)]
#[form(model = OptPost)]
struct OptPostForm {
author_id: uuid::Uuid,
}
async fn body_text(resp: http::Response<Body>) -> String {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&bytes).to_string()
}
let mut db = Db::builder()
.models(toasty::models!(OptAuthor, OptPost))
.connect("sqlite::memory:")
.await
.unwrap();
db.push_schema().await.unwrap();
for name in ["Ada", "Grace", "Alan"] {
toasty::create!(OptAuthor {
name: name.to_string(),
})
.exec(&mut db)
.await
.unwrap();
}
let router = mount(
db,
Panel::new("admin")
.resource::<OptPostResource>()
.resource::<OptAuthorResource>()
.auth(crate::Auth::disabled()),
)
.expect("panel builds");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/opt-posts/options?field=author_id&q=Ada")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(resp.status(), http::StatusCode::OK);
let html = body_text(resp).await;
assert!(html.contains("Ada"), "search must return Ada, got {html}");
assert!(!html.contains("Grace"), "search must narrow, got {html}");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/opt-posts/options?field=nope&q=Ada")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let resp = router
.handle(
http::Request::builder()
.uri("/admin/opt-posts/options?q=Ada")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn option_load_loads_no_relation() {
use http_body_util::BodyExt;
use toasty::stmt::{Include, List, Query};
use crate::resource::Resource;
#[derive(Debug, toasty::Model, Clone)]
struct Parent {
#[key]
#[auto]
id: uuid::Uuid,
name: String,
}
#[derive(Debug, toasty::Model, Clone)]
struct Child {
#[key]
#[auto]
id: uuid::Uuid,
name: String,
#[index]
parent_id: uuid::Uuid,
#[belongs_to(key = parent_id, references = id)]
parent: toasty::Deferred<Parent>,
}
fn with_parent() -> Query<List<Child>> {
let inc: Include<Child, Parent> = Child::fields().parent().into();
Query::<List<Child>>::all().include(inc)
}
struct ChildSource;
impl Resource for ChildSource {
type Model = Child;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("children")
.policy(|_cx: &Cx, ability: Ability<'_, Child>| match ability {
Ability::ViewAny => true,
Ability::View(record) => record.parent.is_unloaded(),
_ => false,
})
.table(crate::table::Table::new(
crate::table::ComputedColumn::new("Name", |c: &Child| c.name.clone())
.include(Child::fields().parent()),
))
}
fn view_query(_cx: &Cx) -> Query<List<Child>> {
with_parent()
}
}
#[derive(Debug, toasty::Model, Clone)]
struct Owner {
#[key]
#[auto]
id: uuid::Uuid,
child_id: uuid::Uuid,
name: String,
}
struct OwnerResource;
impl Resource for OwnerResource {
type Model = Owner;
type Form = OwnerForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("owners")
.table(crate::table::Table::new(crate::table::TextColumn::new(
lens!(Owner.name),
)))
.form(crate::schema::Schema::new(
crate::schema::Field::choice(Owner::fields().child_id())
.relationship::<ChildSource>(|c: &Child| c.name.clone())
.searchable(),
))
}
}
#[derive(crate::RecordForm)]
#[form(model = Owner)]
struct OwnerForm {
child_id: uuid::Uuid,
}
let mut db = Db::builder()
.models(toasty::models!(Parent, Child, Owner))
.connect("sqlite::memory:")
.await
.unwrap();
db.push_schema().await.unwrap();
let parent_id = uuid::Uuid::new_v4();
toasty::create!(Parent {
id: parent_id,
name: "Ada".to_string(),
})
.exec(&mut db)
.await
.unwrap();
toasty::create!(Child {
name: "Only Child".to_string(),
parent_id,
})
.exec(&mut db)
.await
.unwrap();
let router = mount(
db,
Panel::new("admin")
.resource::<OwnerResource>()
.resource::<ChildSource>()
.auth(crate::Auth::disabled()),
)
.expect("panel builds");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/owners/options?field=child_id")
.body(Body::empty())
.unwrap(),
)
.await;
assert!(resp.status().is_success());
let html = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(
html.contains("Only Child"),
"the option must render, which it cannot if the loader loaded the source's include: {html}"
);
}
#[tokio::test]
async fn options_endpoint_rejects_non_searchable_and_overflows() {
use http_body_util::BodyExt;
use crate::resource::Resource;
#[derive(Debug, toasty::Model, Clone)]
struct BigA {
#[key]
#[auto]
id: uuid::Uuid,
name: String,
}
struct BigAResource;
impl Resource for BigAResource {
type Model = BigA;
type Form = crate::NoForm<Self::Model>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("big-as")
.policy(ReadOnly)
.table(crate::table::Table::new(
crate::table::TextColumn::new(lens!(BigA.name)).searchable(),
))
}
}
#[derive(Debug, toasty::Model, Clone)]
struct BigP {
#[key]
#[auto]
id: uuid::Uuid,
author_id: uuid::Uuid,
name: String,
}
struct SearchableParent;
impl Resource for SearchableParent {
type Model = BigP;
type Form = SearchableParentForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("big-ps")
.table(crate::table::Table::new(crate::table::TextColumn::new(
lens!(BigP.name),
)))
.form(crate::schema::Schema::new(
crate::schema::Field::choice(BigP::fields().author_id())
.relationship::<BigAResource>(|a: &BigA| a.name.clone())
.searchable(),
))
}
}
#[derive(crate::RecordForm)]
#[form(model = BigP)]
struct SearchableParentForm {
author_id: uuid::Uuid,
}
struct PlainParent;
impl Resource for PlainParent {
type Model = BigP;
type Form = PlainParentForm;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("plain-ps")
.table(crate::table::Table::new(crate::table::TextColumn::new(
lens!(BigP.name),
)))
.form(crate::schema::Schema::new(
crate::schema::Field::choice(BigP::fields().author_id())
.relationship::<BigAResource>(|a: &BigA| a.name.clone()),
))
}
}
#[derive(crate::RecordForm)]
#[form(model = BigP)]
struct PlainParentForm {
author_id: uuid::Uuid,
}
let mut db = Db::builder()
.models(toasty::models!(BigA, BigP))
.connect("sqlite::memory:")
.await
.unwrap();
db.push_schema().await.unwrap();
for i in 0..=crate::schema::MAX_RELATIONSHIP_OPTIONS {
toasty::create!(BigA {
name: format!("author-{i}"),
})
.exec(&mut db)
.await
.unwrap();
}
let router = mount(
db,
Panel::new("admin")
.resource::<SearchableParent>()
.resource::<PlainParent>()
.resource::<BigAResource>()
.auth(crate::Auth::disabled()),
)
.expect("panel builds");
let resp = router
.handle(
http::Request::builder()
.uri("/admin/plain-ps/options?field=author_id&q=author-1")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let resp = router
.handle(
http::Request::builder()
.uri("/admin/big-ps/options?field=author_id&q=")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(resp.status(), http::StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&bytes).to_string();
assert!(
html.contains("keep typing"),
"filtered overflow must hint, got {html}"
);
}
#[tokio::test]
async fn an_option_escapes_its_value_and_label() {
use topcoat::context::CxTestBuilder;
let cx = CxTestBuilder::new().build();
let html = crate::schema::option_view(
&cx,
"a\"b".to_string(),
"<script>x</script>".to_string(),
false,
)
.single()
.await
.unwrap()
.render(&cx);
assert!(
!html.contains("<script>") && html.contains("<script>"),
"the label must be escaped, got {html}"
);
assert!(
!html.contains("value=\"a\"b\""),
"the value must be escaped, got {html}"
);
}