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