mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email list/get/delete, stats) so the service can be automated without scraping the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at /api/openapi.json with a Scalar reference at /api/docs. Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth primitives into src/lib/auth.ts and the feed create/update/delete orchestration into src/lib/feed-service.ts so the admin UI and the REST API share a single source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { apiApp } from "./index";
|
||||
import { createMockEnv } from "../../test/setup";
|
||||
import { Env } from "../../types";
|
||||
|
||||
const PASSWORD = "test-password";
|
||||
const authHeaders = { Authorization: `Bearer ${PASSWORD}` };
|
||||
|
||||
describe("REST API (/api/v1)", () => {
|
||||
let testApp: Hono;
|
||||
let mockEnv: Env;
|
||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
testApp.route("/api", apiApp);
|
||||
request = (path, init = {}) =>
|
||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
});
|
||||
|
||||
async function createFeed(title = "Test Feed"): Promise<string> {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { id: string };
|
||||
return body.id;
|
||||
}
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("rejects requests without a token", async () => {
|
||||
const res = await request("/api/v1/feeds");
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()) as { error: string }).toEqual({
|
||||
error: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects requests with a wrong token", async () => {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a valid Bearer token", async () => {
|
||||
const res = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts proxy auth headers", async () => {
|
||||
const proxyApp = new Hono();
|
||||
proxyApp.route("/api", apiApp);
|
||||
const proxyEnv = {
|
||||
...createMockEnv(),
|
||||
PROXY_TRUSTED_IPS: "10.0.0.1",
|
||||
PROXY_AUTH_SECRET: "proxy-secret",
|
||||
} as unknown as Env;
|
||||
const res = await proxyApp.request(
|
||||
"/api/v1/feeds",
|
||||
{
|
||||
headers: {
|
||||
"CF-Connecting-IP": "10.0.0.1",
|
||||
"X-Auth-Proxy-Secret": "proxy-secret",
|
||||
"Remote-User": "alice",
|
||||
},
|
||||
},
|
||||
proxyEnv,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feeds CRUD", () => {
|
||||
it("creates, reads, lists, updates and deletes a feed", async () => {
|
||||
// Create
|
||||
const createRes = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Daily Digest",
|
||||
description: "news",
|
||||
allowedSenders: ["News@Example.com"],
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const created = (await createRes.json()) as {
|
||||
id: string;
|
||||
title: string;
|
||||
allowedSenders: string[];
|
||||
emailAddress: string;
|
||||
rssUrl: string;
|
||||
atomUrl: string;
|
||||
emailCount: number;
|
||||
};
|
||||
expect(created.title).toBe("Daily Digest");
|
||||
// senders are normalized to lowercase
|
||||
expect(created.allowedSenders).toEqual(["news@example.com"]);
|
||||
expect(created.emailCount).toBe(0);
|
||||
expect(created.rssUrl).toContain(`/rss/${created.id}`);
|
||||
|
||||
// Get
|
||||
const getRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((await getRes.json()) as { id: string }).toMatchObject({
|
||||
id: created.id,
|
||||
title: "Daily Digest",
|
||||
});
|
||||
|
||||
// List
|
||||
const listRes = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
const list = (await listRes.json()) as { feeds: { id: string }[] };
|
||||
expect(list.feeds.map((f) => f.id)).toContain(created.id);
|
||||
|
||||
// Update
|
||||
const patchRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Renamed" }),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect((await patchRes.json()) as { title: string }).toMatchObject({
|
||||
title: "Renamed",
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(delRes.status).toBe(200);
|
||||
expect((await delRes.json()) as { ok: boolean }).toEqual({ ok: true });
|
||||
|
||||
// Gone from the list
|
||||
const after = await request("/api/v1/feeds", { headers: authHeaders });
|
||||
const afterList = (await after.json()) as { feeds: { id: string }[] };
|
||||
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid create body", async () => {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()) as { error: string }).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns 404 when getting a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when deleting a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
method: "DELETE",
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when updating a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "x" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Emails", () => {
|
||||
it("lists, reads and deletes an email", async () => {
|
||||
const feedId = await createFeed();
|
||||
|
||||
// Seed an email directly into KV (mirrors storeEmail's key shape).
|
||||
const receivedAt = 1737000000000;
|
||||
const key = `feed:${feedId}:email:${receivedAt}`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
key,
|
||||
JSON.stringify({
|
||||
subject: "Hello",
|
||||
from: "news@example.com",
|
||||
content: "<p>hi</p>",
|
||||
receivedAt,
|
||||
headers: {},
|
||||
}),
|
||||
);
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [{ key, subject: "Hello", receivedAt }],
|
||||
}),
|
||||
);
|
||||
|
||||
// List
|
||||
const listRes = await request(`/api/v1/feeds/${feedId}/emails`, {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(listRes.status).toBe(200);
|
||||
const list = (await listRes.json()) as {
|
||||
emails: { entryId: number; subject: string }[];
|
||||
};
|
||||
expect(list.emails).toHaveLength(1);
|
||||
expect(list.emails[0]).toMatchObject({
|
||||
entryId: receivedAt,
|
||||
subject: "Hello",
|
||||
});
|
||||
|
||||
// Get single
|
||||
const getRes = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((await getRes.json()) as { content: string }).toMatchObject({
|
||||
from: "news@example.com",
|
||||
content: "<p>hi</p>",
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ method: "DELETE", headers: authHeaders },
|
||||
);
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(await mockEnv.EMAIL_STORAGE.get(key)).toBeNull();
|
||||
|
||||
// Gone
|
||||
const after = await request(
|
||||
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||
{ headers: authHeaders },
|
||||
);
|
||||
expect(after.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 listing emails for a missing feed", async () => {
|
||||
const res = await request("/api/v1/feeds/missing/emails", {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stats", () => {
|
||||
it("returns monitoring counters", async () => {
|
||||
await createFeed();
|
||||
const res = await request("/api/v1/stats", { headers: authHeaders });
|
||||
expect(res.status).toBe(200);
|
||||
const stats = (await res.json()) as {
|
||||
feeds_created: number;
|
||||
active_feeds: number;
|
||||
attachments_enabled: boolean;
|
||||
};
|
||||
expect(stats.feeds_created).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.active_feeds).toBeGreaterThanOrEqual(1);
|
||||
expect(typeof stats.attachments_enabled).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAPI document", () => {
|
||||
it("serves a public OpenAPI 3.1 spec", async () => {
|
||||
const res = await request("/api/openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
const doc = (await res.json()) as {
|
||||
openapi: string;
|
||||
paths: Record<string, unknown>;
|
||||
};
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
expect(doc.paths).toHaveProperty("/v1/feeds");
|
||||
expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}");
|
||||
expect(doc.paths).toHaveProperty("/v1/stats");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user