← Provider replacement walkthrough

IAPKit · convex/subscriptions/mutation.test.ts

Source snapshot · 2026-09-09

SHA-256 dd89c082b0257562961ced0966edad30911a7ec806648fcfc2828b42c3084ab2
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          return {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          };78        },79      };80      return api;81    },82    async patch(id: string, value: Record<string, unknown>) {83      const row = Object.values(this.rows)84        .flat()85        .find((candidate) => candidate._id === id);86      if (row) Object.assign(row, value);87    },88    async insert(table: string, value: Record<string, unknown>) {89      const id = `${table}_${(this.rows[table]?.length ?? 0) + 1}`;90      const row = { _id: id, ...value };91      (this.rows[table] ??= []).push(row);92      return id;93    },94  });95 96  it("rejects a publishable key", async () => {97    await expect(98      rebindUser._handler(dbWithCtx("publishable"), {99        apiKey: "k",100        purchaseToken: "t",101        userId: "u",102      }),103    ).rejects.toSatisfy(104      (e: unknown) =>105        (e as { data?: { code?: string } }).data?.code === "INSUFFICIENT_SCOPE",106    );107  });108 109  it("accepts a secret key", async () => {110    await expect(111      rebindUser._handler(dbWithCtx("secret"), {112        apiKey: "k",113        purchaseToken: "t",114        userId: "u",115      }),116    ).resolves.toEqual({ ok: true, rebound: false, notified: true });117  });118 119  it("rejects a publishable key for user erasure inside Convex", async () => {120    await expect(121      requestUserErasure._handler(dbWithCtx("publishable"), {122        apiKey: "k",123        userId: "user-1",124      }),125    ).rejects.toSatisfy(126      (e: unknown) =>127        (e as { data?: { code?: string } }).data?.code === "INSUFFICIENT_SCOPE",128    );129  });130 131  it("rejects an unknown secret-shaped key for user erasure", async () => {132    await expect(133      requestUserErasure._handler(134        { db: dbWith() },135        {136          apiKey: "openiap-kit_sk_unknown",137          userId: "user-1",138        },139      ),140    ).rejects.toSatisfy(141      (error: unknown) =>142        (error as { data?: { code?: string } }).data?.code ===143        "INVALID_API_KEY",144    );145  });146 147  it("returns the completed erasure job instead of reopening it", async () => {148    const userId = "user-1";149    const completed = {150      _id: "subscriptionUserErasureJobs_1",151      projectId: project._id,152      userIdHash: await hmacSha256Hex(project.userErasureHashKey, userId),153      status: "completed",154    };155    const scheduler = { runAfter: vi.fn() };156 157    await expect(158      requestUserErasure._handler(159        { db: dbWith("secret", completed), scheduler },160        { apiKey: "k", userId },161      ),162    ).resolves.toEqual({163      ok: true,164      jobId: completed._id,165      status: "completed",166    });167    expect(scheduler.runAfter).not.toHaveBeenCalled();168  });169 170  it("rekeys a legacy completed job on an idempotent request", async () => {171    const userId = "legacy-user";172    const completed = {173      _id: "subscriptionUserErasureJobs_legacy",174      projectId: project._id,175      userIdHash: await sha256Hex(userId),176      status: "completed",177    };178    const db = dbWith("secret", completed);179    const scheduler = { runAfter: vi.fn() };180 181    await expect(182      requestUserErasure._handler(183        { db, scheduler },184        {185          apiKey: "k",186          userId,187        },188      ),189    ).resolves.toMatchObject({ jobId: completed._id, status: "completed" });190    expect(completed.userIdHash).toBe(191      await hmacSha256Hex(project.userErasureHashKey, userId),192    );193    expect(scheduler.runAfter).not.toHaveBeenCalled();194  });195 196  it("stores a keyed erasure lookup instead of a plain userId digest", async () => {197    const userId = "guessable-user@example.com";198    const db = dbWith("secret");199    const scheduler = { runAfter: vi.fn() };200 201    await requestUserErasure._handler(202      { db, scheduler },203      {204        apiKey: "k",205        userId,206      },207    );208 209    const [job] = db.rows.subscriptionUserErasureJobs;210    expect(job.userIdHash).toBe(211      await hmacSha256Hex(project.userErasureHashKey, userId),212    );213    expect(job.userIdHash).not.toBe(await sha256Hex(userId));214  });215 216  it("creates a project erasure key on first use", async () => {217    const userId = "first-erasure-user";218    const db = dbWith("secret");219    db.rows.projects = [{ ...project, userErasureHashKey: undefined }];220    const scheduler = { runAfter: vi.fn() };221 222    await requestUserErasure._handler(223      { db, scheduler },224      {225        apiKey: "k",226        userId,227      },228    );229 230    const [projectRow] = db.rows.projects;231    const key = projectRow.userErasureHashKey;232    expect(key).toEqual(expect.any(String));233    expect(String(key)).toHaveLength(64);234    expect(db.rows.subscriptionUserErasureJobs[0].userIdHash).toBe(235      await hmacSha256Hex(String(key), userId),236    );237  });238 239  // bindPurchase (server role) must distinguish an unknown key from an240  // under-scoped one: unknown/inactive is INVALID_API_KEY (UNAUTHORIZED at the241  // edge), only a real publishable key is INSUFFICIENT_SCOPE (FORBIDDEN).242  it("rejects an unknown key with INVALID_API_KEY, not INSUFFICIENT_SCOPE", async () => {243    await expect(244      bindUserAsServer._handler(245        { db: dbWith() },246        {247          apiKey: "openiap-kit_sk_unknown",248          purchaseToken: "t",249          userId: "u",250        },251      ),252    ).rejects.toSatisfy(253      (error: unknown) =>254        (error as { data?: { code?: string } }).data?.code ===255        "INVALID_API_KEY",256    );257  });258 259  it("rejects a valid publishable key with INSUFFICIENT_SCOPE", async () => {260    await expect(261      bindUserAsServer._handler(dbWithCtx("publishable"), {262        apiKey: "k",263        purchaseToken: "t",264        userId: "u",265      }),266    ).rejects.toSatisfy(267      (error: unknown) =>268        (error as { data?: { code?: string } }).data?.code ===269        "INSUFFICIENT_SCOPE",270    );271  });272 273  it("accepts a secret key", async () => {274    await expect(275      bindUserAsServer._handler(dbWithCtx("secret"), {276        apiKey: "k",277        purchaseToken: "t",278        userId: "u",279      }),280    ).resolves.toEqual({ ok: true, bound: false });281  });282 283  function dbWithCtx(keyType: "publishable" | "secret") {284    return { db: dbWith(keyType) } as never;285  }286});287