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