import { convexTest } from "convex-test"; import { expect, test, describe, beforeEach, afterEach, vi } from "vitest"; import schema from "../schema"; import { api, internal } from "../_generated/api"; import { getFeaturesForPlan } from "../lib/entitlements"; const modules = import.meta.glob("../**/*.ts"); // --------------------------------------------------------------------------- // Helpers (mirrors convex/__tests__/apiKeys.test.ts) // --------------------------------------------------------------------------- const NOW = Date.now(); const FUTURE = NOW + 86400000 * 30; // 30 days const PAST = NOW - 86400000; // 1 day ago const API_USER = { subject: "user-api", tokenIdentifier: "clerk|user-api" }; const PRO_USER = { subject: "user-pro", tokenIdentifier: "clerk|user-pro" }; const FREE_USER = { subject: "user-free", tokenIdentifier: "clerk|user-free" }; const OTHER_USER = { subject: "user-other", tokenIdentifier: "clerk|user-other" }; const SHARED_SECRET = "test-shared-secret"; async function seedProEntitlement( t: ReturnType, userId: string, opts: { validUntil?: number } = {}, ) { await t.run(async (ctx) => { await ctx.db.insert("entitlements", { userId, planKey: "pro_monthly", features: getFeaturesForPlan("pro_monthly"), validUntil: opts.validUntil ?? FUTURE, updatedAt: NOW, }); }); } async function seedApiEntitlement( t: ReturnType, userId: string, opts: { validUntil?: number } = {}, ) { await t.run(async (ctx) => { await ctx.db.insert("entitlements", { userId, planKey: "api_starter", features: getFeaturesForPlan("api_starter"), validUntil: opts.validUntil ?? FUTURE, updatedAt: NOW, }); }); } // --------------------------------------------------------------------------- // issueProMcpToken (internal) // --------------------------------------------------------------------------- describe("issueProMcpToken", () => { test("succeeds for tier-1 (Pro) user", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const result = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", clientId: "claude-desktop", name: "Connected via Claude Desktop", }); expect(result.tokenId).toBeTruthy(); // Verify row exists with revokedAt unset const row = await t.run(async (ctx) => ctx.db.get(result.tokenId)); expect(row).toBeTruthy(); expect(row?.userId).toBe("user-pro"); expect(row?.clientId).toBe("claude-desktop"); expect(row?.name).toBe("Connected via Claude Desktop"); expect(row?.revokedAt).toBeUndefined(); expect(row?.createdAt).toBeGreaterThan(0); }); test("succeeds for tier-2 (API) user — Pro is the floor not exclusive", async () => { const t = convexTest(schema, modules); await seedApiEntitlement(t, "user-api"); const result = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-api", }); expect(result.tokenId).toBeTruthy(); }); test("rejects tier-0 (free) user with PRO_REQUIRED", async () => { const t = convexTest(schema, modules); await expect( t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-free" }), ).rejects.toThrow(/PRO_REQUIRED/); }); test("rejects tier-1 user whose entitlement has lapsed", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro", { validUntil: PAST }); await expect( t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro" }), ).rejects.toThrow(/PRO_REQUIRED/); }); test("F5 convergence: 6 actives (race-leftover) → next issue trims to MAX", async () => { // Models the post-race state the F5 fix converges from: a brief // racing window left 6 active rows; the next issue call must trim // back to MAX (5) — that's the "eventually 5" guarantee. const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); // Manually seed 6 active rows (simulates the race outcome). await t.run(async (ctx) => { const now = Date.now(); for (let i = 0; i < 6; i++) { await ctx.db.insert("mcpProTokens", { userId: "user-pro", createdAt: now - (6 - i) * 1000, // oldest first name: `racing-slot-${i + 1}`, }); } }); // Next issue must converge: revoke 2 oldest, insert 1 new → 5 active. const seventh = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", name: "post-race", }); expect(seventh.tokenId).toBeTruthy(); const rows = await t .withIdentity(PRO_USER) .query(api.mcpProTokens.listProMcpTokens, {}); const active = rows.filter((r: any) => !r.revokedAt); expect(active).toHaveLength(5); }); test("6th issue rotates oldest — caps active rows at 5", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const ids: string[] = []; for (let i = 1; i <= 5; i++) { const r = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", name: `slot-${i}`, }); ids.push(r.tokenId); } // Sixth issue should rotate the oldest (slot-1) and return a new id const sixth = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", name: "slot-6", }); expect(sixth.tokenId).toBeTruthy(); expect(ids).not.toContain(sixth.tokenId); // The oldest row must be revoked, NOT deleted (audit trail) const oldestRow = await t.run(async (ctx) => ctx.db.get(ids[0] as any)); expect(oldestRow).toBeTruthy(); expect(oldestRow?.revokedAt).toBeGreaterThan(0); // Active count is exactly 5 const allRows = await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {}); const active = allRows.filter((r: any) => !r.revokedAt); expect(active).toHaveLength(5); }); }); // --------------------------------------------------------------------------- // validateProMcpToken (internal) // --------------------------------------------------------------------------- describe("validateProMcpToken", () => { test("returns {userId} for active row", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const result = await t.query(internal.mcpProTokens.validateProMcpToken, { tokenId: issued.tokenId, }); expect(result).toEqual({ userId: "user-pro" }); }); test("returns null for revoked row", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.withIdentity(PRO_USER).mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId, }); const result = await t.query(internal.mcpProTokens.validateProMcpToken, { tokenId: issued.tokenId, }); expect(result).toBeNull(); }); test("returns null for non-existent tokenId (real-shape id)", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); // Issue then delete to obtain a syntactically valid but non-existent id const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.run(async (ctx) => ctx.db.delete(issued.tokenId)); const result = await t.query(internal.mcpProTokens.validateProMcpToken, { tokenId: issued.tokenId, }); expect(result).toBeNull(); }); }); // --------------------------------------------------------------------------- // revokeProMcpToken (public) // --------------------------------------------------------------------------- describe("revokeProMcpToken", () => { test("owner revoke sets revokedAt and subsequent validate returns null", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const result = await t.withIdentity(PRO_USER).mutation( api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId }, ); expect(result).toEqual({ ok: true }); const validated = await t.query(internal.mcpProTokens.validateProMcpToken, { tokenId: issued.tokenId, }); expect(validated).toBeNull(); }); test("non-owner revoke throws NOT_FOUND (no leak)", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await expect( t.withIdentity(OTHER_USER).mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId, }), ).rejects.toThrow(/NOT_FOUND/); }); test("double revoke throws ALREADY_REVOKED", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const asPro = t.withIdentity(PRO_USER); await asPro.mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId, }); await expect( asPro.mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId }), ).rejects.toThrow(/ALREADY_REVOKED/); }); }); // --------------------------------------------------------------------------- // listProMcpTokens (public) // --------------------------------------------------------------------------- describe("listProMcpTokens", () => { test("returns empty array for user with zero rows", async () => { const t = convexTest(schema, modules); const rows = await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {}); expect(rows).toEqual([]); }); test("returns ALL rows (active + revoked) for transparency", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const a = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", name: "active-one", }); const b = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", name: "to-be-revoked", }); await t.withIdentity(PRO_USER).mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: b.tokenId, }); const rows = await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {}); expect(rows).toHaveLength(2); expect(rows.find((r: any) => r.id === a.tokenId)?.revokedAt).toBeUndefined(); expect(rows.find((r: any) => r.id === b.tokenId)?.revokedAt).toBeGreaterThan(0); }); test("does not return other users' rows", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro" }); const rows = await t.withIdentity(OTHER_USER).query(api.mcpProTokens.listProMcpTokens, {}); expect(rows).toEqual([]); }); test("returns empty array when called without auth identity (does not throw AUTH_REQUIRED)", async () => { // WORLDMONITOR-RD regression: this query is reactive (subscribed by // the settings UI), so it fires during transient unauth windows — // sign-out, initial page load before Clerk resolves, token-rotation // races. Throwing AUTH_REQUIRED from those races was paging via // Convex's server-side Sentry integration despite the // requireUserId() comment explicitly aiming to suppress N3-class // noise. Returning [] is observationally identical to "no tokens // yet" and removes the throw entirely. const t = convexTest(schema, modules); // Seed a token under a real user so we can prove the no-auth caller // gets empty (not the other user's row, not a throw). await seedProEntitlement(t, "user-pro"); await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro" }); // No .withIdentity(...) — simulates the unauth race. const rows = await t.query(api.mcpProTokens.listProMcpTokens, {}); expect(rows).toEqual([]); }); }); // --------------------------------------------------------------------------- // touchProMcpTokenLastUsed (internal) — debounce // --------------------------------------------------------------------------- describe("touchProMcpTokenLastUsed", () => { test("sets lastUsedAt on first call", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.mutation(internal.mcpProTokens.touchProMcpTokenLastUsed, { tokenId: issued.tokenId, }); const rows = await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {}); const row = rows.find((r: any) => r.id === issued.tokenId); expect(row?.lastUsedAt).toBeGreaterThan(0); }); test("debounces: second call within 5min does not bump lastUsedAt", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.mutation(internal.mcpProTokens.touchProMcpTokenLastUsed, { tokenId: issued.tokenId, }); const after1 = (await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {})) .find((r: any) => r.id === issued.tokenId)?.lastUsedAt; // Immediate second call should be debounced await t.mutation(internal.mcpProTokens.touchProMcpTokenLastUsed, { tokenId: issued.tokenId, }); const after2 = (await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {})) .find((r: any) => r.id === issued.tokenId)?.lastUsedAt; expect(after1).toBe(after2); }); test("no-op on revoked row (don't bump revoked tokens)", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.withIdentity(PRO_USER).mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId, }); // Should not throw and lastUsedAt must remain unset await t.mutation(internal.mcpProTokens.touchProMcpTokenLastUsed, { tokenId: issued.tokenId, }); const rows = await t.withIdentity(PRO_USER).query(api.mcpProTokens.listProMcpTokens, {}); const row = rows.find((r: any) => r.id === issued.tokenId); expect(row?.lastUsedAt).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // HTTP routes (service-to-service via x-convex-shared-secret) // --------------------------------------------------------------------------- describe("HTTP route /api/internal-issue-pro-mcp-token", () => { beforeEach(() => { process.env.CONVEX_SERVER_SHARED_SECRET = SHARED_SECRET; }); afterEach(() => { delete process.env.CONVEX_SERVER_SHARED_SECRET; }); test("rejects missing shared-secret with 401", async () => { const t = convexTest(schema, modules); const res = await t.fetch("/api/internal-issue-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: "user-pro" }), }); expect(res.status).toBe(401); }); test("rejects wrong shared-secret with 401", async () => { const t = convexTest(schema, modules); const res = await t.fetch("/api/internal-issue-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": "wrong", }, body: JSON.stringify({ userId: "user-pro" }), }); expect(res.status).toBe(401); }); test("happy path: tier-1 user → 200 with tokenId", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const res = await t.fetch("/api/internal-issue-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ userId: "user-pro", clientId: "cl", name: "n" }), }); expect(res.status).toBe(200); const body = (await res.json()) as { tokenId?: string }; expect(body.tokenId).toBeTruthy(); }); test("tier-0 → 403 PRO_REQUIRED", async () => { const t = convexTest(schema, modules); const res = await t.fetch("/api/internal-issue-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ userId: "user-free" }), }); expect(res.status).toBe(403); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("PRO_REQUIRED"); }); }); describe("HTTP route /api/internal-validate-pro-mcp-token", () => { beforeEach(() => { process.env.CONVEX_SERVER_SHARED_SECRET = SHARED_SECRET; }); afterEach(() => { delete process.env.CONVEX_SERVER_SHARED_SECRET; }); test("rejects missing shared-secret with 401", async () => { const t = convexTest(schema, modules); const res = await t.fetch("/api/internal-validate-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tokenId: "anything" }), }); expect(res.status).toBe(401); }); test("happy path: active token → {userId}", async () => { vi.useFakeTimers(); const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const res = await t.fetch("/api/internal-validate-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ tokenId: issued.tokenId }), }); expect(res.status).toBe(200); const body = (await res.json()) as { userId?: string } | null; expect(body).toEqual({ userId: "user-pro" }); // The validate HTTP route schedules a fire-and-forget touch via // ctx.scheduler.runAfter(0, ...). Drain it deterministically with // fake timers so the lastUsedAt write doesn't escape the transaction // window and surface as an unhandled rejection during teardown. await t.finishAllScheduledFunctions(vi.runAllTimers); vi.useRealTimers(); }); test("revoked token → null", async () => { vi.useFakeTimers(); const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); await t.withIdentity(PRO_USER).mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: issued.tokenId, }); const res = await t.fetch("/api/internal-validate-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ tokenId: issued.tokenId }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body).toBeNull(); // Defensive: revoked path returns null and skips scheduling, but // drain regardless to keep this test resilient to future edits. await t.finishAllScheduledFunctions(vi.runAllTimers); vi.useRealTimers(); }); }); describe("HTTP route /api/internal-revoke-pro-mcp-token", () => { beforeEach(() => { process.env.CONVEX_SERVER_SHARED_SECRET = SHARED_SECRET; }); afterEach(() => { delete process.env.CONVEX_SERVER_SHARED_SECRET; }); test("rejects missing shared-secret with 401", async () => { const t = convexTest(schema, modules); const res = await t.fetch("/api/internal-revoke-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: "user-pro", tokenId: "x" }), }); expect(res.status).toBe(401); }); test("owner-matched revoke → 200", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const res = await t.fetch("/api/internal-revoke-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ userId: "user-pro", tokenId: issued.tokenId }), }); expect(res.status).toBe(200); }); test("non-owner userId mismatch → 404 NOT_FOUND", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); const issued = await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro", }); const res = await t.fetch("/api/internal-revoke-pro-mcp-token", { method: "POST", headers: { "Content-Type": "application/json", "x-convex-shared-secret": SHARED_SECRET, }, body: JSON.stringify({ userId: "user-other", tokenId: issued.tokenId }), }); expect(res.status).toBe(404); }); }); // --------------------------------------------------------------------------- // Integration: userApiKeys table is untouched // --------------------------------------------------------------------------- describe("integration: userApiKeys table untouched", () => { test("issuing a Pro MCP token does not create a userApiKeys row", async () => { const t = convexTest(schema, modules); await seedProEntitlement(t, "user-pro"); await t.mutation(internal.mcpProTokens.issueProMcpToken, { userId: "user-pro" }); // Inspect the underlying table directly const apiKeys = await t.run(async (ctx) => ctx.db .query("userApiKeys") .withIndex("by_userId", (q) => q.eq("userId", "user-pro")) .collect(), ); expect(apiKeys).toEqual([]); }); });