mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-3
@@ -1,10 +1,19 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono, type Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import {
|
import {
|
||||||
verifyAndStoreSubscription,
|
verifyAndStoreSubscription,
|
||||||
verifyAndDeleteSubscription,
|
verifyAndDeleteSubscription,
|
||||||
} from "../utils/websub";
|
} from "../utils/websub";
|
||||||
|
|
||||||
|
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
|
||||||
|
// 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 DEFAULT_LEASE_SECONDS = 86400;
|
||||||
const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
|
const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
@@ -82,7 +91,8 @@ hubRouter.post("/", async (c) => {
|
|||||||
|
|
||||||
// Return 202 immediately; verification is async
|
// Return 202 immediately; verification is async
|
||||||
if (mode === "subscribe") {
|
if (mode === "subscribe") {
|
||||||
c.executionCtx.waitUntil(
|
waitUntilSafe(
|
||||||
|
c,
|
||||||
verifyAndStoreSubscription(
|
verifyAndStoreSubscription(
|
||||||
feedId,
|
feedId,
|
||||||
callbackUrl as string,
|
callbackUrl as string,
|
||||||
@@ -92,7 +102,8 @@ hubRouter.post("/", async (c) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
c.executionCtx.waitUntil(
|
waitUntilSafe(
|
||||||
|
c,
|
||||||
verifyAndDeleteSubscription(feedId, callbackUrl as string, env),
|
verifyAndDeleteSubscription(feedId, callbackUrl as string, env),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user