mod common;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
use what_core::server::create_router;
use what_core::{CollectionPolicyConfig, Config};
fn future_exp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600
}
fn config_with_policy(name: &str, policy: CollectionPolicyConfig) -> Config {
let mut config = Config::default();
config.collections.insert(name.to_string(), policy);
config
}
fn auth_config_with_policy(secret: &str, name: &str, policy: CollectionPolicyConfig) -> Config {
let mut config = auth_config_with_secret(secret);
config.auth.jwt_claims.push("roles".to_string());
config.auth.jwt_claims.push("org_id".to_string());
config.collections.insert(name.to_string(), policy);
config
}
#[tokio::test]
async fn default_denies_cross_session_update() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let (_dir, state) = proj.build_state();
let router = create_router(state);
let created = post_form(&router, "/w-action/notes?w-redirect=/", "title=Mine").await;
let cookie_a = created.session_cookie().expect("session cookie");
let resp = post_form(&router, "/w-action/notes/1?w-redirect=/", "title=Hacked").await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
let page = get_with_headers(&router, "/notes", vec![("cookie", &cookie_a)]).await;
page.assert_contains("Mine");
page.assert_not_contains("Hacked");
}
#[tokio::test]
async fn default_denies_cross_session_delete() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let (_dir, state) = proj.build_state();
let router = create_router(state);
let created = post_form(&router, "/w-action/notes?w-redirect=/", "title=Keep").await;
let cookie_a = created.session_cookie().expect("session cookie");
post_form(&router, "/w-action/notes/1?w-action=delete&w-redirect=/", "").await;
let page = get_with_headers(&router, "/notes", vec![("cookie", &cookie_a)]).await;
page.assert_contains("Keep");
}
#[tokio::test]
async fn legacy_unowned_record_is_modifiable() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let (_dir, state) = proj.build_state();
state
.store
.create("notes", json!({ "title": "Legacy" }))
.await
.unwrap();
let router = create_router(state);
post_form(&router, "/w-action/notes/1?w-redirect=/", "title=Edited").await;
let page = get(&router, "/notes").await;
page.assert_contains("Edited");
}
#[tokio::test]
async fn explicit_policy_locks_unowned_record() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let policy = CollectionPolicyConfig {
update: Some("owner".to_string()),
..Default::default()
};
let (_dir, state) = proj.build_state_with_config(config_with_policy("notes", policy));
state
.store
.create("notes", json!({ "title": "Locked" }))
.await
.unwrap();
let router = create_router(state);
post_form(&router, "/w-action/notes/1?w-redirect=/", "title=Changed").await;
let page = get(&router, "/notes").await;
page.assert_contains("Locked");
page.assert_not_contains("Changed");
}
#[tokio::test]
async fn owner_stamped_and_spoof_and_readonly_stripped() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"items.html",
"<loop data=\"#items#\" as=\"i\"><li>#i.name# owner=#i._owner# price=#i.price#</li></loop>",
);
let policy = CollectionPolicyConfig {
fields: what_core::FieldRulesConfig {
readonly: vec!["price".to_string()],
private: vec![],
},
..Default::default()
};
let (_dir, state) = proj.build_state_with_config(config_with_policy("items", policy));
let router = create_router(state);
let created = post_form(
&router,
"/w-action/items?w-redirect=/",
"name=Widget&_owner=user:evil&price=999",
)
.await;
let cookie = created.session_cookie().expect("session cookie");
let page = get_with_headers(&router, "/items", vec![("cookie", &cookie)]).await;
page.assert_contains("Widget");
page.assert_contains("owner=session:");
page.assert_not_contains("user:evil");
page.assert_not_contains("price=999");
}
#[tokio::test]
async fn read_owner_scopes_per_session() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
proj.add_page(
"list.html",
"<what>\nfetch.notes = \"local:notes\"\n</what>\n<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let policy = CollectionPolicyConfig {
read: Some("owner".to_string()),
..Default::default()
};
let (_dir, state) = proj.build_state_with_config(config_with_policy("notes", policy));
let router = create_router(state);
let a = post_form(&router, "/w-action/notes?w-redirect=/", "title=AlphaNote").await;
let cookie_a = a.session_cookie().expect("session cookie");
let b = post_form(&router, "/w-action/notes?w-redirect=/", "title=BetaNote").await;
let cookie_b = b.session_cookie().expect("session cookie");
let page_a = get_with_headers(&router, "/list", vec![("cookie", &cookie_a)]).await;
page_a.assert_contains("AlphaNote");
page_a.assert_not_contains("BetaNote");
let page_b = get_with_headers(&router, "/list", vec![("cookie", &cookie_b)]).await;
page_b.assert_contains("BetaNote");
page_b.assert_not_contains("AlphaNote");
}
#[tokio::test]
async fn read_scoped_collection_absent_from_base_context() {
let proj = TestProject::new();
proj.add_page(
"leak.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let policy = CollectionPolicyConfig {
read: Some("owner".to_string()),
..Default::default()
};
let (_dir, state) = proj.build_state_with_config(config_with_policy("notes", policy));
state
.store
.create("notes", json!({ "title": "SecretNote", "_owner": "user:someone" }))
.await
.unwrap();
let router = create_router(state);
let page = get(&router, "/leak").await;
page.assert_not_contains("SecretNote");
}
#[tokio::test]
async fn private_fields_stripped_from_render() {
let proj = TestProject::new();
proj.add_page(
"users.html",
"<what>\nfetch.users = \"local:users\"\n</what>\n<loop data=\"#users#\" as=\"u\"><li>#u.name# / #u.email#</li></loop>",
);
let policy = CollectionPolicyConfig {
read: Some("all".to_string()),
fields: what_core::FieldRulesConfig {
readonly: vec![],
private: vec!["email".to_string()],
},
..Default::default()
};
let (_dir, state) = proj.build_state_with_config(config_with_policy("users", policy));
state
.store
.create("users", json!({ "name": "Ada", "email": "ada@secret.com" }))
.await
.unwrap();
let router = create_router(state);
let page = get(&router, "/users").await;
page.assert_contains("Ada");
page.assert_not_contains("ada@secret.com");
}
#[tokio::test]
async fn tenant_filter_scopes_reads_and_mutations() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"orders.html",
"<what>\nfetch.orders = \"local:orders\"\n</what>\n<loop data=\"#orders#\" as=\"o\"><li>#o.item#</li></loop>",
);
let policy = CollectionPolicyConfig {
owner: Some("none".to_string()),
create: Some("user".to_string()),
update: Some("user".to_string()),
read: Some("all".to_string()),
filter: Some("org_id=#user.org_id#".to_string()),
..Default::default()
};
let (_dir, state) =
proj.build_state_with_config(auth_config_with_policy("secret", "orders", policy));
state
.store
.create("orders", json!({ "item": "AcmeOrder", "org_id": "acme" }))
.await
.unwrap();
state
.store
.create("orders", json!({ "item": "OtherOrder", "org_id": "other" }))
.await
.unwrap();
let router = create_router(state);
let acme_token = create_test_jwt(
&json!({ "sub": "u1", "org_id": "acme", "exp": future_exp() }),
"secret",
);
let acme_cookie = format!("w_token={}", acme_token);
let page = get_with_headers(&router, "/orders", vec![("cookie", &acme_cookie)]).await;
page.assert_contains("AcmeOrder");
page.assert_not_contains("OtherOrder");
post_form_with_headers(
&router,
"/w-action/orders/2?w-redirect=/",
"item=Hijacked",
vec![("cookie", &acme_cookie)],
)
.await;
let other_token = create_test_jwt(
&json!({ "sub": "u2", "org_id": "other", "exp": future_exp() }),
"secret",
);
let other_cookie = format!("w_token={}", other_token);
let page2 = get_with_headers(&router, "/orders", vec![("cookie", &other_cookie)]).await;
page2.assert_contains("OtherOrder");
page2.assert_not_contains("Hijacked");
}
#[tokio::test]
async fn create_stamps_tenant_field_preventing_cross_tenant_injection() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"orders.html",
"<what>\nfetch.orders = \"local:orders\"\n</what>\n<loop data=\"#orders#\" as=\"o\"><li>#o.item#</li></loop>",
);
let policy = CollectionPolicyConfig {
owner: Some("none".to_string()),
create: Some("user".to_string()),
read: Some("all".to_string()),
filter: Some("org_id=#user.org_id#".to_string()),
..Default::default()
};
let (_dir, state) =
proj.build_state_with_config(auth_config_with_policy("secret", "orders", policy));
let router = create_router(state);
let acme = format!(
"w_token={}",
create_test_jwt(&json!({ "sub": "u1", "org_id": "acme", "exp": future_exp() }), "secret")
);
post_form_with_headers(
&router,
"/w-action/orders?w-redirect=/",
"item=StampedOrder&org_id=other",
vec![("cookie", &acme)],
)
.await;
let other = format!(
"w_token={}",
create_test_jwt(&json!({ "sub": "u2", "org_id": "other", "exp": future_exp() }), "secret")
);
let other_page = get_with_headers(&router, "/orders", vec![("cookie", &other)]).await;
other_page.assert_not_contains("StampedOrder");
let acme_page = get_with_headers(&router, "/orders", vec![("cookie", &acme)]).await;
acme_page.assert_contains("StampedOrder");
}
#[tokio::test]
async fn role_delete_rule_allows_and_denies() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"posts.html",
"<loop data=\"#posts#\" as=\"p\"><li>#p.title#</li></loop>",
);
let policy = CollectionPolicyConfig {
owner: Some("none".to_string()),
create: Some("all".to_string()),
delete: Some("admin".to_string()),
read: Some("all".to_string()),
..Default::default()
};
let (_dir, state) =
proj.build_state_with_config(auth_config_with_policy("secret", "posts", policy));
state
.store
.create("posts", json!({ "title": "Hello" }))
.await
.unwrap();
let router = create_router(state);
let editor = create_test_jwt(
&json!({ "sub": "e1", "roles": ["editor"], "exp": future_exp() }),
"secret",
);
post_form_with_headers(
&router,
"/w-action/posts/1?w-action=delete&w-redirect=/",
"",
vec![("cookie", &format!("w_token={}", editor))],
)
.await;
get(&router, "/posts").await.assert_contains("Hello");
let admin = create_test_jwt(
&json!({ "sub": "a1", "roles": ["admin"], "exp": future_exp() }),
"secret",
);
post_form_with_headers(
&router,
"/w-action/posts/1?w-action=delete&w-redirect=/",
"",
vec![("cookie", &format!("w_token={}", admin))],
)
.await;
get(&router, "/posts").await.assert_not_contains("Hello");
}
#[tokio::test]
async fn create_user_rule_denies_anonymous() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_page(
"notes.html",
"<loop data=\"#notes#\" as=\"n\"><li>#n.title#</li></loop>",
);
let policy = CollectionPolicyConfig {
create: Some("user".to_string()),
..Default::default()
};
let (_dir, state) =
proj.build_state_with_config(auth_config_with_policy("secret", "notes", policy));
let router = create_router(state);
post_form(&router, "/w-action/notes?w-redirect=/", "title=Nope").await;
get(&router, "/notes").await.assert_not_contains("Nope");
}
#[tokio::test]
async fn partial_denial_is_403_with_policy_header() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
let policy = CollectionPolicyConfig {
create: Some("user".to_string()),
..Default::default()
};
let (_dir, state) =
proj.build_state_dev_with_config(auth_config_with_policy("secret", "notes", policy));
let router = create_router(state);
let resp = post_form_with_headers(
&router,
"/w-action/notes",
"title=Nope",
vec![("X-Requested-With", "What")],
)
.await;
assert_eq!(resp.status, StatusCode::FORBIDDEN);
assert!(resp.header("x-what-policy").is_some());
}
#[tokio::test]
async fn w_set_wired_role_gating() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_app_config(".", "data.wired = [\"revenue [admin]\"]\n");
let (_dir, state) = proj.build_state_with_config({
let mut c = auth_config_with_secret("secret");
c.auth.jwt_claims.push("roles".to_string());
c
});
state.rebuild_wired_scopes().await;
let router = create_router(state);
let editor = create_test_jwt(
&json!({ "sub": "e1", "roles": ["editor"], "exp": future_exp() }),
"secret",
);
let resp = post_form_with_headers(
&router,
"/w-set",
"expr=wired.revenue+%2B%3D+1",
vec![("cookie", &format!("w_token={}", editor))],
)
.await;
assert_eq!(resp.status, StatusCode::FORBIDDEN);
let admin = create_test_jwt(
&json!({ "sub": "a1", "roles": ["admin"], "exp": future_exp() }),
"secret",
);
let resp = post_form_with_headers(
&router,
"/w-set",
"expr=wired.revenue+%2B%3D+1",
vec![("cookie", &format!("w_token={}", admin))],
)
.await;
assert_ne!(
resp.status,
StatusCode::FORBIDDEN,
"admin should not be denied"
);
assert!(resp.status.is_redirection() || resp.status.is_success());
}
#[tokio::test]
async fn w_set_app_role_gating() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>Home</h1>");
proj.add_app_config(".", "data.application = [\"mrr [admin]\"]\n");
let (_dir, state) = proj.build_state_with_config({
let mut c = auth_config_with_secret("secret");
c.auth.jwt_claims.push("roles".to_string());
c
});
state.rebuild_wired_scopes().await;
let router = create_router(state);
let editor = create_test_jwt(
&json!({ "sub": "e1", "roles": ["editor"], "exp": future_exp() }),
"secret",
);
let resp = post_form_with_headers(
&router,
"/w-set",
"expr=app.mrr+%2B%3D+1",
vec![("cookie", &format!("w_token={}", editor))],
)
.await;
assert_eq!(resp.status, StatusCode::FORBIDDEN);
}