tablo-core 0.6.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
use toasty::Db;
use topcoat::{
    Result,
    context::Cx,
    router::Body,
    view::{View, view},
};

use crate::{
    DeclarationError, DeclarationErrorKind, Page, Panel, SegmentFault, Site,
    navigation::NavigationItem,
    panel::test_support::{Dummy, dummy_table, mount, mount_without_db, refusal, response_html},
    resource::{Resource, ResourceDef},
    test_support::memory_db,
};

struct Dashboard;
impl Page for Dashboard {
    async fn render(cx: &Cx) -> Result<impl View> {
        Ok(view! { cx => <h1>"Welcome home"</h1> })
    }
}

struct ReportsPage;
impl Page for ReportsPage {
    fn navigation() -> NavigationItem {
        NavigationItem {
            order: -1,
            ..NavigationItem::for_page::<Self>()
        }
    }

    async fn render(cx: &Cx) -> Result<impl View> {
        Ok(view! { cx => <h1>"Quarterly reports"</h1> })
    }
}

struct DummyResource;
impl Resource for DummyResource {
    type Model = Dummy;
    type Form = crate::NoForm<Self::Model>;

    fn declare() -> ResourceDef<Self> {
        ResourceDef::new().table(dummy_table())
    }
}

async fn db() -> Db {
    memory_db(toasty::models!(Dummy)).await
}

async fn get(router: &topcoat::router::Router, uri: &str) -> (http::StatusCode, String) {
    let request = http::Request::builder()
        .uri(uri)
        .body(Body::empty())
        .unwrap();
    let response = router.handle(request).await;
    (response.status(), response_html(response).await)
}

/// A page and the home page mount under the panel's own prefix, and their
/// sidebar entries resolve from the same place. The home entry leads even
/// when registered last.
#[tokio::test]
async fn pages_mount_under_the_prefix_with_their_sidebar_entries() {
    let panel = || {
        Panel::new("backoffice")
            .auth(crate::Auth::disabled())
            .resource::<DummyResource>()
            .page::<ReportsPage>()
            .home::<Dashboard>()
    };
    let registered = panel().registered();
    let entries: Vec<_> = registered
        .nav_items
        .iter()
        .map(|entry| {
            (
                entry.item.label.as_str(),
                entry.item.url(),
                entry.item.order,
            )
        })
        .collect();
    assert_eq!(
        entries,
        [
            ("Dashboard", Some("/backoffice"), 0),
            ("Dummies", Some("/backoffice/dummies"), 0),
            ("Reports", Some("/backoffice/reports"), -1),
        ]
    );
    let router = mount(db().await, panel()).expect("the panel builds");

    let (status, html) = get(&router, "/backoffice").await;
    assert_eq!(
        status,
        http::StatusCode::OK,
        "the home page replaces the redirect"
    );
    assert!(
        html.contains("Welcome home"),
        "home renders at the prefix: {html}"
    );

    let (status, html) = get(&router, "/backoffice/reports").await;
    assert_eq!(status, http::StatusCode::OK);
    assert!(
        html.contains("Quarterly reports"),
        "the page renders: {html}"
    );
}

/// Pages and resources share one slug namespace, in either order.
#[test]
fn a_page_slug_that_a_resource_holds_does_not_build() {
    struct DummiesPage;
    impl Page for DummiesPage {
        async fn render(cx: &Cx) -> Result<impl View> {
            Ok(view! { cx => "dummies" })
        }
    }

    let duplicate = || DeclarationErrorKind::DuplicateSlug {
        slug: "dummies".to_string(),
    };
    let errors = refusal(mount_without_db(
        Panel::new("admin")
            .resource::<DummyResource>()
            .page::<DummiesPage>(),
    ));
    assert_eq!(
        errors[0],
        DeclarationError::of::<DummiesPage>(Site::Registration, duplicate())
    );
    let errors = refusal(mount_without_db(
        Panel::new("admin")
            .page::<DummiesPage>()
            .resource::<DummyResource>(),
    ));
    assert_eq!(
        errors[0],
        DeclarationError::of::<DummyResource>(Site::Registration, duplicate())
    );
}

#[test]
fn a_page_slug_that_is_not_one_segment_does_not_build() {
    struct NestedPage;
    impl Page for NestedPage {
        fn slug() -> String {
            "reports/q3".to_string()
        }

        async fn render(cx: &Cx) -> Result<impl View> {
            Ok(view! { cx => "nested" })
        }
    }

    let errors = refusal(mount_without_db(Panel::new("admin").page::<NestedPage>()));
    assert_eq!(
        errors[0].kind,
        DeclarationErrorKind::InvalidSegment {
            item: "Page::slug",
            segment: "reports/q3".to_string(),
            fault: SegmentFault::Char('/'),
        }
    );
}

