Deepen PracticeOrdered learning track

Unit Testing Permission Logic

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 092

Unit testing permission logic in React apps with deterministic can() matrices, RBAC/ABAC/ReBAC/ACL cases, deny-by-default behavior, stale permission handling, and authorization regression tests.

8 min read1475 words
PrevNext
Lesson 92130 lesson track72–107 Deepen Practice
#react#authorization#permission#acl+6 more

Part 092 — Unit Testing Permission Logic

Permission bugs rarely look dramatic in code review.

They look like this:

if (user.role === "admin") {
  showDeleteButton();
}

or this:

const canEdit = user.permissions.includes("case:edit");

or this:

if (caseFile.ownerId === user.id || user.role === "manager") {
  return true;
}

Each line seems harmless. The real problem appears when the system grows:

  • tenant-scoped roles,
  • inherited group permission,
  • workflow state restrictions,
  • temporary grants,
  • delegated admin,
  • separation of duties,
  • field-level rules,
  • object-level ACL,
  • step-up requirements,
  • impersonation,
  • permission cache invalidation,
  • policy versioning.

Permission logic must be tested as a decision system, not as scattered UI conditions.

This part focuses on unit testing permission logic: the pure core behind can(), useCan(), route guards, menu filtering, form modes, table row actions, and action eligibility.


1. The target: deterministic can()

The smallest useful permission primitive is not a boolean.

A boolean loses too much information.

Bad:

function canEditCase(user: User, caseFile: CaseFile): boolean {
  return user.role === "admin" || caseFile.ownerId === user.id;
}

Better:

export type PermissionDecision =
  | {
      effect: "allow";
      reason: "explicit_grant" | "owner" | "role_permission" | "inherited_permission";
    }
  | {
      effect: "deny";
      reason:
        | "anonymous"
        | "missing_permission"
        | "tenant_mismatch"
        | "resource_state"
        | "separation_of_duties"
        | "permission_unknown";
      publicMessage?: string;
    }
  | {
      effect: "challenge";
      reason: "step_up_required";
      requiredAcr?: string;
    };

Then define a generic request.

export interface PermissionRequest<Resource = unknown> {
  subject: Subject | null;
  action: string;
  resource?: Resource;
  context: PermissionContext;
}

A pure can() function:

export function can<Resource>(request: PermissionRequest<Resource>): PermissionDecision {
  const { subject, action, resource, context } = request;

  if (!subject) {
    return { effect: "deny", reason: "anonymous" };
  }

  if (context.permissionStatus !== "ready") {
    return { effect: "deny", reason: "permission_unknown" };
  }

  if (resource && hasTenant(resource) && resource.tenantId !== context.tenantId) {
    return { effect: "deny", reason: "tenant_mismatch" };
  }

  if (requiresStepUp(action) && !context.assurance.fresh) {
    return { effect: "challenge", reason: "step_up_required", requiredAcr: "mfa" };
  }

  if (hasExplicitPermission(subject, action, context)) {
    return { effect: "allow", reason: "role_permission" };
  }

  return { effect: "deny", reason: "missing_permission" };
}

This style gives tests something precise to assert.


2. Boolean checks hide failure semantics

Consider two denied users:

  1. user is not logged in,
  2. user is logged in but lacks case.approve,
  3. user has permission but resource belongs to another tenant,
  4. user has permission but needs MFA step-up,
  5. permission service failed so permission is unknown.

All are false in a boolean API.

But each needs different UI and system behavior.

DecisionUI behaviorSystem behavior
anonymouslogin promptdo not call protected action
missing permissiondisabled/explained actionshow access request path
tenant mismatchnot found/forbiddensecurity log
step-up requiredMFA challengepreserve intent
permission unknownfail closed/degradedretry/reload option

Unit tests should preserve this distinction.


3. Test deny-by-default first

The most important permission tests are deny tests.

Allow tests prove access works. Deny tests prove the boundary exists.

import { describe, expect, it } from "vitest";
import { can } from "./permissions";

