Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 13 additions & 1 deletion example/convex/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 57 additions & 1 deletion src/component/lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
/// <reference types="vite/client" />
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 = {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/component/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading