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
337 lines
9.9 KiB
TypeScript
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");
|
|
});
|
|
});
|