GenerAI / worldmonitor /server /__tests__ /entitlement-check.test.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
51.1 kB
// @vitest-environment node
/**
* Unit tests for gateway entitlement check logic.
*
* Mocking strategy: Controls the Redis mock return value to steer what
* getEntitlements returns β€” no dependency injection needed. Since CONVEX_SITE_URL
* is not set in most tests, the Convex fallback is skipped and getCachedJson is
* the sole source of entitlement data.
*
* Per-file @vitest-environment node override avoids edge-runtime's missing
* process.env for these helpers.
*/
import { describe, test, expect, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock the Redis dependency so the module loads without a real Redis connection
// ---------------------------------------------------------------------------
vi.mock("../_shared/redis", () => ({
getCachedJson: vi.fn().mockResolvedValue(null),
setCachedJson: vi.fn().mockResolvedValue(undefined),
}));
import { getCachedJson, setCachedJson } from "../_shared/redis";
import {
getRequiredTier,
checkEntitlement,
getEntitlements,
classifyBillingVerification,
getBillingVerificationDenial,
__negativeCacheMaxEntriesForTests,
__negativeCacheSizeForTests,
__negativeCacheTtlMsForTests,
__resetEntitlementNegativeCacheForTests,
} from "../_shared/entitlement-check";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const FUTURE = Date.now() + 86400000 * 30;
function makeEntitlements(tier: number, planKey = "free") {
return {
planKey,
features: {
tier,
apiAccess: tier >= 2,
apiRateLimit: tier >= 2 ? 60 : 0,
maxDashboards: tier >= 1 ? 10 : 3,
prioritySupport: tier >= 2,
exportFormats: tier >= 2 ? ["csv", "json", "pdf"] : [],
// Plan 2026-05-10-001 U10 added mcpAccess to the feature set. Cache
// entries lacking this field are now treated as stale by
// _getEntitlementsImpl (round-2 P2-cache fix), so test fixtures
// must include it to be considered fresh.
mcpAccess: tier >= 1,
// Plan 2026-07-25-001 U1 added dataExport. Mirrors the catalog for the
// tiers this factory can express β€” Pro Business also exports at tier 1,
// but it is not reachable through a tier-only fixture. Deliberately NOT
// part of the cache-staleness gate (undefined fail-opens at tier >= 2).
dataExport: tier >= 2,
},
validUntil: FUTURE,
};
}
async function withConvexEntitlementResponse<T>(
payload: unknown,
run: () => Promise<T>,
): Promise<T> {
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
));
try {
return await run();
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
}
// Like withConvexEntitlementResponse, but with full control over the fetch
// outcome (throw, 5xx, 4xx) β€” used to pin the transient-vs-confirmed split.
async function withConvexEntitlementFetch<T>(
fetchImpl: () => Promise<Response>,
run: () => Promise<T>,
): Promise<T> {
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
vi.stubGlobal("fetch", vi.fn().mockImplementation(fetchImpl));
try {
return await run();
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("gateway entitlement check", () => {
test.each([
"/api/intelligence/v1/classify-event",
"/api/market/v1/analyze-stock",
"/api/market/v1/get-stock-analysis-history",
"/api/market/v1/backtest-stock",
"/api/market/v1/list-stored-stock-backtests",
])("getRequiredTier returns 1 for %s (regression-lock against tier-2 revert)", (path) => {
expect(getRequiredTier(path)).toBe(1);
});
test("getRequiredTier returns null for ungated endpoint", () => {
expect(getRequiredTier("/api/seismology/v1/list-earthquakes")).toBeNull();
});
test("checkEntitlement returns null for ungated endpoint", async () => {
const result = await checkEntitlement(null, "/api/seismology/v1/list-earthquakes", {});
expect(result).toBeNull();
});
test("checkEntitlement returns 403 when no resolved userId is provided (fail-closed)", async () => {
const result = await checkEntitlement(null, "/api/market/v1/analyze-stock", {});
expect(result).not.toBeNull();
expect(result!.status).toBe(403);
const body = await result!.json();
expect(body.error).toBe("Authentication required");
expect(body.requiredTier).toBe(1);
});
test("checkEntitlement returns 403 when Convex CONFIRMS no entitlement row (fail-closed)", async () => {
// This test used to rely on "no Convex URL" to produce its null, which
// conflated the two states a null now distinguishes: a lookup that was
// never attempted vs one that came back empty. Drive the confirmed case
// explicitly β€” backend configured, Convex answering 200 with a null body β€”
// so the terminal 403 is asserted against a real verdict about the account.
await withConvexEntitlementFetch(
() => Promise.resolve(new Response("null", {
status: 200,
headers: { "Content-Type": "application/json" },
})),
async () => {
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).not.toBeNull();
expect(result!.status).toBe(403);
const body = await result!.json();
expect(body.error).toBe("Unable to verify entitlements");
expect(body.requiredTier).toBe(1);
},
);
});
test("checkEntitlement answers the retryable 503 when the backend is UNCONFIGURED", async () => {
// The other half of the split above. With CONVEX_SITE_URL / the shared
// secret missing, getEntitlements returns null before attempting a lookup β€”
// for every user, paying customers included. Rendering that as the terminal
// "unable to verify" 403 tells subscribers their access failed because of
// our own deploy defect. This gate is reached from server/gateway.ts on
// every tier-gated session request, so it is the widest surface of the
// asymmetry #5619 set out to remove (#5600 is the precedent).
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
delete process.env.CONVEX_SITE_URL;
delete process.env.CONVEX_SERVER_SHARED_SECRET;
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
try {
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).not.toBeNull();
expect(result!.status).toBe(503);
expect(result!.headers.get("X-Billing-Verification")).toBe(
"entitlement_verification_unavailable",
);
expect(Number(result!.headers.get("Retry-After"))).toBeGreaterThan(0);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
}
});
test("transient Convex fetch failure returns a verificationUnavailable marker, not null", async () => {
await withConvexEntitlementFetch(
() => Promise.reject(Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" })),
async () => {
const ent = await getEntitlements("user-transient-timeout");
expect(ent).not.toBeNull();
expect(ent?.verificationUnavailable).toBe(true);
// Deny-side: the marker must never carry an affirmative grant.
expect(ent?.features.tier).toBe(0);
expect(ent?.features.apiAccess).toBe(false);
expect(ent?.validUntil).toBe(0);
},
);
});
test("neither a Convex 5xx nor a 4xx can be mistaken for a confirmed answer", async () => {
await withConvexEntitlementFetch(
() => Promise.resolve(new Response("upstream error", { status: 503 })),
async () => {
const ent = await getEntitlements("user-transient-5xx");
expect(ent?.verificationUnavailable).toBe(true);
},
);
await withConvexEntitlementFetch(
() => Promise.resolve(new Response("forbidden", { status: 403 })),
async () => {
// #5619: a 4xx (bad shared secret / contract rejection) IS a deploy
// defect rather than a blip, and #5661 kept it a fail-closed null on
// that reasoning. But "not transient" and "is a verdict about this
// user's plan" are different axes: the lookup did not happen, so
// rendering it as `pro_required` sells a subscription to a paying
// customer β€” the #5600 failure mode. The marker denies just as hard
// (tier 0, nothing granted) and only changes the wording to the
// retryable contract, which is already what server/gateway.ts answers
// for this exact state on wm_-key traffic. A client that keeps
// retrying spends its transient budget and lands on `give_up` β€” still
// terminal, still not an upsell.
const ent = await getEntitlements("user-config-4xx");
expect(ent?.verificationUnavailable).toBe(true);
expect(ent?.features.tier).toBe(0);
expect(ent?.features.apiAccess).toBe(false);
expect(ent?.validUntil).toBe(0);
},
);
});
test("an unconfigured backend still returns null β€” the gateway's fail-open exception depends on it", async () => {
// The one null that survives #5619. server/gateway.ts distinguishes it with
// isEntitlementBackendConfigured() and serves wm_-key traffic fail-open,
// because 503ing a missing env var turns a config regression into a
// fleet-wide API outage. Returning a marker here would silently delete that
// exception (the gateway would answer the billing 503 first).
const site = process.env.CONVEX_SITE_URL;
const secret = process.env.CONVEX_SERVER_SHARED_SECRET;
delete process.env.CONVEX_SITE_URL;
delete process.env.CONVEX_SERVER_SHARED_SECRET;
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
try {
expect(await getEntitlements("user-unconfigured")).toBeNull();
// Not merely null β€” null WITHOUT attempting a lookup, which is what
// separates this state from the 4xx above.
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
if (site !== undefined) process.env.CONVEX_SITE_URL = site;
if (secret !== undefined) process.env.CONVEX_SERVER_SHARED_SECRET = secret;
}
});
test("a 429 carries its own Retry-After instead of the generic default", async () => {
// Every other unanswered lookup gets the generic 5s. A 429 is the one that
// tells us how long the upstream wants to be left alone; re-advertising 5s
// would send clients back inside that window and amplify the throttling.
await withConvexEntitlementFetch(
() => Promise.resolve(new Response("slow down", {
status: 429,
headers: { "Retry-After": "60" },
})),
async () => {
const ent = await getEntitlements("user-429");
expect(ent?.verificationUnavailable).toBe(true);
expect(ent?.retryAfterSeconds).toBe(60);
// Still denies exactly as hard.
expect(ent?.features.tier).toBe(0);
const denial = classifyBillingVerification(ent);
expect(denial?.retryAfterSeconds).toBe(60);
// And it must survive the negative-cache hit. The cache re-synthesizes
// the marker rather than storing it, so without carrying the cooldown
// the first response honors the upstream and every hit inside the
// window quietly downgrades to the generic default.
const cached = await getEntitlements("user-429");
expect(cached?.verificationUnavailable).toBe(true);
expect(cached?.retryAfterSeconds).toBe(60);
},
);
});
test("a non-429 unanswered lookup keeps the generic Retry-After", async () => {
await withConvexEntitlementFetch(
() => Promise.resolve(new Response("boom", {
status: 503,
headers: { "Retry-After": "60" },
})),
async () => {
const ent = await getEntitlements("user-503-retryafter");
expect(ent?.verificationUnavailable).toBe(true);
expect(ent?.retryAfterSeconds).toBeUndefined();
},
);
});
test("checkEntitlement answers a transient lookup failure with the retryable 503 contract, not a hard 403", async () => {
await withConvexEntitlementFetch(
() => Promise.reject(new Error("fetch failed")),
async () => {
const result = await checkEntitlement("user-transient-check", "/api/market/v1/analyze-stock", {});
expect(result).not.toBeNull();
expect(result!.status).toBe(503);
expect(result!.headers.get("X-Billing-Verification")).toBe("entitlement_verification_unavailable");
expect(result!.headers.get("Retry-After")).toBe("5");
expect(result!.headers.get("Cache-Control")).toBe("no-store");
const body = await result!.json();
expect(body.error).toBe("Unable to verify API access");
expect(body.code).toBe("entitlement_verification_unavailable");
expect(body.requiredTier).toBe(1);
},
);
});
test.each([
["renewal_verification_pending", "Renewal verification pending"],
["renewal_verification_failed", "Renewal verification failed"],
] as const)("%s returns a distinct retryable 503", async (billingStatus, error) => {
const result = await withConvexEntitlementResponse(
{
...makeEntitlements(0),
validUntil: 0,
billingStatus,
retryAfterSeconds: 17,
},
() => checkEntitlement("test-user", "/api/market/v1/analyze-stock", {}),
);
expect(result?.status).toBe(503);
expect(result?.headers.get("Retry-After")).toBe("17");
expect(result?.headers.get("X-Billing-Verification")).toBe(billingStatus);
expect(await result?.json()).toMatchObject({ error, code: billingStatus });
});
test.each([
"renewal_verification_pending",
"renewal_verification_failed",
] as const)(
"current Pro fallback authorizes tier-1 REST while stronger verification is %s",
async (billingStatus) => {
const result = await withConvexEntitlementResponse(
{
...makeEntitlements(1, "pro_monthly"),
billingStatus,
retryAfterSeconds: 17,
},
() => checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
),
);
expect(result).toBeNull();
},
);
test("subscription_lapsed returns a distinct hard-denial code", async () => {
const result = await withConvexEntitlementResponse(
{
...makeEntitlements(0),
validUntil: 0,
billingStatus: "subscription_lapsed",
},
() => checkEntitlement("test-user", "/api/market/v1/analyze-stock", {}),
);
expect(result?.status).toBe(403);
expect(result?.headers.get("X-Billing-Verification")).toBe("subscription_lapsed");
expect(await result?.json()).toMatchObject({
error: "Subscription lapsed",
code: "subscription_lapsed",
});
});
test("serves a short-lived verification marker from Redis without another Convex request", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce({
...makeEntitlements(0),
validUntil: 0,
billingStatus: "renewal_verification_pending",
retryAfterSeconds: 11,
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
);
expect(result?.status).toBe(503);
expect(result?.headers.get("Retry-After")).toBe("11");
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
test("serves a recent not-applicable freshness marker without another Convex request", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce({
...makeEntitlements(0),
validUntil: 0,
renewalVerificationFreshness: {
status: "not_applicable",
checkedAt: Date.now(),
},
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
);
expect(result?.status).toBe(403);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
test("a not-applicable freshness marker past the bounded window falls through to Convex", async () => {
// #5600: a fresh Pro checkout races the Dodo webhook. The pre-purchase
// no-history answer must stop being served-sticky within a minute so the
// new subscriber's next request re-reads Convex instead of eating a
// 15-minute wrongful 403 on every tier-gated endpoint.
vi.mocked(getCachedJson).mockResolvedValueOnce({
...makeEntitlements(0),
validUntil: 0,
renewalVerificationFreshness: {
status: "not_applicable",
checkedAt: Date.now() - 61_000,
},
});
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(makeEntitlements(1, "pro_monthly")), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
);
expect(result).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
});
test("an expired not-applicable freshness marker falls through to Convex", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce({
...makeEntitlements(0),
validUntil: 0,
renewalVerificationFreshness: {
status: "not_applicable",
checkedAt: Date.now() - 900_001,
},
});
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(makeEntitlements(1, "pro_monthly")), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
);
expect(result).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
});
test("caches a not-applicable freshness marker for at most 60 seconds", async () => {
const marker = {
...makeEntitlements(0),
validUntil: 0,
renewalVerificationFreshness: {
status: "not_applicable",
checkedAt: Date.now(),
},
};
await withConvexEntitlementResponse(marker, async () => {
await getEntitlements("test-user-marker-ttl");
});
// Pin the exact value, not a window. `checkedAt` is Date.now() immediately
// before the write and setCachedJson is mocked, so the computed
// Math.ceil((checkedAt + 60_000 - now) / 1000) is deterministically 60 β€” a
// range of (30, 60] let a drift to any value in [31, 59] pass both this and
// the api/_user-api-key.test.mjs mirror undetected, and cross-mirror parity
// would not catch a symmetric drift either.
const ttl = vi.mocked(setCachedJson).mock.calls.at(-1)?.[2];
expect(ttl).toBe(60);
});
test("checkEntitlement accepts Clerk role=pro for tier-1 gates without Convex entitlements", async () => {
const result = await checkEntitlement(
"test-user",
"/api/market/v1/analyze-stock",
{},
{ clerkRole: "pro" },
);
expect(result).toBeNull();
});
test("checkEntitlement returns 403 for insufficient tier", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce(makeEntitlements(0));
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).not.toBeNull();
expect(result!.status).toBe(403);
const body = await result!.json();
expect(body.error).toBe("Upgrade required");
expect(body.requiredTier).toBe(1);
expect(body.currentTier).toBe(0);
});
test("checkEntitlement returns null for Pro tier (tier=1) on stock analysis", async () => {
// Regression: previous tier=2 requirement 403'd real Pro subscribers
// calling via Clerk session (no tester key in localStorage). Stock
// analysis is marketed as a Pro feature and must accept tier >= 1.
vi.mocked(getCachedJson).mockResolvedValueOnce(makeEntitlements(1, "pro_monthly"));
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).toBeNull();
});
test("checkEntitlement returns null for sufficient tier", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce(makeEntitlements(2, "api_starter"));
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).toBeNull();
});
test("checkEntitlement ignores spoofable request headers and uses explicit userId contract", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce(makeEntitlements(1, "pro_monthly"));
const result = await checkEntitlement("trusted-user", "/api/market/v1/analyze-stock", {});
expect(result).toBeNull();
expect(getCachedJson).toHaveBeenLastCalledWith("entitlements:test:trusted-user", true);
});
test("reviewer round-2 P2-cache: legacy cache entry without mcpAccess is treated as stale and falls through to Convex", async () => {
// Seed Redis with a pre-U10 cached entitlement: tier-1 Pro, but the
// stored features object is the OLD shape WITHOUT mcpAccess. The cache
// predicate must detect this and fall through to Convex (which does
// the read-time catalog merge), rather than returning a row that
// would block the user at the grant/MCP gates with mcpAccess !== true.
const legacyCache = {
planKey: "pro_monthly",
features: {
tier: 1,
apiAccess: false,
apiRateLimit: 0,
maxDashboards: 10,
prioritySupport: false,
exportFormats: ["csv"],
// NO mcpAccess field β€” pre-U10 cache entry
},
validUntil: FUTURE,
};
vi.mocked(getCachedJson).mockResolvedValueOnce(legacyCache);
// Mock Convex fallback to return the post-U10 merged shape.
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(makeEntitlements(1, "pro_monthly")), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
// Expect: cache rejected as stale β†’ Convex round-trip β†’ tier-1 row
// with mcpAccess: true β†’ checkEntitlement passes (returns null).
expect(result).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
process.env.CONVEX_SITE_URL = originalSiteUrl;
process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
});
test("reviewer round-2 P2-cache: cache entry WITH mcpAccess is honored without Convex round-trip", async () => {
// Sanity check the inverse: a post-U10 cache entry should be returned
// directly without falling through to Convex.
vi.mocked(getCachedJson).mockResolvedValueOnce(makeEntitlements(1, "pro_monthly"));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(0); // cache hit, no Convex call
} finally {
vi.unstubAllGlobals();
}
});
test("getEntitlements uses CONVEX_SITE_URL for HTTP fallback", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(makeEntitlements(2, "api_starter")), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.stubGlobal("fetch", fetchMock);
try {
const result = await checkEntitlement("test-user", "/api/market/v1/analyze-stock", {});
expect(result).toBeNull();
expect(fetchMock).toHaveBeenCalledWith(
"https://example-deployment.convex.site/api/internal-entitlements",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"x-convex-shared-secret": "test-secret",
}),
}),
);
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.signal).toBeInstanceOf(AbortSignal);
} finally {
if (originalSiteUrl === undefined) {
delete process.env.CONVEX_SITE_URL;
} else {
process.env.CONVEX_SITE_URL = originalSiteUrl;
}
if (originalSecret === undefined) {
delete process.env.CONVEX_SERVER_SHARED_SECRET;
} else {
process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
}
vi.unstubAllGlobals();
}
});
test("confirmed Convex entitlement survives a Redis cache-write failure", async () => {
vi.mocked(getCachedJson).mockResolvedValueOnce(null);
vi.mocked(setCachedJson).mockRejectedValueOnce(new Error("upstash unavailable"));
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
const confirmed = makeEntitlements(2, "api_starter");
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(confirmed), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.stubGlobal("fetch", fetchMock);
try {
const result = await getEntitlements("user_cache_write_failure");
expect(result).toEqual(confirmed);
expect(setCachedJson).toHaveBeenCalledWith(
"entitlements:test:user_cache_write_failure",
confirmed,
900,
true,
);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
}
});
});
// ---------------------------------------------------------------------------
// #5622 β€” classifyBillingVerification: the decision, as an executable table
// ---------------------------------------------------------------------------
/**
* Three consumers cannot take a `Response` (an HTML page, the OAuth-grant
* handshake's own error vocabulary, and a boolean premium check), so the
* retryable-vs-terminal decision was extracted here. Pin it as a truth table
* rather than through any one renderer: a source-level guard on the renderers
* would stay green with the decision itself inverted.
*/
describe("classifyBillingVerification (#5622)", () => {
test("no billing metadata is not a denial", () => {
expect(classifyBillingVerification(null)).toBeNull();
expect(classifyBillingVerification(undefined)).toBeNull();
expect(classifyBillingVerification({})).toBeNull();
});
test("an unrecognised billingStatus string is not a denial (fail-open on vocabulary drift)", () => {
expect(
classifyBillingVerification({
billingStatus: "something_new" as never,
}),
).toBeNull();
});
test("a transient lookup failure is retryable with the advertised 5s default", () => {
expect(classifyBillingVerification({ verificationUnavailable: true })).toEqual({
retryable: true,
code: "entitlement_verification_unavailable",
retryAfterSeconds: 5,
message: "Unable to verify API access",
status: 503,
});
});
test("verificationUnavailable outranks a stale billingStatus on the same row", () => {
// The marker is synthesized over whatever shape the failed lookup had; a
// lapsed status carried alongside it is unverified, so it must not turn a
// transient failure into a terminal 403.
const denial = classifyBillingVerification({
verificationUnavailable: true,
billingStatus: "subscription_lapsed",
});
expect(denial?.retryable).toBe(true);
expect(denial?.code).toBe("entitlement_verification_unavailable");
});
test("a provider-confirmed lapse is the only terminal member", () => {
expect(classifyBillingVerification({ billingStatus: "subscription_lapsed" })).toEqual({
retryable: false,
code: "subscription_lapsed",
retryAfterSeconds: 0,
message: "Subscription lapsed",
status: 403,
});
});
test.each([
["renewal_verification_pending", "Renewal verification pending"],
["renewal_verification_failed", "Renewal verification failed"],
] as const)("%s is retryable and carries the provider's own delay", (billingStatus, message) => {
expect(classifyBillingVerification({ billingStatus, retryAfterSeconds: 17 })).toEqual({
retryable: true,
code: billingStatus,
retryAfterSeconds: 17,
message,
status: 503,
});
});
test("retryAfterSeconds is clamped into 1-60 whatever the provider sent", () => {
const delay = (raw: unknown) =>
classifyBillingVerification({
billingStatus: "renewal_verification_pending",
retryAfterSeconds: raw as number,
})?.retryAfterSeconds;
expect(delay(0)).toBe(1);
expect(delay(-5)).toBe(1);
expect(delay(0.2)).toBe(1);
// 2.1 -> 3 is the case that actually distinguishes ceil from floor; the 0.2
// case above is masked by the outer Math.max(1, ...) clamp.
expect(delay(2.1)).toBe(3);
expect(delay(600)).toBe(60);
expect(delay(Number.NaN)).toBe(5);
expect(delay(undefined)).toBe(5);
expect(delay("11")).toBe(5);
});
test("every retryable member advertises a delay and the terminal one does not", () => {
for (const input of [
{ verificationUnavailable: true as const },
{ billingStatus: "renewal_verification_pending" as const },
{ billingStatus: "renewal_verification_failed" as const },
]) {
const denial = classifyBillingVerification(input);
expect(denial?.retryable).toBe(true);
expect(denial?.status).toBe(503);
expect(denial!.retryAfterSeconds).toBeGreaterThan(0);
}
const lapsed = classifyBillingVerification({ billingStatus: "subscription_lapsed" });
expect(lapsed?.retryable).toBe(false);
expect(lapsed?.status).toBe(403);
expect(lapsed?.retryAfterSeconds).toBe(0);
});
});
describe("getBillingVerificationDenial renders the classification (#5622)", () => {
test("a terminal denial carries no Retry-After β€” a lapse must not invite a retry loop", async () => {
const res = getBillingVerificationDenial({ billingStatus: "subscription_lapsed" }, {}, 1);
expect(res?.status).toBe(403);
expect(res?.headers.get("Retry-After")).toBeNull();
expect(res?.headers.get("X-Billing-Verification")).toBe("subscription_lapsed");
expect(await res?.json()).toEqual({
error: "Subscription lapsed",
code: "subscription_lapsed",
requiredTier: 1,
});
});
test("requiredTier is omitted, not null, when the caller does not supply one", async () => {
const res = getBillingVerificationDenial({ verificationUnavailable: true }, {});
expect(await res?.json()).toEqual({
error: "Unable to verify API access",
code: "entitlement_verification_unavailable",
});
});
test("cors headers are merged and cannot clobber the verification header", () => {
const res = getBillingVerificationDenial({ verificationUnavailable: true }, {
"Access-Control-Allow-Origin": "https://worldmonitor.app",
});
expect(res?.headers.get("Access-Control-Allow-Origin")).toBe("https://worldmonitor.app");
expect(res?.headers.get("X-Billing-Verification")).toBe("entitlement_verification_unavailable");
expect(res?.headers.get("Cache-Control")).toBe("no-store");
});
test("returns null when there is nothing to deny", () => {
expect(getBillingVerificationDenial(null, {})).toBeNull();
expect(getBillingVerificationDenial({}, {})).toBeNull();
});
test("the contract headers win over a corsHeaders map that collides with them", () => {
// The refactor changed this precedence (the pre-#5622 version let corsHeaders
// clobber X-Billing-Verification but not Retry-After β€” inconsistent). No cors
// helper in the repo emits either name, so it is inert today; pinned so it
// stays that way rather than being rediscovered from a wrong header in prod.
const res = getBillingVerificationDenial({ verificationUnavailable: true }, {
"Access-Control-Allow-Origin": "https://worldmonitor.app",
"X-Billing-Verification": "spoofed",
"Retry-After": "999",
"Cache-Control": "public, max-age=600",
});
expect(res?.headers.get("X-Billing-Verification")).toBe("entitlement_verification_unavailable");
expect(res?.headers.get("Retry-After")).toBe("5");
// no-store is load-bearing: a cached denial is a wrongful denial for everyone
// behind the same CDN entry.
expect(res?.headers.get("Cache-Control")).toBe("no-store");
expect(res?.headers.get("Access-Control-Allow-Origin")).toBe("https://worldmonitor.app");
});
});
// ---------------------------------------------------------------------------
// #5622 β€” transient failures are negative-cached so an outage is not amplified
// ---------------------------------------------------------------------------
describe("transient-failure negative cache (#5622)", () => {
test("a repeat lookup inside the window reuses the transient answer without another backend call", async () => {
__resetEntitlementNegativeCacheForTests();
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValue(null);
const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
try {
const first = await getEntitlements("user-negcache-hit");
const second = await getEntitlements("user-negcache-hit");
expect(first?.verificationUnavailable).toBe(true);
// Same deny-side answer, so every gate still emits the retryable 503.
expect(second?.verificationUnavailable).toBe(true);
expect(second?.features.tier).toBe(0);
expect(second?.validUntil).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
vi.mocked(getCachedJson).mockResolvedValue(null);
__resetEntitlementNegativeCacheForTests();
}
});
test("the cached failure expires, so recovery is not held back past the window", async () => {
__resetEntitlementNegativeCacheForTests();
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValue(null);
const recovered = makeEntitlements(1, "pro_monthly");
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("fetch failed"))
.mockResolvedValue(
new Response(JSON.stringify(recovered), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
// Advance past the window rather than sleeping for it β€” the TTL is real
// seconds and this assertion is about the boundary, not the wall clock.
const realNow = Date.now;
try {
expect((await getEntitlements("user-negcache-expiry"))?.verificationUnavailable).toBe(true);
Date.now = () => realNow() + __negativeCacheTtlMsForTests + 1;
const after = await getEntitlements("user-negcache-expiry");
expect(after?.verificationUnavailable).toBeUndefined();
expect(after?.features.tier).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
Date.now = realNow;
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
vi.mocked(getCachedJson).mockResolvedValue(null);
__resetEntitlementNegativeCacheForTests();
}
});
test("the window stays strictly inside the Retry-After the same state advertises", () => {
// Load-bearing inequality, not a style preference: if the negative cache
// outlived the advertised delay, a client that correctly honors
// `Retry-After: 5` would retry straight back into the cached failure and the
// outage would outlive the outage.
const advertised = classifyBillingVerification({ verificationUnavailable: true });
expect(__negativeCacheTtlMsForTests).toBeLessThan(advertised!.retryAfterSeconds * 1_000);
});
test("a confirmed row is never negative-cached", async () => {
__resetEntitlementNegativeCacheForTests();
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValue(null);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(makeEntitlements(1, "pro_monthly")), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
try {
await getEntitlements("user-negcache-confirmed");
await getEntitlements("user-negcache-confirmed");
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
vi.mocked(getCachedJson).mockResolvedValue(null);
__resetEntitlementNegativeCacheForTests();
}
});
test("stays bounded under a fleet-wide outage, and eviction does not break the answer", async () => {
// The cap's whole purpose is the fleet-wide-outage case: one entry per active
// user for the life of the isolate. That branch only fires above the cap, so
// it is unreachable from any test that does not actually cross it.
__resetEntitlementNegativeCacheForTests();
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValue(null);
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed")));
try {
const overflow = __negativeCacheMaxEntriesForTests + 200;
for (let i = 0; i < overflow; i++) {
const ent = await getEntitlements(`user-negcache-flood-${i}`);
// Every user still gets the correct deny-side answer while evicting.
expect(ent?.verificationUnavailable).toBe(true);
}
expect(__negativeCacheSizeForTests()).toBeLessThanOrEqual(
__negativeCacheMaxEntriesForTests,
);
// Eviction drops the OLDEST insertions, so the most recent user is still
// cached β€” an eviction policy that dropped the newest would make the cache
// useless precisely when it is needed.
const lastUser = `user-negcache-flood-${overflow - 1}`;
const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
expect((await getEntitlements(lastUser))?.verificationUnavailable).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
vi.mocked(getCachedJson).mockResolvedValue(null);
__resetEntitlementNegativeCacheForTests();
}
});
test.each([
"renewal_verification_pending",
"renewal_verification_failed",
] as const)("a CONFIRMED %s row is never negative-cached", async (billingStatus) => {
// Scope pin for the TTL/Retry-After inequality above. That invariant is
// verified against the synthesized marker's fixed 5s delay. The renewal codes
// carry a PROVIDER-supplied delay that can be as low as 1s β€” below the 3s
// window β€” so if a future change ever negative-cached them, the inequality
// would silently invert and an honoring client would retry into a cached
// failure. These rows have their own (Redis) marker TTL and must stay out.
__resetEntitlementNegativeCacheForTests();
const row = {
...makeEntitlements(0),
validUntil: 0,
billingStatus,
retryAfterSeconds: 1,
};
await withConvexEntitlementResponse(row, async () => {
const ent = await getEntitlements(`user-negcache-${billingStatus}`);
expect(ent?.billingStatus).toBe(billingStatus);
});
expect(__negativeCacheSizeForTests()).toBe(0);
__resetEntitlementNegativeCacheForTests();
});
test("a 4xx is negative-cached like any other unanswered lookup β€” and still never upsells", async () => {
__resetEntitlementNegativeCacheForTests();
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
process.env.CONVEX_SITE_URL = "https://example-deployment.convex.site";
process.env.CONVEX_SERVER_SHARED_SECRET = "test-secret";
vi.mocked(getCachedJson).mockResolvedValue(null);
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("forbidden", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
try {
// #5619: the 4xx now answers with the marker, so the same amplification
// bound applies to it β€” one lookup per user per 3s window instead of one
// per request while a bad shared secret is live. Recovery after the fix
// deploys is bounded by that same window.
expect((await getEntitlements("user-negcache-4xx"))?.verificationUnavailable).toBe(true);
const cached = await getEntitlements("user-negcache-4xx");
expect(cached?.verificationUnavailable).toBe(true);
expect(cached?.features.tier).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(__negativeCacheSizeForTests()).toBe(1);
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
vi.unstubAllGlobals();
vi.mocked(getCachedJson).mockResolvedValue(null);
__resetEntitlementNegativeCacheForTests();
}
});
});
// ---------------------------------------------------------------------------
// #3199 U2 β€” apiDailyAllowance threads through to the resolved entitlement
// ---------------------------------------------------------------------------
describe("getEntitlements surfaces apiDailyAllowance (#3199 U2)", () => {
test("a fresh Starter cache row exposes apiDailyAllowance", async () => {
const fresh = makeEntitlements(2, "api_starter");
vi.mocked(getCachedJson).mockResolvedValueOnce({
...fresh,
features: { ...fresh.features, apiDailyAllowance: 1000 },
} as never);
const result = await getEntitlements("user_starter");
expect(result?.features.apiDailyAllowance).toBe(1000);
});
test("a legacy cache row lacking apiDailyAllowance resolves to undefined (fail-open), no throw", async () => {
// makeEntitlements sets mcpAccess (boolean) so the row passes the
// staleness gate, but does NOT set apiDailyAllowance β€” the field is
// intentionally absent from the staleness gate so legacy rows are served
// from cache and the rate-limit consumer fail-opens on undefined.
vi.mocked(getCachedJson).mockResolvedValueOnce(
makeEntitlements(2, "api_starter") as never,
);
const result = await getEntitlements("user_legacy");
expect(result).not.toBeNull();
expect(result?.features.apiDailyAllowance).toBeUndefined();
});
// #5379 β€” the misconfiguration edge of the null contract.
test("MISCONFIG: absent Convex env returns null for everyone, permanently (not a transient blip)", async () => {
// A deploy missing CONVEX_SITE_URL / CONVEX_SERVER_SHARED_SECRET takes the
// early return at entitlement-check.ts, so every user resolves to null on
// every request with no self-healing path β€” the 15-min cache cannot warm
// what never resolves.
//
// Why this test lives here and matters elsewhere: the #4611 apiAccess gate
// in server/gateway.ts deliberately fail-OPENS on null. Its "warm path
// re-resolves" bound assumes the entitlement EVENTUALLY resolves, which this
// case violates β€” so a missing env var silently disables that gate fleet-wide
// and indefinitely. Pinning it here keeps the premise of that bound honest.
// Save/restore in finally, matching this file's convention above β€” without
// it the deleted env leaks into any test appended after this one.
const originalSiteUrl = process.env.CONVEX_SITE_URL;
const originalSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
delete process.env.CONVEX_SITE_URL;
delete process.env.CONVEX_SERVER_SHARED_SECRET;
vi.mocked(getCachedJson).mockResolvedValue(null); // cold cache, every call
try {
// Distinct userIds: repeated nulls, not one coalesced in-flight promise.
expect(await getEntitlements("user_misconfig_a")).toBeNull();
expect(await getEntitlements("user_misconfig_b")).toBeNull();
// Same user twice β€” still null, i.e. no recovery on retry.
expect(await getEntitlements("user_misconfig_a")).toBeNull();
} finally {
if (originalSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
else process.env.CONVEX_SITE_URL = originalSiteUrl;
if (originalSecret === undefined) delete process.env.CONVEX_SERVER_SHARED_SECRET;
else process.env.CONVEX_SERVER_SHARED_SECRET = originalSecret;
}
});
});