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.
Build an ability from claims
Section titled “Build an ability from claims”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.
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) }Masking — every grant scenario
Section titled “Masking — every grant scenario”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.
#[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.
#[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.
What still belongs in e2e
Section titled “What still belongs in e2e”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.
Going further
Section titled “Going further”Built by YV17labs