From 09db52bb4d88ae226f9a7f90448b996cb087949e Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Thu, 21 May 2026 23:03:57 +0200 Subject: [PATCH] test(websub): add hub route tests Add comprehensive tests for POST /hub validation (missing fields, unknown mode, non-HTTPS callback, invalid URL, wrong domain, secret > 200 bytes) and happy-path subscribe/unsubscribe (202). Also fix hub.ts to use a waitUntilSafe wrapper so executionCtx.waitUntil doesn't throw in Node test environments. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/hub.test.ts | 213 +++++++++++++++++++++++++++++++++++++++++ src/routes/hub.ts | 17 +++- 2 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 src/routes/hub.test.ts diff --git a/src/routes/hub.test.ts b/src/routes/hub.test.ts new file mode 100644 index 0000000..af7bacf --- /dev/null +++ b/src/routes/hub.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { http, HttpResponse } from "msw"; +import { server, createMockEnv } from "../test/setup"; +import { hubRouter } from "./hub"; + +function makeApp() { + const app = new Hono(); + app.route("/hub", hubRouter); + return app; +} + +function hubBody(fields: Record): Request { + const body = new URLSearchParams(fields).toString(); + return new Request("http://localhost/hub", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); +} + +describe("POST /hub — input validation", () => { + it("returns 400 when hub.mode is missing", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when hub.topic is missing", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when hub.callback is missing", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 for unknown hub.mode", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "ping", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when hub.callback is not HTTPS", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "http://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(400); + const body = await res.text(); + expect(body).toContain("HTTPS"); + }); + + it("returns 400 when hub.callback is not a valid URL", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "not-a-url", + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when hub.topic does not match this hub's domain", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": "https://other.example/rss/feed1", + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when hub.secret exceeds 200 bytes", async () => { + const app = makeApp(); + const env = createMockEnv(); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + "hub.secret": "x".repeat(201), + }), + env, + ); + expect(res.status).toBe(400); + }); +}); + +describe("POST /hub — subscribe", () => { + it("returns 202 for valid subscribe request", async () => { + const app = makeApp(); + const env = createMockEnv(); + server.use( + http.get("https://cb.example/sub", ({ request }) => { + const challenge = + new URL(request.url).searchParams.get("hub.challenge") ?? ""; + return HttpResponse.text(challenge); + }), + ); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(202); + }); + + it("accepts hub.lease_seconds within range", async () => { + const app = makeApp(); + const env = createMockEnv(); + server.use( + http.get("https://cb.example/sub", ({ request }) => { + const challenge = + new URL(request.url).searchParams.get("hub.challenge") ?? ""; + return HttpResponse.text(challenge); + }), + ); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "subscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + "hub.lease_seconds": "3600", + }), + env, + ); + expect(res.status).toBe(202); + }); +}); + +describe("POST /hub — unsubscribe", () => { + it("returns 202 for valid unsubscribe request", async () => { + const app = makeApp(); + const env = createMockEnv(); + server.use( + http.get("https://cb.example/sub", ({ request }) => { + const challenge = + new URL(request.url).searchParams.get("hub.challenge") ?? ""; + return HttpResponse.text(challenge); + }), + ); + const res = await app.request( + "/hub", + hubBody({ + "hub.mode": "unsubscribe", + "hub.topic": `https://${env.DOMAIN}/rss/feed1`, + "hub.callback": "https://cb.example/sub", + }), + env, + ); + expect(res.status).toBe(202); + }); +}); diff --git a/src/routes/hub.ts b/src/routes/hub.ts index c5af93f..b470088 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -1,10 +1,19 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { Env } from "../types"; import { verifyAndStoreSubscription, verifyAndDeleteSubscription, } from "../utils/websub"; +function waitUntilSafe(c: Context, promise: Promise) { + // Hono throws when ExecutionContext isn't present (e.g. Node unit tests). + try { + c.executionCtx.waitUntil(promise); + } catch { + // ignore + } +} + const DEFAULT_LEASE_SECONDS = 86400; const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days @@ -82,7 +91,8 @@ hubRouter.post("/", async (c) => { // Return 202 immediately; verification is async if (mode === "subscribe") { - c.executionCtx.waitUntil( + waitUntilSafe( + c, verifyAndStoreSubscription( feedId, callbackUrl as string, @@ -92,7 +102,8 @@ hubRouter.post("/", async (c) => { ), ); } else { - c.executionCtx.waitUntil( + waitUntilSafe( + c, verifyAndDeleteSubscription(feedId, callbackUrl as string, env), ); }