Files
scope-gate/test/scope-gate.test.ts
2026-09-28 14:55:57 -04:00

298 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from 'vitest'
import { validateReport } from '@extant2000/evidence-record'
import {
DEFAULT_QUOTAS,
explainPlan,
formatAccess,
toReport,
MemoryStore,
createLimiter,
effectivePlan,
hasEntitlement,
meetsPlan,
planRank,
createSyncLimiter,
planWriteQuota,
type SuiteGating,
} from '../src/index.js'
const gating: SuiteGating = {
plans: ['free', 'pro', 'govcon'],
planEntitlements: {
free: ['dashboard'],
pro: ['dashboard', 'exports'],
govcon: ['dashboard', 'exports', 'audit'],
},
adminRole: 'admin',
roleField: 'role',
}
describe('effectivePlan', () => {
it('returns empty string when signed out', () => {
expect(effectivePlan(null, gating)).toBe('')
expect(effectivePlan({ profile: null }, gating)).toBe('')
})
it('falls back to the base plan with no entitlement row', () => {
expect(effectivePlan({ profile: {} }, gating)).toBe('free')
})
it('grants the paid plan on an active subscription', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'active' } }, gating,
)).toBe('pro')
})
it('keeps access while past_due — dunning decides, not the gate', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'past_due' } }, gating,
)).toBe('pro')
})
it('drops to base on a cancelled subscription', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'canceled' } }, gating,
)).toBe('free')
})
it('grants the top plan to the admin role', () => {
expect(effectivePlan({ profile: { role: 'admin' } }, gating)).toBe('govcon')
})
it('honours a custom roleField', () => {
const g = { ...gating, roleField: 'hr_role' }
expect(effectivePlan({ profile: { hr_role: 'admin' } }, g)).toBe('govcon')
// The default 'role' field must NOT be consulted once roleField is set.
expect(effectivePlan({ profile: { role: 'admin' } }, g)).toBe('free')
})
it('CROSS-PRODUCT ISOLATION: a sibling product subscription does not unlock this one', () => {
// Several products share one identity store. A billing field on the shared
// profile must never grant scope — only the product-scoped entitlement row.
const src = {
profile: { stripe_subscription_status: 'active', stripe_plan: 'govcon' },
entitlement: null,
}
expect(effectivePlan(src, gating)).toBe('free')
})
it('ignores a plan key that is not in this product gating', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'enterprise', status: 'active' } }, gating,
)).toBe('free')
})
})
describe('hasEntitlement / planRank / meetsPlan', () => {
it('gates features by the effective plan', () => {
const pro = { profile: {}, entitlement: { plan: 'pro', status: 'active' } }
expect(hasEntitlement(pro, 'exports', gating)).toBe(true)
expect(hasEntitlement(pro, 'audit', gating)).toBe(false)
})
it('ranks plans by declared order', () => {
expect(planRank('free', gating)).toBe(0)
expect(planRank('govcon', gating)).toBe(2)
expect(planRank('nope', gating)).toBe(-1)
})
it('meetsPlan compares rank', () => {
const pro = { profile: {}, entitlement: { plan: 'pro', status: 'active' } }
expect(meetsPlan(pro, 'free', gating)).toBe(true)
expect(meetsPlan(pro, 'pro', gating)).toBe(true)
expect(meetsPlan(pro, 'govcon', gating)).toBe(false)
})
it('treats an UNKNOWN minPlan as not-gated (documented fail-open)', () => {
// Deliberate: this model drives surfaces shown locked rather than removed,
// so a typo costs a visible feature, not a silent one.
expect(meetsPlan({ profile: {} }, 'typoo', gating)).toBe(true)
})
})
describe('planWriteQuota', () => {
it('uses the default table', () => {
expect(planWriteQuota('free')).toBe(DEFAULT_QUOTAS.free)
expect(planWriteQuota('pro')).toBe(DEFAULT_QUOTAS.pro)
})
it('falls back to default for an unknown plan', () => {
expect(planWriteQuota('mystery')).toBe(DEFAULT_QUOTAS.default)
})
it('treats a null plan as none', () => {
expect(planWriteQuota(null)).toBe(DEFAULT_QUOTAS.none)
})
it('returns 0 (unlimited) for enterprise', () => {
expect(planWriteQuota('enterprise')).toBe(0)
})
it('lets a global env var override', () => {
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: '5' } })).toBe(5)
})
it('lets a per-product env var beat the global', () => {
const env = { RATE_LIMIT_WRITES_FREE: '5', RATE_LIMIT_WRITES_CRM_FREE: '99' }
expect(planWriteQuota('free', { env, product: 'crm' })).toBe(99)
})
it('accepts the literal "unlimited"', () => {
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: 'unlimited' } })).toBe(0)
})
it('ignores a malformed env value rather than failing open to 0', () => {
// A negative or non-numeric override must not silently become "unlimited".
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: 'abc' } })).toBe(60)
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: '-1' } })).toBe(60)
})
})
describe('createLimiter', () => {
let store: MemoryStore
beforeEach(() => { store = new MemoryStore() })
it('allows up to the quota then rejects', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '3' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
for (let i = 0; i < 3; i++) {
expect((await check(input)).allowed).toBe(true)
}
const denied = await check(input)
expect(denied.allowed).toBe(false)
expect(denied.limit).toBe(3)
expect(denied.remaining).toBe(0)
expect(denied.retryAfter).toBeGreaterThan(0)
expect(denied.message).toContain('3 changes/minute')
})
it('counts down remaining', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '3' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect((await check(input)).remaining).toBe(2)
expect((await check(input)).remaining).toBe(1)
expect((await check(input)).remaining).toBe(0)
})
it('budgets each account separately', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'crm', userId: 'u2', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(false)
})
it('budgets each product separately for the same account', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'books', userId: 'u1', plan: 'free' })).allowed).toBe(true)
})
it('never limits an unlimited plan', async () => {
const check = createLimiter({ store })
for (let i = 0; i < 50; i++) {
expect((await check({ product: 'crm', userId: 'u1', plan: 'enterprise' })).allowed).toBe(true)
}
})
it('does not bill unauthenticated writes to a shared empty key', async () => {
// Otherwise one anonymous caller exhausts the budget for every other one.
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
for (let i = 0; i < 10; i++) {
expect((await check({ product: 'crm', userId: '', plan: 'free' })).allowed).toBe(true)
}
})
it('accepts an injected shared store — the multi-instance fix', async () => {
// Two "instances" sharing one store must share one budget. With the old
// module-level Map, each instance kept its own counters and the effective
// limit silently became N× the configured one.
const shared = new MemoryStore()
const a = createLimiter({ store: shared, env: { RATE_LIMIT_WRITES_FREE: '2' } })
const b = createLimiter({ store: shared, env: { RATE_LIMIT_WRITES_FREE: '2' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect((await a(input)).allowed).toBe(true)
expect((await b(input)).allowed).toBe(true)
expect((await a(input)).allowed).toBe(false)
})
})
describe('createSyncLimiter', () => {
it('returns a decision synchronously, not a promise', () => {
// The reason this exists: every product middleware calls the limiter
// WITHOUT await. If the limiter were async, the 429 would surface as an
// unhandled rejection and the write would proceed — a limit that reports
// correctly and enforces nothing.
const check = createSyncLimiter({
store: new MemoryStore(), env: { RATE_LIMIT_WRITES_FREE: '2' },
})
const d = check({ product: 'crm', userId: 'u1', plan: 'free' })
expect(d).not.toBeInstanceOf(Promise)
expect(d.allowed).toBe(true)
})
it('enforces the same budget as the async limiter', () => {
const check = createSyncLimiter({
store: new MemoryStore(), env: { RATE_LIMIT_WRITES_FREE: '2' },
})
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect(check(input).allowed).toBe(true)
expect(check(input).allowed).toBe(true)
expect(check(input).allowed).toBe(false)
})
it('exempts unlimited plans and anonymous callers', () => {
const check = createSyncLimiter({ store: new MemoryStore() })
expect(check({ product: 'crm', userId: 'u1', plan: 'enterprise' }).limit).toBe(Infinity)
expect(check({ product: 'crm', userId: '', plan: 'free' }).limit).toBe(Infinity)
})
})
describe('explainPlan — a decision you can answer for', () => {
const gating: SuiteGating = {
plans: ['free', 'pro', 'enterprise'],
adminRole: 'admin',
}
const pro = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'active' } }
it('separates a genuine grant from a fail-open on an unknown plan', () => {
// meetsPlan returns true for both. Only one of them is a grant.
expect(meetsPlan(pro, 'pro', gating)).toBe(true)
expect(meetsPlan(pro, 'tpyo', gating)).toBe(true)
expect(explainPlan(pro, 'pro', gating).determination).toBe('pass')
const typo = explainPlan(pro, 'tpyo', gating)
expect(typo.determination).toBe('not-applicable')
expect(typo.reason).toContain('allowed by fail-open, NOT granted by the plan')
})
it('names the subscription status that caused a fallback', () => {
const cancelled = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'cancelled' } }
const x = explainPlan(cancelled, 'pro', gating)
expect(x.determination).toBe('fail')
expect(x.reason).toContain('"cancelled" is not an access-granting status')
expect(x.effectivePlan).toBe('free')
})
it('past_due keeps access, and says so as a pass', () => {
const pastDue = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'past_due' } }
expect(explainPlan(pastDue, 'pro', gating).determination).toBe('pass')
})
it('a signed-out principal is a denial, not an absence of assessment', () => {
expect(explainPlan(null, 'pro', gating).determination).toBe('fail')
})
it('cites the entitlement row behind each decision', () => {
const text = formatAccess(pro, [{ feature: 'bulk export', minPlan: 'enterprise' }], gating, 'u1', { evidence: 'full' })
expect(text).toContain('entitlements#u1 (plan)')
expect(text).toContain('entitlements#u1 (status)')
})
it('every conclusion carries a citation', () => {
const r = toReport(pro, [{ feature: 'bulk export', minPlan: 'enterprise' }], gating, 'u1')
expect(validateReport(r)).toEqual([])
})
})