mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
a29e9ab372
WebSub / PubSubHubbub:
- Hub now accepts both /rss/:id and /atom/:id topic URLs
- WebSubSubscription stores format ("rss" | "atom")
- notifySubscribers sends RSS or Atom XML with correct Content-Type
- verifyAndStoreSubscription sends correct topic URL per format
- CI paths-ignore docs/** to skip deploy on docs-only changes
HTML processing (linkedom + escape-html):
- New html-processor.ts: body extraction, script/iframe/object removal,
event handler + javascript: URL stripping, mso-* style cleanup,
plain text → <pre> with HTML escaping via escape-html
- feed-generator.ts and entries.ts use processEmailContent
Admin UI:
- W3C validation badges (Atom + RSS) on feed detail page
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
327 lines
8.8 KiB
TypeScript
327 lines
8.8 KiB
TypeScript
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.topic uses an unsupported path (not rss or atom)", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
const res = await app.request(
|
|
"/hub",
|
|
hubBody({
|
|
"hub.mode": "subscribe",
|
|
"hub.topic": `https://${env.DOMAIN}/feed/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();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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);
|
|
});
|
|
|
|
it("returns 404 when feed does not exist", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
const res = await app.request(
|
|
"/hub",
|
|
hubBody({
|
|
"hub.mode": "subscribe",
|
|
"hub.topic": `https://${env.DOMAIN}/rss/nonexistent`,
|
|
"hub.callback": "https://cb.example/sub",
|
|
}),
|
|
env,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 202 for valid Atom subscribe request", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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}/atom/feed1`,
|
|
"hub.callback": "https://cb.example/sub",
|
|
}),
|
|
env,
|
|
);
|
|
expect(res.status).toBe(202);
|
|
});
|
|
|
|
it("returns 404 for Atom topic when feed does not exist", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
const res = await app.request(
|
|
"/hub",
|
|
hubBody({
|
|
"hub.mode": "subscribe",
|
|
"hub.topic": `https://${env.DOMAIN}/atom/nonexistent`,
|
|
"hub.callback": "https://cb.example/sub",
|
|
}),
|
|
env,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("POST /hub — unsubscribe", () => {
|
|
it("returns 202 for valid RSS unsubscribe request", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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);
|
|
});
|
|
|
|
it("returns 202 for valid Atom unsubscribe request", async () => {
|
|
const app = makeApp();
|
|
const env = createMockEnv();
|
|
await env.EMAIL_STORAGE.put(
|
|
"feed:feed1:config",
|
|
JSON.stringify({ title: "Feed 1" }),
|
|
);
|
|
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}/atom/feed1`,
|
|
"hub.callback": "https://cb.example/sub",
|
|
}),
|
|
env,
|
|
);
|
|
expect(res.status).toBe(202);
|
|
});
|
|
});
|