298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
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([])
|
||
})
|
||
})
|