npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@entitle/testing

v0.2.0

Published

Test utilities and helpers for Entitle

Readme

@entitle/testing

Test utilities for Entitle, a strongly-typed feature entitlements and access control system for TypeScript: a subject-hierarchy builder, assertion helpers for explain() trees, a mock clock, and an authorize stub for the HTTP adapters.

Three of these helpers changed behaviour in 0.1.1, and a test of yours may start failing because of it. Each change turns an assertion that was silently not made into one that is made. See Breaking changes in 0.1.1 below -- if one of them fails for you, the assertion was not being made before.

Install

pnpm add -D @entitle/testing

Subject builder

import { buildSubject } from '@entitle/testing'

type Kind = 'user' | 'team' | 'org'

const org = buildSubject<Kind>('org-1', 'org')
const team = buildSubject<Kind>('team-1', 'team').withParent(org)
const user = buildSubject<Kind>('user-1', 'user').withParent(team)

user.toChain() // [user-1, team-1, org-1]
user.toResolver() // a HierarchyResolver for the whole graph

Name the kinds once with the type parameter: buildSubject otherwise infers the kind from its argument, and withParent would only accept another builder of the same kind.

  • toResolver() answers the subject it is asked about. It indexes every subject reachable from the builder it was called on, so resolver('team-1', 'team') gets the team's chain, not the user's. A subject that is not in the graph is an error, not a silent substitution. The chain is computed per call, so a parent added after the resolver was taken is part of the hierarchy the engine sees.
  • toChain() de-duplicates by (kind, id). A diamond (user under both teamA and teamB, both under org) lists the org once.
  • withParent refuses a cycle and names the path. Two separately built builders with the same (kind, id) are one subject, so making one the other's parent is a cycle.
  • A parent you built yourself is accepted. SubjectBuilder is a structural type: an object of your own with a toChain() works, and its chain is appended as ancestors.

Assertion helpers

import { assertGranted, assertDenied, assertDeniedWith } from '@entitle/testing'

const tree = await engine.explain('user-1', 'user', 'seats')

assertGranted(tree) // throws if not granted
assertDenied(tree) // throws if not denied
assertDeniedWith(tree, { kind: 'condition_failed', policyId: 'pro' })

assertDeniedWith compares policyId whenever it is given. Only two deny reason kinds carry one -- condition_failed and policy_expired -- and asking a kind that does not, such as no_policy, is a caller error and is reported as one, naming the kind.

Mock clock

import { createMockClock } from '@entitle/testing'

const clock = createMockClock(new Date('2025-01-01'))

const engine = await createPolicyEngine({
  features,
  policies,
  providers,
  store,
  resolver: async (subjectId, kind) => [{ id: subjectId, kind }],
  clock: clock.now,
})

clock.advance(30 * 24 * 60 * 60 * 1000) // 30 days
clock.set(new Date('2025-06-01')) // or jump
console.log(engine.check)

Every Date is copied in both directions, so nothing your test holds can move the clock by accident. It also refuses to become unreadable: an Invalid Date passed to createMockClock or set, and a non-finite advance, throw. Every relational comparison against an Invalid Date is false, so a clock that quietly went NaN would not make expiries fire early or late -- it would make them never fire, and the suite would go green on a permanent entitlement.

Authorize stub

Every Entitle HTTP adapter requires an authorize hook, so every test that mounts one has to supply it. createAuthorizeStub is that hook plus the record of what it was asked -- which is how a test asserts both what the adapter told the hook and, when the expectation is that a request never got that far, that the hook was never reached at all.

import { createAuthorizeStub } from '@entitle/testing'

const allow = createAuthorizeStub<Request>()
const deny = createAuthorizeStub<Request>({ ok: false, code: 'FORBIDDEN' })

// Or decide per request -- including throwing, which is how a test exercises
// the adapter's fail-closed path.
const admin = createAuthorizeStub<Request>((ctx) =>
  ctx.access === 'write' ? { ok: false, code: 'FORBIDDEN' } : { ok: true, actorId: 'tester' },
)

allow.calls[0]?.operation // what the adapter passed the hook

Memory store

createMemoryStore is re-exported from @entitle/store-memory so a test needs one import instead of two. It is that package's own function, not a wrapper.

Breaking changes in 0.1.1

Each of these turns an assertion that was quietly skipped into one that runs. A failure means the assertion was not being made before.

  • assertDeniedWith(tree, { kind: 'no_policy', policyId: 'anything' }) passed for any no_policy denial: the comparison was guarded by 'policyId' in reason, so it was skipped rather than failed for every kind that does not carry one. It runs whenever policyId is given now, and asking a kind that carries none is reported as a caller error.
  • match.kind was string. It is DenyReason['kind'], so a misspelled kind is a compile error rather than a test that fails for the wrong reason.
  • toResolver() discarded both arguments and answered every subject with the chain of the builder it was called on. It answers the subject it is asked about, and throws for one that is not in the graph.
  • toChain() listed a diamond's shared ancestor twice, so a 'sum' merge field counted its grant twice. De-duplicated by (kind, id).
  • withParent did no cycle check, so a mutual parent recursed until the stack overflowed. It refuses a cycle and names the path.
  • createMockClock(...).advance(NaN) produced an Invalid Date, against which every comparison is false, so expiries never fired. A non-finite advance, an advance out of Date range, and an Invalid Date all throw.

Links

  • @entitle/core -- the engine, and the security guidance for the HTTP adapters. Installed alongside this package at node_modules/@entitle/core/README.md.
  • @entitle/store-memory -- the store re-exported here.

License

MIT