describe("can() deny by default", () => {
  it("denies anonymous subject", () => {
    expect(
      can({
        subject: null,
        action: "case.view",
        resource: { id: "case_1", tenantId: "tenant_a" },
        context: readyContext({ tenantId: "tenant_a" }),
      }),
    ).toEqual({ effect: "deny", reason: "anonymous" });
  });

  it("denies when permission status is unknown", () => {
    expect(
      can({
        subject: subjectWithPermissions(["case.view"]),
        action: "case.view",
        resource: { id: "case_1", tenantId: "tenant_a" },
        context: {
          tenantId: "tenant_a",
          permissionStatus: "loading",
          assurance: { fresh: true },
        },
      }),
    ).toEqual({ effect: "deny", reason: "permission_unknown" });
  });

  it("denies unknown action", () => {
    expect(
      can({
        subject: subjectWithPermissions(["case.view"]),
        action: "case.magic_delete_everything",
        resource: { id: "case_1", tenantId: "tenant_a" },
        context: readyContext({ tenantId: "tenant_a" }),
      }),
    ).toEqual({ effect: "deny", reason: "missing_permission" });
  });
});

If you only test the allow path, you are not testing authorization. You are testing convenience.


4. Build a permission fixture DSL

Raw fixtures become unreadable quickly.

Bad:

const user = {
  id: "u1",
  roles: [{ id: "r1", tenantId: "t1", permissions: ["case.view"] }],
  groups: [],
  grants: [],
};

Better:

const alice = subject("alice")
  .inTenant("tenant_a")
  .withRole("case_worker", ["case.view", "case.edit"])
  .build();

const caseA = resource("case", "case_1")
  .inTenant("tenant_a")
  .ownedBy("alice")
  .withState("draft")
  .build();

Example fixture builders:

export function subject(id: string) {
  const roles: RoleAssignment[] = [];
  const grants: Grant[] = [];
  let tenantId = "tenant_a";

  return {
    inTenant(value: string) {
      tenantId = value;
      return this;
    },
    withRole(roleId: string, permissions: string[]) {
      roles.push({ roleId, tenantId, permissions });
      return this;
    },
    withGrant(action: string, resourceId: string) {
      grants.push({ action, resourceId });
      return this;
    },
    build(): Subject {
      return { id, tenantId, roles, grants };
    },
  };
}

Fixtures should read like policy stories.


5. Matrix testing for RBAC

RBAC tests should assert the role-permission mapping without coupling UI to role names.

const rbacMatrix = [
  {
    role: "viewer",
    permissions: ["case.view"],
    action: "case.view",
    expected: "allow",
  },
  {
    role: "viewer",
    permissions: ["case.view"],
    action: "case.edit",
    expected: "deny",
  },
  {
    role: "editor",
    permissions: ["case.view", "case.edit"],
    action: "case.edit",
    expected: "allow",
  },
  {
    role: "approver",
    permissions: ["case.view", "case.approve"],
    action: "case.delete",
    expected: "deny",
  },
] as const;

it.each(rbacMatrix)(
  "$role evaluating $action -> $expected",
  ({ role, permissions, action, expected }) => {
    const user = subject("alice").withRole(role, [...permissions]).build();

    const decision = can({
      subject: user,
      action,
      resource: caseInTenant("tenant_a"),
      context: readyContext({ tenantId: "tenant_a" }),
    });

    expect(decision.effect).toBe(expected);
  },
);

The test does not say:

user.role === "admin"

It says:

role assignment produces permission decision

That is the correct abstraction.


6. Test tenant scope explicitly

Multi-tenant auth bugs are severe because they cross isolation boundaries.

