Files
freno-dev/src/server/clerk-user-webhook.test.ts
Michael Freno 898c891bd5 chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
2026-08-11 13:36:18 -04:00

337 lines
9.9 KiB
TypeScript

/**
* Clerk user webhook sync tests
*
* Exercises `handleClerkUserWebhook` against an in-memory SQLite DB
* (`bun:sqlite`) wrapped to match the libsql `{ execute({ sql, args }) }`
* contract. Each event payload is Svix-signed with the same secret the
* handler verifies against, so the signature path is exercised for real —
* including the unsigned / wrong-signature rejection cases.
*/
import { describe, it, expect, beforeEach } from "bun:test";
import { Database } from "bun:sqlite";
import { Webhook } from "svix";
import {
handleClerkUserWebhook,
__resetClerkWebhookMigrationForTests,
type NessaConn
} from "~/server/clerk-user-webhook";
// ─── harness ──────────────────────────────────────────────────────────────
const WEBHOOK_SECRET =
"whsec_dGhpcyBpcyBhIHRlc3Qgc2VjcmV0IGtleSBmb3IgY2xlcmsgd2ViaG9va3M=";
let db: Database;
let conn: NessaConn;
function makeConn(): NessaConn {
return {
execute: async ({
sql,
args
}: {
sql: string;
args?: (string | number | null)[];
}) => {
const stmt = db.prepare(sql);
const upper = sql.trim().toUpperCase();
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
if (isRead) {
const rows = stmt.all(...(args ?? []));
return { rows: rows as unknown[] };
}
const info = stmt.run(...(args ?? []));
return { rows: [] as unknown[], rowsAffected: info.changes };
}
};
}
function initSchema() {
db = new Database(":memory:");
// `users` table mirrors the Nessa production schema — note clerkUserId is
// NOT present here so we exercise the idempotent ALTER-TABLE migration path.
db.run(`CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
emailVerified INTEGER DEFAULT 0,
firstName TEXT,
lastName TEXT,
displayName TEXT,
avatarUrl TEXT,
provider TEXT,
appleUserId TEXT,
status TEXT,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
lastLoginAt TEXT
)`);
}
beforeEach(() => {
initSchema();
conn = makeConn();
__resetClerkWebhookMigrationForTests();
});
// ─── signings helpers ─────────────────────────────────────────────────────
function sign(
payload: object,
secret: string = WEBHOOK_SECRET
): { rawBody: string; headers: ReturnType<typeof headersFor> } {
const rawBody = JSON.stringify(payload);
const msgId = `msg_${Math.random().toString(36).slice(2)}`;
const ts = new Date();
const wh = new Webhook(secret);
const signature = wh.sign(msgId, ts, rawBody);
return {
rawBody,
headers: {
"svix-id": msgId,
"svix-timestamp": String(Math.floor(ts.getTime() / 1000)),
"svix-signature": signature
}
};
}
// satisfy the inferred header type
function headersFor() {
return {
"svix-id": "",
"svix-timestamp": "",
"svix-signature": ""
};
}
function userCreatedPayload(overrides: Record<string, unknown> = {}) {
return {
object: "event",
type: "user.created",
data: {
id: "user_abc123",
email_addresses: [
{
id: "idn_1",
email_address: "jane@example.com",
verification: { status: "verified" }
}
],
primary_email_address_id: "idn_1",
first_name: "Jane",
last_name: "Doe",
username: "janedoe",
image_url: "https://cdn.clerk.com/avatar.png",
...overrides
}
};
}
function userUpdatedPayload(overrides: Record<string, unknown> = {}) {
return {
object: "event",
type: "user.updated",
data: {
id: "user_abc123",
email_addresses: [
{
id: "idn_1",
email_address: "jane.new@example.com",
verification: { status: "verified" }
}
],
primary_email_address_id: "idn_1",
first_name: "Jane",
last_name: "Smith",
username: "janesmith",
image_url: "https://cdn.clerk.com/avatar2.png",
...overrides
}
};
}
async function call(
signed: { rawBody: string; headers: ReturnType<typeof headersFor> },
secret: string = WEBHOOK_SECRET
) {
return handleClerkUserWebhook({
rawBody: signed.rawBody,
headers: signed.headers,
webhookSecret: secret,
conn
});
}
function getUserByClerkId(clerkUserId: string) {
const row = db
.prepare(
"SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
)
.get(clerkUserId) as
| {
id: string;
clerkUserId: string;
email: string | null;
emailVerified: number;
firstName: string | null;
lastName: string | null;
displayName: string | null;
avatarUrl: string | null;
provider: string | null;
status: string | null;
}
| undefined;
return row;
}
// ─── tests ───────────────────────────────────────────────────────────────
describe("Clerk user.created webhook", () => {
it("creates a local users row keyed by clerkUserId", async () => {
const res = await call(sign(userCreatedPayload()));
expect(res.status).toBe(200);
const user = getUserByClerkId("user_abc123");
expect(user).toBeDefined();
expect(user!.clerkUserId).toBe("user_abc123");
expect(user!.email).toBe("jane@example.com");
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
expect(user!.firstName).toBe("Jane");
expect(user!.lastName).toBe("Doe");
expect(user!.displayName).toBe("Jane Doe");
expect(user!.avatarUrl).toBe("https://cdn.clerk.com/avatar.png");
expect(user!.provider).toBe("clerk");
expect(user!.status).toBe("active");
expect(user!.id).not.toBe("user_abc123"); // a fresh local UUID, not the Clerk id
});
it("upserts (does not duplicate) on a replayed created event", async () => {
const signed = sign(userCreatedPayload());
await call(signed);
await call(signed); // replay
const count = (
db.prepare("SELECT COUNT(*) as n FROM users WHERE clerkUserId = ?").get(
"user_abc123"
) as { n: number }
).n;
expect(count).toBe(1);
});
it("falls back to username when first/last name are absent", async () => {
const res = await call(
sign(
userCreatedPayload({
first_name: null,
last_name: null
})
)
);
expect(res.status).toBe(200);
const user = getUserByClerkId("user_abc123");
expect(user!.displayName).toBe("janedoe");
});
it("records emailVerified=0 when Clerk marks the email unverified", async () => {
const res = await call(
sign(
userCreatedPayload({
email_addresses: [
{
id: "idn_1",
email_address: "unverified@example.com",
verification: { status: "unverified" }
}
],
primary_email_address_id: "idn_1"
})
)
);
expect(res.status).toBe(200);
const user = getUserByClerkId("user_abc123");
expect(user!.emailVerified).toBe(0);
});
});
describe("Clerk user.updated webhook", () => {
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
await call(sign(userCreatedPayload()));
const before = getUserByClerkId("user_abc123");
const localId = before!.id;
const res = await call(sign(userUpdatedPayload()));
expect(res.status).toBe(200);
const after = getUserByClerkId("user_abc123");
expect(after!.id).toBe(localId); // local UUID stable
expect(after!.clerkUserId).toBe("user_abc123");
expect(after!.email).toBe("jane.new@example.com");
expect(after!.emailVerified).toBe(1); // verification status carried through update
expect(after!.lastName).toBe("Smith");
expect(after!.displayName).toBe("Jane Smith");
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");
});
it("is a no-op when the user does not exist (no row inserted)", async () => {
const res = await call(sign(userUpdatedPayload()));
expect(res.status).toBe(200);
const count = (
db.prepare("SELECT COUNT(*) as n FROM users").get() as { n: number }
).n;
expect(count).toBe(0);
});
});
describe("Clerk webhook signature enforcement", () => {
it("rejects a missing-svix-header request with 400", async () => {
const res = await handleClerkUserWebhook({
rawBody: JSON.stringify(userCreatedPayload()),
headers: {
"svix-id": "",
"svix-timestamp": "",
"svix-signature": ""
},
webhookSecret: WEBHOOK_SECRET,
conn
});
expect(res.status).toBe(400);
});
it("rejects an unsigned payload with 401", async () => {
const res = await handleClerkUserWebhook({
rawBody: JSON.stringify(userCreatedPayload()),
headers: {
"svix-id": "msg_x",
"svix-timestamp": String(Math.floor(Date.now() / 1000)),
"svix-signature": "v1,tampered"
},
webhookSecret: WEBHOOK_SECRET,
conn
});
expect(res.status).toBe(401);
});
it("rejects a payload signed with the wrong secret with 401", async () => {
const res = await call(
sign(
userCreatedPayload(),
"whsec_YW5vdGhlcl9kaWZmZXJlbnRfc2VjcmV0X2tleV9mb3JfdGVzdHM="
),
WEBHOOK_SECRET // handler uses this
);
expect(res.status).toBe(401);
});
it("ignores non user.* event types with 200", async () => {
const res = await call(
sign({
object: "event",
type: "session.created",
data: { id: "sess_1" }
})
);
expect(res.status).toBe(200);
expect((res.body as { ignored: string }).ignored).toBe("session.created");
});
});