| import { convexTest } from "convex-test"; |
| import { expect, test, describe } from "vitest"; |
| import schema from "../schema"; |
| import { api, internal } from "../_generated/api"; |
| import { getFeaturesForPlan } from "../lib/entitlements"; |
|
|
| const modules = import.meta.glob("../**/*.ts"); |
|
|
| |
| |
| |
|
|
| const NOW = Date.now(); |
| const FUTURE = NOW + 86400000 * 30; |
| const PAST = NOW - 86400000; |
|
|
| 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" }; |
|
|
| function makeKeyArgs(n: number) { |
| const hex = n.toString(16).padStart(5, "0"); |
| const hash = hex.repeat(13).slice(0, 64); |
| return { |
| name: `test-key-${n}`, |
| keyPrefix: `wm_${hex}`, |
| keyHash: hash, |
| }; |
| } |
|
|
| |
| async function seedApiEntitlement( |
| t: ReturnType<typeof convexTest>, |
| 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, |
| }); |
| }); |
| } |
|
|
| async function seedActiveApiKeys( |
| t: ReturnType<typeof convexTest>, |
| userId: string, |
| count: number, |
| ) { |
| await t.run(async (ctx) => { |
| const now = Date.now(); |
| for (let i = 1; i <= count; i++) { |
| const args = makeKeyArgs(i); |
| await ctx.db.insert("userApiKeys", { |
| userId, |
| name: args.name, |
| keyPrefix: args.keyPrefix, |
| keyHash: args.keyHash, |
| createdAt: now - (count - i) * 1000, |
| }); |
| } |
| }); |
| } |
|
|
| |
| async function seedProEntitlement( |
| t: ReturnType<typeof convexTest>, |
| 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, |
| }); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| describe("createApiKey", () => { |
| test("rejects free-tier users (API_ACCESS_REQUIRED)", async () => { |
| const t = convexTest(schema, modules); |
|
|
| await expect( |
| t.withIdentity(FREE_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)), |
| ).rejects.toThrow(/API_ACCESS_REQUIRED/); |
| }); |
|
|
| test("rejects pro-tier users without apiAccess", async () => { |
| const t = convexTest(schema, modules); |
| await seedProEntitlement(t, "user-pro"); |
|
|
| |
| await expect( |
| t.withIdentity(PRO_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)), |
| ).rejects.toThrow(/API_ACCESS_REQUIRED/); |
| }); |
|
|
| test("rejects users with expired entitlement", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api", { validUntil: PAST }); |
|
|
| await expect( |
| t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)), |
| ).rejects.toThrow(/API_ACCESS_REQUIRED/); |
| }); |
|
|
| test("succeeds for API-tier user", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const result = await t.withIdentity(API_USER).mutation( |
| api.apiKeys.createApiKey, |
| makeKeyArgs(1), |
| ); |
|
|
| expect(result).toMatchObject({ |
| name: "test-key-1", |
| keyPrefix: "wm_00001", |
| }); |
| expect(result.id).toBeTruthy(); |
| }); |
|
|
| test("enforces per-user limit of 5 active keys", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| for (let i = 1; i <= 5; i++) { |
| await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(i)); |
| } |
|
|
| await expect( |
| asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(6)), |
| ).rejects.toThrow(/KEY_LIMIT_REACHED/); |
|
|
| const keys = await asApiUser.query(api.apiKeys.listApiKeys, {}); |
| expect(keys.filter((k: any) => !k.revokedAt)).toHaveLength(5); |
| expect(keys.filter((k: any) => k.revokedAt)).toHaveLength(0); |
| }); |
|
|
| for (const overflow of [ |
| { seededActive: 6, nextKey: 7, revokedNames: ["test-key-1", "test-key-2"] }, |
| { |
| seededActive: 8, |
| nextKey: 9, |
| revokedNames: ["test-key-1", "test-key-2", "test-key-3", "test-key-4"], |
| }, |
| ]) { |
| test(`race-leftover overflow from ${overflow.seededActive} active keys converges back to 5 active keys while creating`, async () => { |
| |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
| await seedActiveApiKeys(t, "user-api", overflow.seededActive); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation( |
| api.apiKeys.createApiKey, |
| makeKeyArgs(overflow.nextKey), |
| ); |
| expect(created.name).toBe(`test-key-${overflow.nextKey}`); |
|
|
| const keys = await asApiUser.query(api.apiKeys.listApiKeys, {}); |
| const active = keys.filter((k: any) => !k.revokedAt); |
| const revoked = keys.filter((k: any) => k.revokedAt); |
| expect(active).toHaveLength(5); |
| expect(revoked).toHaveLength(overflow.revokedNames.length); |
| expect(revoked.map((k: any) => k.name).sort()).toEqual(overflow.revokedNames); |
| expect(active.some((k: any) => k.name === `test-key-${overflow.nextKey}`)).toBe(true); |
| }); |
| } |
|
|
| test("revoked keys do not count toward the limit", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const first = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| for (let i = 2; i <= 5; i++) { |
| await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(i)); |
| } |
|
|
| |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: first.id }); |
|
|
| |
| const sixth = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(6)); |
| expect(sixth.name).toBe("test-key-6"); |
| }); |
|
|
| test("rejects duplicate key hash", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
|
|
| await expect( |
| asApiUser.mutation(api.apiKeys.createApiKey, { |
| ...makeKeyArgs(1), |
| name: "different-name", |
| }), |
| ).rejects.toThrow(/DUPLICATE_KEY/); |
| }); |
|
|
| test("rejects invalid keyPrefix format", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| await expect( |
| t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, { |
| name: "test", |
| keyPrefix: "wm_toolong00", |
| keyHash: "a".repeat(64), |
| }), |
| ).rejects.toThrow(/INVALID_PREFIX/); |
| }); |
|
|
| test("rejects invalid keyHash format", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| await expect( |
| t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, { |
| name: "test", |
| keyPrefix: "wm_abcde", |
| keyHash: "not-a-valid-hash", |
| }), |
| ).rejects.toThrow(/INVALID_HASH/); |
| }); |
|
|
| test("rejects empty name", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| await expect( |
| t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, { |
| name: " ", |
| keyPrefix: "wm_abcde", |
| keyHash: "a".repeat(64), |
| }), |
| ).rejects.toThrow(/INVALID_NAME/); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("revokeApiKey", () => { |
| test("revokes own key and returns keyHash", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
|
|
| const result = await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }); |
| expect(result.ok).toBe(true); |
| expect(result.keyHash).toBe(makeKeyArgs(1).keyHash); |
| }); |
|
|
| test("rejects non-owner revoke attempt", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const created = await t.withIdentity(API_USER).mutation( |
| api.apiKeys.createApiKey, |
| makeKeyArgs(1), |
| ); |
|
|
| await expect( |
| t.withIdentity(OTHER_USER).mutation(api.apiKeys.revokeApiKey, { keyId: created.id }), |
| ).rejects.toThrow(/NOT_FOUND/); |
| }); |
|
|
| test("rejects double revocation", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }); |
|
|
| await expect( |
| asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }), |
| ).rejects.toThrow(/ALREADY_REVOKED/); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("listApiKeys", () => { |
| test("returns empty list when no keys", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const keys = await t.withIdentity(API_USER).query(api.apiKeys.listApiKeys, {}); |
| expect(keys).toEqual([]); |
| }); |
|
|
| test("returns both active and revoked keys", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const k1 = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(2)); |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: k1.id }); |
|
|
| const keys = await asApiUser.query(api.apiKeys.listApiKeys, {}); |
| expect(keys).toHaveLength(2); |
|
|
| const active = keys.filter((k: any) => !k.revokedAt); |
| const revoked = keys.filter((k: any) => k.revokedAt); |
| expect(active).toHaveLength(1); |
| expect(revoked).toHaveLength(1); |
| }); |
|
|
| test("does not return other users' keys", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| await t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
|
|
| const otherKeys = await t.withIdentity(OTHER_USER).query(api.apiKeys.listApiKeys, {}); |
| expect(otherKeys).toEqual([]); |
| }); |
|
|
| test("returns empty during an unauthenticated query race", async () => { |
| |
| |
| |
| |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
| await t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
|
|
| const keys = await t.query(api.apiKeys.listApiKeys, {}); |
| expect(keys).toEqual([]); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("validateKeyByHash", () => { |
| test("returns key info for valid active key", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| await t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
|
|
| const result = await t.query(internal.apiKeys.validateKeyByHash, { |
| keyHash: makeKeyArgs(1).keyHash, |
| }); |
| expect(result).toMatchObject({ |
| userId: "user-api", |
| name: "test-key-1", |
| }); |
| }); |
|
|
| test("returns null for revoked key", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }); |
|
|
| const result = await t.query(internal.apiKeys.validateKeyByHash, { |
| keyHash: makeKeyArgs(1).keyHash, |
| }); |
| expect(result).toBeNull(); |
| }); |
|
|
| test("returns null for nonexistent hash", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const result = await t.query(internal.apiKeys.validateKeyByHash, { |
| keyHash: "f".repeat(64), |
| }); |
| expect(result).toBeNull(); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("getKeyOwner", () => { |
| test("returns owner regardless of revoked status", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }); |
|
|
| const result = await t.query(internal.apiKeys.getKeyOwner, { |
| keyHash: makeKeyArgs(1).keyHash, |
| }); |
| expect(result).toEqual({ userId: "user-api" }); |
| }); |
|
|
| test("returns null for nonexistent hash", async () => { |
| const t = convexTest(schema, modules); |
|
|
| const result = await t.query(internal.apiKeys.getKeyOwner, { |
| keyHash: "f".repeat(64), |
| }); |
| expect(result).toBeNull(); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("touchKeyLastUsed", () => { |
| test("sets lastUsedAt on first call", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const created = await t.withIdentity(API_USER).mutation( |
| api.apiKeys.createApiKey, |
| makeKeyArgs(1), |
| ); |
|
|
| await t.mutation(internal.apiKeys.touchKeyLastUsed, { keyId: created.id }); |
|
|
| const keys = await t.withIdentity(API_USER).query(api.apiKeys.listApiKeys, {}); |
| const key = keys.find((k: any) => k.id === created.id); |
| expect(key?.lastUsedAt).toBeGreaterThan(0); |
| }); |
|
|
| test("skips write for revoked key", async () => { |
| const t = convexTest(schema, modules); |
| await seedApiEntitlement(t, "user-api"); |
|
|
| const asApiUser = t.withIdentity(API_USER); |
| const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1)); |
| await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }); |
|
|
| |
| await t.mutation(internal.apiKeys.touchKeyLastUsed, { keyId: created.id }); |
| }); |
| }); |
|
|