it("denies action when resource belongs to another tenant", () => {
  const user = subject("alice")
    .inTenant("tenant_a")
    .withRole("editor", ["case.edit"])
    .build();

  const resource = caseResource({
    id: "case_2",
    tenantId: "tenant_b",
    ownerId: "alice",
    state: "draft",
  });

  expect(
    can({
      subject: user,
      action: "case.edit",
      resource,
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({ effect: "deny", reason: "tenant_mismatch" });
});

Do not assume tenant is covered by route params or API URL. Test it inside the decision model.


7. Test object-level ACL

Object-level permission checks answer:

Is this subject allowed to perform this action on this specific object?

Example model:

export interface CaseFile {
  type: "case";
  id: string;
  tenantId: string;
  ownerId: string;
  state: "draft" | "submitted" | "approved" | "closed";
  acl: Array<{
    subjectId: string;
    relation: "viewer" | "editor" | "approver";
  }>;
}

Test ACL-specific rules.

it("allows editor relation to edit draft case", () => {
  const user = subject("alice").build();

  const resource: CaseFile = {
    type: "case",
    id: "case_1",
    tenantId: "tenant_a",
    ownerId: "bob",
    state: "draft",
    acl: [{ subjectId: "alice", relation: "editor" }],
  };

  expect(
    can({
      subject: user,
      action: "case.edit",
      resource,
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({ effect: "allow", reason: "explicit_grant" });
});

it("denies viewer relation from editing case", () => {
  const user = subject("alice").build();

  const resource: CaseFile = {
    type: "case",
    id: "case_1",
    tenantId: "tenant_a",
    ownerId: "bob",
    state: "draft",
    acl: [{ subjectId: "alice", relation: "viewer" }],
  };

  expect(
    can({
      subject: user,
      action: "case.edit",
      resource,
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({ effect: "deny", reason: "missing_permission" });
});

A viewer should not become editor because the UI accidentally exposes a button.


8. Test ABAC conditions

ABAC rules depend on attributes.

Example rule:

User may edit a case only when they have case.edit, the case is in draft, the case is inside their tenant, and the user is not currently impersonating.

Test each condition independently.

const editCaseCases = [
  {
    name: "allows editor to edit draft case",
    permissions: ["case.edit"],
    resourceState: "draft",
    impersonating: false,
    expected: { effect: "allow", reason: "role_permission" },
  },
  {
    name: "denies edit when case is approved",
    permissions: ["case.edit"],
    resourceState: "approved",
    impersonating: false,
    expected: { effect: "deny", reason: "resource_state" },
  },
  {
    name: "denies edit while impersonating",
    permissions: ["case.edit"],
    resourceState: "draft",
    impersonating: true,
    expected: { effect: "deny", reason: "separation_of_duties" },
  },
  {
    name: "denies edit without permission",
    permissions: [],
    resourceState: "draft",
    impersonating: false,
    expected: { effect: "deny", reason: "missing_permission" },
  },
] as const;

it.each(editCaseCases)("$name", ({ permissions, resourceState, impersonating, expected }) => {
  const user = subject("alice").withRole("custom", [...permissions]).build();
  const resource = caseResource({ state: resourceState });

  expect(
    can({
      subject: user,
      action: "case.edit",
      resource,
      context: readyContext({
        tenantId: "tenant_a",
        impersonating,
      }),
    }),
  ).toMatchObject(expected);
});

Notice the matrix isolates variables. That makes policy regressions visible.


9. Test ReBAC with relationship tuples

Relationship-based authorization is often easier to test if you model tuples directly.

export type Tuple = {
  object: string;
  relation: string;
  user: string;
};

const tuples: Tuple[] = [
  { object: "org:acme", relation: "member", user: "user:alice" },
  { object: "project:apollo", relation: "parent", user: "org:acme" },
  { object: "case:case_1", relation: "parent", user: "project:apollo" },
  { object: "case:case_1", relation: "editor", user: "user:bob" },
];

Test direct relation:

it("allows direct editor relation", () => {
  expect(
    checkRelation({
      tuples,
      user: "user:bob",
      relation: "edit",
      object: "case:case_1",
    }),
  ).toEqual({ allowed: true, path: ["case:case_1#editor@user:bob"] });
});

Test inherited relation:

it("allows inherited org member to view project case", () => {
  expect(
    checkRelation({
      tuples,
      user: "user:alice",
      relation: "view",
      object: "case:case_1",
    }).allowed,
  ).toBe(true);
});

Test missing relation:

it("denies unrelated user", () => {
  expect(
    checkRelation({
      tuples,
      user: "user:eve",
      relation: "view",
      object: "case:case_1",
    }),
  ).toMatchObject({ allowed: false });
});

For a real OpenFGA/Zanzibar-style engine, unit tests should cover the local mapping layer and contract tests should cover the real policy service.


10. Test separation of duties

Regulated systems often have rules like:

  • creator cannot approve their own case,
  • investigator cannot be final approver,
  • person who requested access cannot approve that same access,
  • support impersonation cannot perform destructive actions,
  • reviewer cannot edit evidence after approval.

These rules must be unit-tested because they are business-critical.

it("denies approval by case creator", () => {
  const user = subject("alice")
    .withRole("approver", ["case.approve"])
    .build();

  const resource = caseResource({
    ownerId: "alice",
    state: "submitted",
  });

  expect(
    can({
      subject: user,
      action: "case.approve",
      resource,
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({ effect: "deny", reason: "separation_of_duties" });
});

This is not just a security rule. It is a defensibility rule.


11. Test step-up permission

Some actions are allowed only after fresh authentication or MFA.

it("challenges when sensitive action requires fresh MFA", () => {
  const user = subject("alice")
    .withRole("admin", ["user.delete"])
    .build();

  expect(
    can({
      subject: user,
      action: "user.delete",
      resource: userResource("target_user"),
      context: readyContext({
        tenantId: "tenant_a",
        assurance: { fresh: false, acr: "pwd" },
      }),
    }),
  ).toEqual({
    effect: "challenge",
    reason: "step_up_required",
    requiredAcr: "mfa",
  });
});

it("allows sensitive action after fresh MFA", () => {
  const user = subject("alice")
    .withRole("admin", ["user.delete"])
    .build();

  expect(
    can({
      subject: user,
      action: "user.delete",
      resource: userResource("target_user"),
      context: readyContext({
        tenantId: "tenant_a",
        assurance: { fresh: true, acr: "mfa" },
      }),
    }).effect,
  ).toBe("allow");
});

A step-up decision is not a denial. It is a challenge decision.

Tests should preserve that difference.


12. Test field-level permission

Field-level permission is usually where frontend/backend drift appears.

Define field decisions.

export type FieldMode = "hidden" | "masked" | "readonly" | "editable";

export interface FieldPermission {
  field: string;
  mode: FieldMode;
  reason?: string;
}

Example evaluator:

export function evaluateCaseFields(
  subject: Subject,
  resource: CaseFile,
  context: PermissionContext,
): FieldPermission[] {
  return [
    {
      field: "title",
      mode: can({ subject, action: "case.edit.title", resource, context }).effect === "allow"
        ? "editable"
        : "readonly",
    },
    {
      field: "riskScore",
      mode: can({ subject, action: "case.view.risk_score", resource, context }).effect === "allow"
        ? "readonly"
        : "masked",
    },
    {
      field: "internalNotes",
      mode: can({ subject, action: "case.view.internal_notes", resource, context }).effect === "allow"
        ? "editable"
        : "hidden",
    },
  ];
}

Test modes.

it("masks risk score when user lacks risk-score permission", () => {
  const user = subject("alice")
    .withRole("case_worker", ["case.view", "case.edit.title"])
    .build();

  const fields = evaluateCaseFields(
    user,
    caseResource({ state: "draft" }),
    readyContext({ tenantId: "tenant_a" }),
  );

  expect(fields).toContainEqual({ field: "riskScore", mode: "masked" });
});

Field permission tests should match server-side field filtering tests. If frontend says hidden but backend returns the field, sensitive data can still leak.


13. Test bulk action eligibility

Bulk actions are easy to get wrong.

Example:

  • user selects 20 cases,
  • 18 can be approved,
  • 2 cannot,
  • UI says “Approve 20”.

You need deterministic bulk decision.

export type BulkDecision =
  | { effect: "allow_all"; allowed: string[] }
  | { effect: "allow_partial"; allowed: string[]; denied: Array<{ id: string; reason: string }> }
  | { effect: "deny_all"; denied: Array<{ id: string; reason: string }> };

Test it.

it("returns partial decision when only some rows are approvable", () => {
  const user = subject("alice")
    .withRole("approver", ["case.approve"])
    .build();

  const resources = [
    caseResource({ id: "case_1", state: "submitted" }),
    caseResource({ id: "case_2", state: "draft" }),
  ];

  expect(
    evaluateBulk({
      subject: user,
      action: "case.approve",
      resources,
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({
    effect: "allow_partial",
    allowed: ["case_1"],
    denied: [{ id: "case_2", reason: "resource_state" }],
  });
});

Bulk tests should assert both allowed IDs and denied reasons.


14. Test stale permission snapshots

Permission snapshots are projections. They can become stale.

export interface PermissionContext {
  tenantId: string;
  permissionStatus: "loading" | "ready" | "stale" | "error";
  permissionEpoch?: number;
  policyVersion?: string;
  assurance: {
    fresh: boolean;
    acr?: string;
  };
  impersonating?: boolean;
}

Test fail-closed behavior.

it("denies when permission snapshot is stale for destructive action", () => {
  const user = subject("alice")
    .withRole("admin", ["case.delete"])
    .build();

  expect(
    can({
      subject: user,
      action: "case.delete",
      resource: caseResource({ state: "draft" }),
      context: {
        tenantId: "tenant_a",
        permissionStatus: "stale",
        permissionEpoch: 3,
        assurance: { fresh: true },
      },
    }),
  ).toEqual({ effect: "deny", reason: "permission_unknown" });
});

You can choose to allow low-risk reads with stale permission in some products, but that should be explicit and tested.


15. Test policy version changes

Policy version should be part of regression safety.

it("evaluates policy v2 stricter approval rule", () => {
  const user = subject("alice")
    .withRole("approver", ["case.approve"])
    .build();

  const resource = caseResource({
    ownerId: "alice",
    state: "submitted",
  });

  const decision = can({
    subject: user,
    action: "case.approve",
    resource,
    context: readyContext({
      tenantId: "tenant_a",
      policyVersion: "case-policy-v2",
    }),
  });

  expect(decision).toEqual({ effect: "deny", reason: "separation_of_duties" });
});

When policy changes, do not overwrite old tests blindly. Add tests that explain why the new behavior exists.


16. Golden permission matrix

For mature systems, create a golden matrix.

Example:

const goldenMatrix = [
  ["anonymous", "case.view", "draft", "deny:anonymous"],
  ["viewer", "case.view", "draft", "allow:role_permission"],
  ["viewer", "case.edit", "draft", "deny:missing_permission"],
  ["editor", "case.edit", "draft", "allow:role_permission"],
  ["editor", "case.edit", "approved", "deny:resource_state"],
  ["approver", "case.approve", "submitted", "allow:role_permission"],
  ["creator_approver", "case.approve", "submitted", "deny:separation_of_duties"],
] as const;

it.each(goldenMatrix)(
  "%s performing %s on %s -> %s",
  (persona, action, state, expected) => {
    const { subject, resource, context } = fixtureFor(persona, state);
    const decision = can({ subject, action, resource, context });

    expect(formatDecision(decision)).toBe(expected);
  },
);

A golden matrix is valuable because product managers, security engineers, and backend/frontend engineers can review it together.


17. Mutation testing mindset

Permission tests should catch dangerous mutations.

Ask:

  • If someone changes && to ||, do tests fail?
  • If tenant check is removed, do tests fail?
  • If stale permission becomes allow, do tests fail?
  • If 403 is mapped to login, do tests fail?
  • If field-level mask is removed, do tests fail?
  • If owner can approve own case, do tests fail?

Example dangerous mutation:

// Bad mutation
if (hasPermission(subject, action) || resource.tenantId === context.tenantId) {
  return { effect: "allow", reason: "role_permission" };
}

A good test suite catches it with:

it("does not allow tenant member without action permission", () => {
  const user = subject("alice")
    .withRole("viewer", ["case.view"])
    .build();

  expect(
    can({
      subject: user,
      action: "case.delete",
      resource: caseResource({ tenantId: "tenant_a" }),
      context: readyContext({ tenantId: "tenant_a" }),
    }),
  ).toEqual({ effect: "deny", reason: "missing_permission" });
});

Authorization tests should be hostile to accidental broadening.


18. Property-style checks without overengineering

You do not need a full property-testing framework to start.

You can write simple generated cases.

const destructiveActions = ["case.delete", "user.delete", "role.assign"];
const unsafePermissionStatuses = ["loading", "stale", "error"] as const;

for (const action of destructiveActions) {
  for (const permissionStatus of unsafePermissionStatuses) {
    it(`denies ${action} when permission status is ${permissionStatus}`, () => {
      const user = subject("alice")
        .withRole("admin", [action])
        .build();

      expect(
        can({
          subject: user,
          action,
          resource: caseResource({ state: "draft" }),
          context: {
            tenantId: "tenant_a",
            permissionStatus,
            assurance: { fresh: true },
          },
        }).effect,
      ).toBe("deny");
    });
  }
}

This catches broad classes of unsafe behavior.


19. Testing permission explanation safely

Permission explanation is useful for UI and support, but it can leak internals.

Test public reason separately from internal reason.

export interface DenyDecision {
  effect: "deny";
  reason: string;
  publicMessage: string;
  internalTraceId?: string;
}

Test safe messages.

it("does not expose tenant existence in public denial message", () => {
  const decision = can({
    subject: subject("alice").build(),
    action: "case.view",
    resource: caseResource({ tenantId: "tenant_b" }),
    context: readyContext({ tenantId: "tenant_a" }),
  });

  expect(decision).toMatchObject({
    effect: "deny",
    reason: "tenant_mismatch",
  });

  if (decision.effect === "deny") {
    expect(decision.publicMessage).not.toContain("tenant_b");
    expect(decision.publicMessage).not.toContain("tenant mismatch");
  }
});

The UI can say:

You do not have access to this resource.

The audit log can say:

tenant mismatch: subject tenant_a attempted resource tenant_b.

Those are different channels.


20. Snapshot tests are usually weak for permissions

Snapshot tests can be useful for large matrices, but they are dangerous when used lazily.

Weak:

expect(decision).toMatchSnapshot();

Better:

expect(decision).toEqual({
  effect: "deny",
  reason: "separation_of_duties",
});

Snapshots can hide changes in a big diff. Authorization tests should be explicit.

Use snapshots only for generated documentation matrices after the semantic tests already exist.


21. Keep frontend permission tests aligned with backend policy

Frontend permission logic is often projection logic.

The backend remains the enforcement boundary.

Therefore, test alignment:

  1. shared action vocabulary,
  2. shared field names,
  3. shared denial reason enum,
  4. shared schema version,
  5. shared permission epoch semantics,
  6. shared bulk decision contract.

Example schema assertion:

it("frontend action vocabulary matches generated contract", () => {
  expect(frontendActions.sort()).toEqual(contractActions.sort());
});

Example denial reason assertion:

it("knows all backend denial reasons", () => {
  expect(frontendDenyReasons.sort()).toEqual(backendDenyReasons.sort());
});

If backend adds legal_hold as denial reason and frontend treats it as unknown, the UI may show the wrong recovery path.


22. Permission test review checklist

Use this checklist before approving permission logic changes.

  • Anonymous subject denies by default.
  • Unknown action denies by default.
  • Missing permission denies.
  • Tenant mismatch denies.
  • Resource state restrictions are tested.
  • Separation of duties is tested.
  • Owner-specific rule is tested.
  • ACL direct grant is tested.
  • Inherited relation is tested.
  • Missing relation denies.
  • Step-up challenge is tested separately from denial.
  • Stale/loading/error permission snapshots fail closed.
  • Field-level modes are tested.
  • Bulk partial eligibility is tested.
  • Impersonation restrictions are tested.
  • Policy version changes have regression tests.
  • Public denial messages do not leak internal details.
  • Frontend action vocabulary matches backend contract.
  • Tests include more deny cases than allow cases.

23. Closing mental model

Permission logic is not a UI feature.

It is a decision model.

Good unit tests turn authorization into a set of executable policy claims:

subject + action + resource + context => decision

Each test should answer one question:

Under these exact facts, what is the only safe decision?

That is the level of precision required for production-grade authorization.


24. References

Lesson Recap

You just completed lesson 92 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.