← Provider replacement walkthrough

IAPKit · convex/subscriptions/mutation.test.ts

Source snapshot · 2026-10-21

SHA-256 a2d3af561721289070b042e0b450dbd9560e9706606a0c8e714a4e8d033df404
1import { describe, expect, it, vi } from "vitest";2 3import { testableFunction } from "../test.setup";4import { hmacSha256Hex, sha256Hex } from "../utils/sha256";5import {6  bindUserAsServer as registeredBindUserAsServer,7  rebindUser as registeredRebindUser,8  requestUserErasure as registeredRequestUserErasure,9} from "./mutation";10 11const rebindUser = testableFunction(registeredRebindUser);12const requestUserErasure = testableFunction(registeredRequestUserErasure);13const bindUserAsServer = testableFunction(registeredBindUserAsServer);14 15// rebindUser moves who owns a purchase, so it is secret-key only. The check16// lives in the mutation, not in the HTTP layer, because Convex functions are17// publicly callable with a key alone.18describe("rebindUser authorization", () => {19  const project = {20    _id: "projects_1",21    _creationTime: 0,22    organizationId: "organizations_1",23    userErasureHashKey: "test-erasure-hash-key",24  };25  const organization = { _id: "organizations_1", _creationTime: 0 };26 27  const dbWith = (28    keyType?: "publishable" | "secret",29    erasureJob?: Record<string, unknown>,30  ) => ({31    rows: {32      apiKeys: keyType33        ? [34            {35              _id: "apiKeys_1",36              key: "k",37              keyType,38              isActive: true,39              projectId: project._id,40              organizationId: organization._id,41            },42          ]43        : [],44      projects: [project],45      organizations: [organization],46      subscriptions: [],47      subscriptionUserErasureJobs: erasureJob ? [erasureJob] : [],48    } as Record<string, Record<string, unknown>[]>,49    async get(id: string) {50      return (51        Object.values(this.rows)52          .flat()53          .find((r) => r._id === id) ?? null54      );55    },56    query(table: string) {57      const rows = this.rows[table] ?? [];58      const api = {59        withIndex: (_n: string, fn: (q: unknown) => unknown) => {60          const captured: Record<string, unknown> = {};61          const q: Record<string, (f: string, v: unknown) => unknown> = {};62          q.eq = (f, v) => {63            captured[f] = v;64            return q;65          };66          fn(q);67          const result = {68            first: async () =>69              rows.find((r) =>70                Object.entries(captured).every(([k, v]) => r[k] === v),71              ) ?? null,72            unique: async () =>73              rows.find((r) =>74                Object.entries(captured).every(([k, v]) => r[k] === v),75              ) ?? null,76            collect: async () => [],77            // Only the store filter of the one-time purchase lookup uses this.78            filter: () => result,79          };80          return result;81        },82      };83      return api;84    },85    async patch(id: string, value: Record<string, unknown>) {86      const row = Object.values(this.rows)87        .flat()88        .find((candidate) => candidate._id === id);89      if (row) Object.assign(row, value);90    },91    async insert(table: string, value: Record<string, unknown>) {92      const id = `${table}_${(this.rows[table]?.length ?? 0) + 1}`;93      const row = { _id: id, ...value };94      (this.rows[table] ??= []).push(row);95      return id;96    },97  });98 99  it("rejects a publishable key", async () => {100    await expect(101      rebindUser._handler(dbWithCtx("publishable"), {102        apiKey: "k",103        purchaseToken: "t",104        userId: "u",105      }),106    ).rejects.toSatisfy(107      (e: unknown) =>108        (e as { data?: { code?: string } }).data?.code === "INSUFFICIENT_SCOPE",109    );110  });111 112  it("accepts a secret key", async () => {113    await expect(114      rebindUser._handler(dbWithCtx("secret"), {115        apiKey: "k",116        purchaseToken: "t",117        userId: "u",118      }),119    ).resolves.toEqual({ ok: true, rebound: false, notified: true });120  });121 122  it("rejects a publishable key for user erasure inside Convex", async () => {123    await expect(124      requestUserErasure._handler(dbWithCtx("publishable"), {125        apiKey: "k",126        userId: "user-1",127      }),128    ).rejects.toSatisfy(129      (e: unknown) =>130        (e as { data?: { code?: string } }).data?.code === "INSUFFICIENT_SCOPE",131    );132  });133 134  it("rejects an unknown secret-shaped key for user erasure", async () => {135    await expect(136      requestUserErasure._handler(137        { db: dbWith() },138        {139          apiKey: "openiap-kit_sk_unknown",140          userId: "user-1",141        },142      ),143    ).rejects.toSatisfy(144      (error: unknown) =>145        (error as { data?: { code?: string } }).data?.code ===146        "INVALID_API_KEY",147    );148  });149 150  it("returns the completed erasure job instead of reopening it", async () => {151    const userId = "user-1";152    const completed = {153      _id: "subscriptionUserErasureJobs_1",154      projectId: project._id,155      userIdHash: await hmacSha256Hex(project.userErasureHashKey, userId),156      status: "completed",157    };158    const scheduler = { runAfter: vi.fn() };159 160    await expect(161      requestUserErasure._handler(162        { db: dbWith("secret", completed), scheduler },163        { apiKey: "k", userId },164      ),165    ).resolves.toEqual({166      ok: true,167      jobId: completed._id,168      status: "completed",169    });170    expect(scheduler.runAfter).not.toHaveBeenCalled();171  });172 173  it("rekeys a legacy completed job on an idempotent request", async () => {174    const userId = "legacy-user";175    const completed = {176      _id: "subscriptionUserErasureJobs_legacy",177      projectId: project._id,178      userIdHash: await sha256Hex(userId),179      status: "completed",180    };181    const db = dbWith("secret", completed);182    const scheduler = { runAfter: vi.fn() };183 184    await expect(185      requestUserErasure._handler(186        { db, scheduler },187        {188          apiKey: "k",189          userId,190        },191      ),192    ).resolves.toMatchObject({ jobId: completed._id, status: "completed" });193    expect(completed.userIdHash).toBe(194      await hmacSha256Hex(project.userErasureHashKey, userId),195    );196    expect(scheduler.runAfter).not.toHaveBeenCalled();197  });198 199  it("stores a keyed erasure lookup instead of a plain userId digest", async () => {200    const userId = "guessable-user@example.com";201    const db = dbWith("secret");202    const scheduler = { runAfter: vi.fn() };203 204    await requestUserErasure._handler(205      { db, scheduler },206      {207        apiKey: "k",208        userId,209      },210    );211 212    const [job] = db.rows.subscriptionUserErasureJobs;213    expect(job.userIdHash).toBe(214      await hmacSha256Hex(project.userErasureHashKey, userId),215    );216    expect(job.userIdHash).not.toBe(await sha256Hex(userId));217  });218 219  it("creates a project erasure key on first use", async () => {220    const userId = "first-erasure-user";221    const db = dbWith("secret");222    db.rows.projects = [{ ...project, userErasureHashKey: undefined }];223    const scheduler = { runAfter: vi.fn() };224 225    await requestUserErasure._handler(226      { db, scheduler },227      {228        apiKey: "k",229        userId,230      },231    );232 233    const [projectRow] = db.rows.projects;234    const key = projectRow.userErasureHashKey;235    expect(key).toEqual(expect.any(String));236    expect(String(key)).toHaveLength(64);237    expect(db.rows.subscriptionUserErasureJobs[0].userIdHash).toBe(238      await hmacSha256Hex(String(key), userId),239    );240  });241 242  // bindPurchase (server role) must distinguish an unknown key from an243  // under-scoped one: unknown/inactive is INVALID_API_KEY (UNAUTHORIZED at the244  // edge), only a real publishable key is INSUFFICIENT_SCOPE (FORBIDDEN).245  it("rejects an unknown key with INVALID_API_KEY, not INSUFFICIENT_SCOPE", async () => {246    await expect(247      bindUserAsServer._handler(248        { db: dbWith() },249        {250          apiKey: "openiap-kit_sk_unknown",251          purchaseToken: "t",252          userId: "u",253        },254      ),255    ).rejects.toSatisfy(256      (error: unknown) =>257        (error as { data?: { code?: string } }).data?.code ===258        "INVALID_API_KEY",259    );260  });261 262  it("rejects a valid publishable key with INSUFFICIENT_SCOPE", async () => {263    await expect(264      bindUserAsServer._handler(dbWithCtx("publishable"), {265        apiKey: "k",266        purchaseToken: "t",267        userId: "u",268      }),269    ).rejects.toSatisfy(270      (error: unknown) =>271        (error as { data?: { code?: string } }).data?.code ===272        "INSUFFICIENT_SCOPE",273    );274  });275 276  it("accepts a secret key", async () => {277    await expect(278      bindUserAsServer._handler(dbWithCtx("secret"), {279        apiKey: "k",280        purchaseToken: "t",281        userId: "u",282      }),283    ).resolves.toEqual({ ok: true, bound: false });284  });285 286  function dbWithCtx(keyType: "publishable" | "secret") {287    return { db: dbWith(keyType) } as never;288  }289});290