#[test]
fn a_second_home_page_does_not_build() {
    let errors = refusal(mount_without_db(
        Panel::new("admin")
            .home::<Dashboard>()
            .home::<ReportsPage>(),
    ));
    assert_eq!(
        errors[0],
        DeclarationError::of::<ReportsPage>(Site::Registration, DeclarationErrorKind::SecondHome)
    );
}

/// The panel routes `{prefix}/login` and `{prefix}/logout` itself: a page
/// named after either is a registration error, not a duplicate-route panic
/// inside the router build.
#[test]
fn a_page_slug_the_panel_routes_itself_does_not_build() {
    struct LoginPage;
    impl Page for LoginPage {
        async fn render(cx: &Cx) -> Result<impl View> {
            Ok(view! { cx => "login" })
        }
    }

    let errors = refusal(mount_without_db(Panel::new("admin").page::<LoginPage>()));
    assert_eq!(
        errors[0],
        DeclarationError::of::<LoginPage>(
            Site::Registration,
            DeclarationErrorKind::ReservedSlug {
                slug: "login".to_string(),
            },
        )
    );
}

/// Without a home page, the root still redirects to the first resource, and
/// the redirect carries the clickjacking directive like every response.
#[tokio::test]
async fn without_a_home_page_the_root_redirects_to_the_first_resource() {
    let router = mount(
        db().await,
        Panel::new("admin")
            .auth(crate::Auth::disabled())
            .resource::<DummyResource>()
            .page::<ReportsPage>(),
    )
    .expect("the panel builds");
    let request = http::Request::builder()
        .uri("/admin")
        .body(Body::empty())
        .unwrap();
    let response = router.handle(request).await;
    assert_eq!(response.status(), http::StatusCode::TEMPORARY_REDIRECT);
    assert_eq!(
        response.headers().get(http::header::LOCATION).unwrap(),
        "/admin/dummies"
    );
    assert!(
        response
            .headers()
            .get(http::header::CONTENT_SECURITY_POLICY)
            .and_then(|value| value.to_str().ok())
            .is_some_and(|policy| policy.contains("frame-ancestors")),
        "the root redirect must carry the directive"
    );
}

/// A page re-checks the resolved user before rendering: with auth enforced
/// and no user, it answers the login redirect and never renders.
#[tokio::test]
async fn a_page_redirects_to_login_without_a_resolved_user() {
    use topcoat::{context::CxTestBuilder, router::response::IntoResponse, view::ViewExt};

    let (parts, ()) = http::Request::builder()
        .uri("/admin/reports")
        .body(())
        .unwrap()
        .into_parts();
    let cx = CxTestBuilder::new()
        .request_context(parts)
        .request_context(crate::panel::test_support::current_panel(
            crate::panel::test_support::panel_state("/admin", crate::Auth::password()),
        ))
        .build();
    let Err(error) = super::page_handler::<ReportsPage>(&cx, Body::empty())
        .single()
        .await
    else {
        panic!("a page must not render without a resolved user");
    };
    let response = error.into_response(&cx).expect("the gate redirect renders");
    let location = response
        .headers()
        .get(http::header::LOCATION)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();
    assert!(
        location.starts_with("/admin/login"),
        "an anonymous page request redirects to login, got {location}"
    );
}

/// A page whose `can_access` refuses answers 403 and leaves the sidebar, as does a resource the
/// user cannot `ViewAny`; what the user may open stays.
#[tokio::test]
async fn the_sidebar_lists_only_what_the_user_may_open() {
    struct SecretPage;
    impl Page for SecretPage {
        fn can_access(_cx: &Cx) -> bool {
            false
        }

        async fn render(cx: &Cx) -> Result<impl View> {
            Ok(view! { cx => <h1>"Secret"</h1> })
        }
    }

    struct OpenResource;
    impl Resource for OpenResource {
        type Model = Dummy;
        type Form = crate::NoForm<Self::Model>;

        fn declare() -> ResourceDef<Self> {
            ResourceDef::new()
                .slug("open")
                .policy(crate::policy::ReadOnly)
                .table(dummy_table())
        }
    }

    let router = mount(
        db().await,
        Panel::new("admin")
            .auth(crate::Auth::disabled())
            .home::<Dashboard>()
            .resource::<OpenResource>()
            // Its policy is the default, which denies every ability.
            .resource::<DummyResource>()
            .page::<ReportsPage>()
            .page::<SecretPage>(),
    )
    .expect("the panel builds");

    let (status, _) = get(&router, "/admin/secret").await;
    assert_eq!(status, http::StatusCode::FORBIDDEN);

    let (status, html) = get(&router, "/admin").await;
    assert_eq!(status, http::StatusCode::OK);
    for (href, listed) in [
        ("/admin/reports", true),
        ("/admin/open", true),
        ("/admin/secret", false),
        ("/admin/dummies", false),
    ] {
        assert_eq!(
            html.contains(&format!("href=\"{href}\"")),
            listed,
            "{href} listed: {listed}, got {html}"
        );
    }
}