Skip to content

Policy tests

Ability::mask and Ability::condition_for are the policy code of a nestrs app — they decide which fields a caller sees and which rows the data layer accepts (through Repo). A regression there is a silent data leak; a happy-path e2e does not catch it.

These are ideal unit-test targets: pure functions on policy types, no DI, no DB, microsecond runs. The demo tests its ability directly — quote them as the model.

The tests reconstruct an Ability the way the guard does: an AbilityBuilder, the app policy’s define, then build. Small role-shaped helpers keep each assertion to one line.

crates/features/src/authz/ability.rs (abridged)
use nest_rs_authz::{Ability, AbilityBuilder, FieldSet};
use sea_orm::{DatabaseBackend, EntityTrait, QueryFilter, QueryTrait};
fn ability_for(roles: Vec<Role>, org_id: Uuid) -> Ability {
let claims = Claims { sub: Some(Uuid::nil()), org_id, roles, exp: 0 };
let mut b = AbilityBuilder::new();
AppAbility.define(&claims, &mut b);
b.build()
}
fn admin(org_id: Uuid) -> Ability { ability_for(vec![Role::Admin], org_id) }
fn member(org_id: Uuid) -> Ability { ability_for(vec![Role::User], org_id) }

mask::<E>(action, &model) returns the wire JSON with disallowed keys stripped (not nulled). Assert the keys that must survive and the ones that must not — including columns with no #[expose], which never reach the wire even under a full field grant.

crates/features/src/authz/ability.rs (abridged)
#[test]
fn member_mask_strips_admin_only_fields() {
let org = Uuid::now_v7();
let json = member(org).mask::<user::Entity>(Action::Read, &user_model(Uuid::now_v7(), org));
let obj = json.as_object().expect("masked model is a JSON object");
assert!(obj.contains_key("id"));
assert!(obj.contains_key("name"));
assert!(!obj.contains_key("email"), "members must not see email");
}

The stripped key matters most: masking removes the key rather than emitting null, so a field grant can never leak an admin-only column — or an unexposed one — by omission.

Ability conditions — fail-closed by default

Section titled “Ability conditions — fail-closed by default”

condition_for::<E>(action) returns the SQL filter Repo applies to every read and by-id write. Render it with SeaORM’s query builder and assert on the SQL. A granted action scopes by tenant; an ungranted action pre-filters to nothing — the fail-closed 1 = 0, not TRUE.

crates/features/src/authz/ability.rs (abridged)
#[test]
fn member_read_scopes_to_own_org() {
let org = Uuid::now_v7();
let sql = user::Entity::find()
.filter(member(org).condition_for::<user::Entity>(Action::Read))
.build(DatabaseBackend::Postgres)
.to_string();
assert!(sql.contains("org_id"), "member reads scope by org_id: {sql}");
}
#[test]
fn member_cannot_delete_users() {
let org = Uuid::now_v7();
let sql = user::Entity::find()
.filter(member(org).condition_for::<user::Entity>(Action::Delete))
.build(DatabaseBackend::Postgres)
.to_string();
assert!(
sql.contains("1 = 0"),
"no Delete grant for members ⇒ pre-filter matches nothing: {sql}",
);
}

The second test pins the invariant that makes the layer safe: no grant means zero rows, never every row. A refactor that flipped the default to TRUE would turn a missing grant into a full-table leak; this test catches it the same day. Pair it with can::<E>(action, &model) / can_class(action, TypeId) for the per-row and per-class decisions.

Policy unit tests prove the code is correct in isolation. The e2e negative path proves it is wired in — that Repo reads through the ambient ability, the shaper masks the response on the way out, and forgetting to import AuthzHttpModule fails the boot rather than silently allowing unscoped reads.

  • Unit — every branch of mask, condition_for, can. Fast, exhaustive.
  • E2E negative path — the wrong caller gets 403, the masked response excludes unexposed columns, a missing authz module fails the boot loudly.

Built by YV17labs