what-core 1.7.0

Core framework for What - an HTML-first web framework powered by Rust
Documentation
mod common;

use axum::http::StatusCode;
use common::*;
use serde_json::json;
use what_core::server::create_router;

fn future_exp() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
        + 3600
}

/// Auth config that also extracts the "roles" claim from JWTs
fn auth_config_with_roles(secret: &str) -> what_core::Config {
    let mut config = auth_config_with_secret(secret);
    config.auth.jwt_claims.push("roles".to_string());
    config
}

#[tokio::test]
async fn public_page_no_auth_directive() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Public</h1>");
    let (_dir, state) = proj.build_state();
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Public");
}

#[tokio::test]
async fn public_page_auth_all() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<what auth=\"all\" />\n<h1>Open</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Open");
}

#[tokio::test]
async fn protected_page_redirects_unauthenticated() {
    let proj = TestProject::new();
    proj.add_page("secret.html", "<what auth=\"user\" />\n<h1>Secret</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = get(&router, "/secret").await;
    assert_eq!(resp.status, StatusCode::SEE_OTHER);
    let location = resp.location().expect("should have Location header");
    assert!(
        location.contains("/login"),
        "redirect should point to login, got: {}",
        location
    );
}

#[tokio::test]
async fn protected_page_allows_jwt() {
    let proj = TestProject::new();
    proj.add_page("secret.html", "<what auth=\"user\" />\n<h1>Secret</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "user@test.com", "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/secret",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Secret");
}

#[tokio::test]
async fn role_protected_allows_matching_role() {
    let proj = TestProject::new();
    proj.add_page("admin.html", "<what auth=\"admin\" />\n<h1>Admin</h1>");
    let config = auth_config_with_roles("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "admin@test.com", "roles": ["admin"], "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/admin",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Admin");
}

#[tokio::test]
async fn role_protected_denies_wrong_role() {
    let proj = TestProject::new();
    proj.add_page("admin.html", "<what auth=\"admin\" />\n<h1>Admin</h1>");
    let config = auth_config_with_roles("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "editor@test.com", "roles": ["editor"], "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/admin",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn config_protected_paths_redirect() {
    let proj = TestProject::new();
    proj.add_page("dashboard/index.html", "<h1>Dashboard</h1>");
    let mut config = auth_config_with_secret("test-secret");
    config.auth.protected_paths = vec!["/dashboard/*".to_string()];
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = get(&router, "/dashboard/").await;
    assert_eq!(resp.status, StatusCode::SEE_OTHER);
    let location = resp.location().expect("should have Location header");
    assert!(
        location.contains("/login"),
        "should redirect to login, got: {}",
        location
    );
}

#[tokio::test]
async fn config_protected_paths_allows_jwt() {
    let proj = TestProject::new();
    proj.add_page("dashboard/index.html", "<h1>Dashboard</h1>");
    let mut config = auth_config_with_secret("test-secret");
    config.auth.protected_paths = vec!["/dashboard/*".to_string()];
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "user@test.com", "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/dashboard/",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("Dashboard");
}

#[tokio::test]
async fn user_authenticated_variable() {
    let proj = TestProject::new();
    proj.add_page("profile.html", "<p>#user.authenticated#</p>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "a@b.com", "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/profile",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("true");
}

#[tokio::test]
async fn user_email_variable() {
    let proj = TestProject::new();
    proj.add_page("profile.html", "<p>#user.email#</p>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let token = create_test_jwt(
        &json!({ "email": "alice@example.com", "exp": future_exp() }),
        "test-secret",
    );
    let resp = get_with_headers(
        &router,
        "/profile",
        vec![("cookie", &format!("w_token={}", token))],
    )
    .await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("alice@example.com");
}

#[tokio::test]
async fn unauthenticated_user_variable() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<p>#user.authenticated#</p>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = get(&router, "/").await;
    assert_eq!(resp.status, StatusCode::OK);
    resp.assert_contains("false");
}

#[tokio::test]
async fn logout_clears_cookie() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = post_form(&router, "/w-auth/logout", "").await;
    assert_eq!(resp.status, StatusCode::SEE_OTHER);
    let cookie = resp.set_cookie().expect("should have Set-Cookie header");
    assert!(
        cookie.contains("Max-Age=0"),
        "cookie should be cleared, got: {}",
        cookie
    );
    assert!(
        cookie.contains("w_token="),
        "should clear the w_token cookie"
    );
}

#[tokio::test]
async fn w_set_session_scope_allows_unauthenticated() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    // Session mutations should be allowed without authentication
    let resp = post_form_with_headers(
        &router,
        "/w-set",
        "expr=session.counter+%2B%3D+1",
        vec![("X-Requested-With", "What")],
    )
    .await;
    // Should succeed (200 with template fragment)
    assert_ne!(
        resp.status,
        StatusCode::FORBIDDEN,
        "session.* mutations should not require auth"
    );
}

#[tokio::test]
async fn w_set_app_scope_rejects_unauthenticated() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    // app.* mutations require authentication (shared state)
    let resp = post_form_with_headers(
        &router,
        "/w-set",
        "expr=app.counter+%2B%3D+1",
        vec![("X-Requested-With", "What")],
    )
    .await;
    assert_eq!(
        resp.status,
        StatusCode::FORBIDDEN,
        "app.* mutations should require auth"
    );
}

#[tokio::test]
async fn w_set_wired_scope_rejects_unauthenticated() {
    let proj = TestProject::new();
    proj.add_page("index.html", "<h1>Home</h1>");
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    // wired.* mutations require authentication (shared state)
    let resp = post_form_with_headers(
        &router,
        "/w-set",
        "expr=wired.total+%2B%3D+1",
        vec![("X-Requested-With", "What")],
    )
    .await;
    assert_eq!(
        resp.status,
        StatusCode::FORBIDDEN,
        "wired.* mutations should require auth"
    );
}

#[tokio::test]
async fn redirect_preserves_return_url() {
    let proj = TestProject::new();
    proj.add_page(
        "admin/secret.html",
        "<what auth=\"user\" />\n<h1>Secret</h1>",
    );
    let config = auth_config_with_secret("test-secret");
    let (_dir, state) = proj.build_state_with_config(config);
    let router = create_router(state);

    let resp = get(&router, "/admin/secret").await;
    assert_eq!(resp.status, StatusCode::SEE_OTHER);
    let location = resp.location().expect("should have Location header");
    assert!(
        location.contains("redirect=%2Fadmin%2Fsecret"),
        "redirect URL should be encoded in location, got: {}",
        location
    );
}