diff --git a/CHANGELOG.md b/CHANGELOG.md index 024e00f..3981f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Call the `user.deleted` handler even when the component never stored the user (#82) + ## 0.2.10 - Fix deleted user handling and drop the unused `additionalEventTypes` option (#77) diff --git a/README.md b/README.md index 06fa7c9..882fd29 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,16 @@ By default the component will handle the following events: - `user.updated` - `user.deleted` +The `user.deleted` handler also runs when the component never stored the user, for +example when WorkOS delivers the deletion before the creation. `event.data` is then +the webhook payload as sent, so only `id` is guaranteed, even though it is typed as +a full user. When the component has the user stored, its fields fill any gaps. + +**If your app has no record of the user, be sure to return rather than throw.** +Throwing fails the webhook and rolls back the component's record of the deletion, +so a `user.created` that arrives before WorkOS retries recreates the user in the +component and your app. + ```ts // convex/auth.ts import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; diff --git a/example/convex/auth.test.ts b/example/convex/auth.test.ts index 5459ff9..2f1fbfc 100644 --- a/example/convex/auth.test.ts +++ b/example/convex/auth.test.ts @@ -135,17 +135,29 @@ describe("authKitEvent", () => { expect(appUsers).toHaveLength(0); }); - test("events for a deleted user do not reach the callback", async () => { + test("user.deleted removes an app user without a stored component user", async () => { const t = initConvexTest(); const onEventHandle = await t.run(async () => createFunctionHandle(internal.auth.authKitEvent) ); const user = makeUser(); + await t.run(async (ctx) => { + await ctx.db.insert("users", { + authId: user.id, + email: user.email, + name: "Alice Smith", + }); + }); await t.mutation(components.workOSAuthKit.lib.onWebhookEvent, { event: makeEvent("user.deleted", user), onEventHandle, }); + const afterDelete = await t.run(async (ctx) => { + return ctx.db.query("users").collect(); + }); + expect(afterDelete).toHaveLength(0); + await t.mutation(components.workOSAuthKit.lib.onWebhookEvent, { event: makeEvent("user.created", user), onEventHandle, diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts index 22ddee7..4adce1b 100644 --- a/src/component/lib.test.ts +++ b/src/component/lib.test.ts @@ -1,10 +1,14 @@ /// import { convexTest } from "convex-test"; -import { describe, test, expect } from "vitest"; +import { createFunctionHandle, makeFunctionReference } from "convex/server"; +import { describe, test, expect, vi } from "vitest"; import { modules } from "./setup.test.js"; import schema from "./schema.js"; import workflow from "@convex-dev/workflow/test"; import { api } from "./_generated/api.js"; +import { mutation } from "./_generated/server.js"; +import { vEvent } from "./lib.js"; +import { v } from "convex/values"; /** Default test user values. */ const defaultUser = { @@ -285,6 +289,58 @@ describe("onWebhookEvent", () => { expect(dbDeletedUsers[0].id).toBe(user.id); }); + test.each([ + { payload: "a full user", data: { object: "user", ...makeUser() } }, + { payload: "only an id", data: { id: defaultUser.id } }, + ])( + "user.deleted forwards $payload without a stored user", + async ({ data }) => { + const onEvent = vi.fn(); + const t = convexTest(schema, { + ...modules, + "./callbacks.ts": async () => ({ + onEvent: mutation({ + args: { event: vEvent.fields.event, data: vEvent.fields.data }, + returns: v.null(), + handler: async (_ctx, args) => { + onEvent(args); + return null; + }, + }), + }), + }); + const onEventHandle = await t.run(async () => + createFunctionHandle( + makeFunctionReference<"mutation">("callbacks:onEvent") + ) + ); + const event = makeRawEvent("user.deleted", data); + + await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle }); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith({ + event: "user.deleted", + data, + }); + + await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle }); + for (const eventType of ["user.created", "user.updated"]) { + await t.mutation(api.lib.onWebhookEvent, { + event: makeEvent(eventType, makeUser()), + onEventHandle, + }); + } + + expect(onEvent).toHaveBeenCalledTimes(1); + const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => ({ + dbUsers: await ctx.db.query("users").collect(), + dbDeletedUsers: await ctx.db.query("deletedUsers").collect(), + })); + expect(dbUsers).toHaveLength(0); + expect(dbDeletedUsers).toMatchObject([{ id: data.id }]); + } + ); + test("user.created after user.deleted is skipped", async () => { const t = initConvexTest(); const user = makeUser(); diff --git a/src/component/lib.ts b/src/component/lib.ts index 1741c23..625f6bf 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -119,8 +119,8 @@ async function processEventHandler( .withIndex("id", (q) => q.eq("id", id)) .unique(); if (!user) { - console.warn("user not found, skipping deletion", id); - return; + console.warn("user not found, forwarding deletion", id); + break; } await ctx.db.delete("users", user._id); // The callback data is typed as a whole user, so fill anything the