mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
3368b0d1d2
Wrap the "Create New Feed" form in a native <details> accordion, collapsed by default and auto-opened when no feeds exist. After creating a feed, redirect to the "Your Feeds" anchor so the new feed is immediately visible. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
993 lines
32 KiB
TypeScript
993 lines
32 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { http, HttpResponse } from "msw";
|
|
import { Hono } from "hono";
|
|
import app from "./admin";
|
|
import { createMockEnv, server } from "../test/setup";
|
|
import { getCounters } from "../utils/stats";
|
|
import { Env } from "../types";
|
|
|
|
describe("Admin Routes", () => {
|
|
let testApp: Hono;
|
|
let mockEnv: Env;
|
|
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
|
let loginAndGetCookie: () => Promise<string>;
|
|
|
|
beforeEach(() => {
|
|
mockEnv = createMockEnv() as unknown as Env;
|
|
testApp = new Hono();
|
|
testApp.route("/admin", app);
|
|
request = (path, init = {}) =>
|
|
Promise.resolve(testApp.request(path, init, mockEnv));
|
|
loginAndGetCookie = async () => {
|
|
const formData = new FormData();
|
|
formData.append("password", "test-password");
|
|
const response = await request("/admin/login", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
expect(response.status).toBe(302);
|
|
const setCookie = response.headers.get("Set-Cookie");
|
|
expect(setCookie).toBeTruthy();
|
|
return (setCookie as string).split(";")[0];
|
|
};
|
|
});
|
|
|
|
describe("Authentication", () => {
|
|
it("should redirect to login page when not authenticated", async () => {
|
|
const res = await request("/admin");
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
|
|
it("should allow access to login page without authentication", async () => {
|
|
const res = await request("/admin/login");
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("Content-Type")).toContain("text/html");
|
|
});
|
|
|
|
it("should set auth cookie and redirect on successful login", async () => {
|
|
const formData = new FormData();
|
|
formData.append("password", "test-password");
|
|
|
|
const res = await request("/admin/login", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin");
|
|
const cookie = res.headers.get("Set-Cookie");
|
|
expect(cookie).toContain("admin_auth=");
|
|
expect(cookie).toContain("HttpOnly");
|
|
expect(cookie).toContain("SameSite=Strict");
|
|
expect(cookie).toContain("Secure");
|
|
expect(cookie).toContain("Path=/");
|
|
});
|
|
|
|
it("should reject login with incorrect password", async () => {
|
|
const formData = new FormData();
|
|
formData.append("password", "wrong-password");
|
|
|
|
const res = await request("/admin/login", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
|
|
});
|
|
|
|
it("should reject login with missing password", async () => {
|
|
const formData = new FormData();
|
|
|
|
const res = await request("/admin/login", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
|
|
});
|
|
});
|
|
|
|
describe("Protected Routes", () => {
|
|
it("should allow access to dashboard with valid auth cookie", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const res = await request("/admin", {
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("Content-Type")).toContain("text/html");
|
|
});
|
|
|
|
it("should reject access with forged auth cookie", async () => {
|
|
const res = await request("/admin", {
|
|
headers: {
|
|
Cookie: "admin_auth=true",
|
|
},
|
|
});
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
|
|
describe("Feed Creation", () => {
|
|
it("should prevent feed creation without authentication", async () => {
|
|
const formData = new FormData();
|
|
formData.append("title", "Test Feed");
|
|
formData.append("description", "Test Description");
|
|
|
|
const res = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
|
|
// Verify no feed was created
|
|
const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
|
|
expect(feedList).toBeNull();
|
|
});
|
|
|
|
it("should allow feed creation with valid authentication", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "Test Feed");
|
|
formData.append("description", "Test Description");
|
|
|
|
const res = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(302); // Redirects back to dashboard
|
|
expect(res.headers.get("Location")).toBe("/admin?view=list#your-feeds");
|
|
|
|
// Verify feed was created in KV
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
expect(feedList).toBeTruthy();
|
|
expect(feedList?.feeds.length).toBe(1);
|
|
expect(feedList?.feeds[0].title).toBe("Test Feed");
|
|
|
|
// Verify feed config was created
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
const feedConfig = await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
);
|
|
expect(feedConfig).toBeTruthy();
|
|
expect((feedConfig as any).title).toBe("Test Feed");
|
|
expect((feedConfig as any).description).toBe("Test Description");
|
|
});
|
|
|
|
it("should reject feed creation with missing title", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("description", "Test Description");
|
|
|
|
const res = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
|
|
// Verify no feed was created
|
|
const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
|
|
expect(feedList).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("API Feed Update", () => {
|
|
it("returns 400 with structured validation error for empty title", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const res = await request("/admin/api/feeds/test-feed/update", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
"Content-Type": "application/json",
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: JSON.stringify({ title: "", description: "desc" }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json<{ success: boolean }>();
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Feed Management", () => {
|
|
it("should prevent feed deletion without authentication", async () => {
|
|
const res = await request("/admin/feeds/test-feed/delete", {
|
|
method: "POST",
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
|
|
it("should prevent API feed updates without authentication", async () => {
|
|
const res = await request("/admin/api/feeds/test-feed/update", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title: "Updated Title",
|
|
description: "Updated Description",
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
|
|
it("should allow feed deletion with valid authentication", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
// First create a feed
|
|
const formData = new FormData();
|
|
formData.append("title", "Test Feed");
|
|
formData.append("description", "Test Description");
|
|
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
expect(createRes.status).toBe(302);
|
|
|
|
// Get the feed ID
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
|
|
// Now delete it
|
|
const deleteRes = await request(`/admin/feeds/${feedId}/delete`, {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
});
|
|
|
|
expect(deleteRes.status).toBe(302);
|
|
expect(deleteRes.headers.get("Location")).toBe("/admin?view=list");
|
|
|
|
// Verify feed was deleted
|
|
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
expect(updatedFeedList).toBeTruthy();
|
|
expect(updatedFeedList?.feeds.length).toBe(0);
|
|
|
|
// Verify feed config was deleted
|
|
const feedConfig = await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
);
|
|
expect(feedConfig).toBeNull();
|
|
});
|
|
|
|
it("should return JSON for feed deletion when requested", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "JSON Feed");
|
|
formData.append("description", "Test Description");
|
|
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
expect(createRes.status).toBe(302);
|
|
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
|
|
const deleteRes = await request(
|
|
`/admin/feeds/${feedId}/delete?view=list`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Accept: "application/json",
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(deleteRes.status).toBe(200);
|
|
const payload = (await deleteRes.json()) as any;
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.feedId).toBe(feedId);
|
|
});
|
|
|
|
it("fires one-click unsubscribe requests on feed deletion and bumps the counter", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "Unsub Feed");
|
|
|
|
await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as {
|
|
feeds: Array<{ id: string }>;
|
|
} | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
|
|
// Simulate an ingested email having captured an unsubscribe URL.
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
`feed:${feedId}:metadata`,
|
|
JSON.stringify({
|
|
emails: [],
|
|
unsubscribe: { "news@example.com": "https://example.com/u/1" },
|
|
}),
|
|
);
|
|
|
|
let unsubHit = false;
|
|
server.use(
|
|
http.post("https://example.com/u/1", () => {
|
|
unsubHit = true;
|
|
return HttpResponse.text("ok");
|
|
}),
|
|
);
|
|
|
|
const pending: Promise<unknown>[] = [];
|
|
const ctx = {
|
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
|
passThroughOnException: () => {},
|
|
} as unknown as ExecutionContext;
|
|
|
|
const deleteRes = await testApp.request(
|
|
`/admin/feeds/${feedId}/delete`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
},
|
|
mockEnv,
|
|
ctx,
|
|
);
|
|
expect(deleteRes.status).toBe(302);
|
|
|
|
await Promise.all(pending);
|
|
|
|
expect(unsubHit).toBe(true);
|
|
const counters = await getCounters(mockEnv.EMAIL_STORAGE);
|
|
expect(counters.unsubscribes_sent).toBe(1);
|
|
});
|
|
|
|
it("sends no unsubscribe requests when the feed has none", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "No Unsub Feed");
|
|
|
|
await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as {
|
|
feeds: Array<{ id: string }>;
|
|
} | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
|
|
const pending: Promise<unknown>[] = [];
|
|
const ctx = {
|
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
|
passThroughOnException: () => {},
|
|
} as unknown as ExecutionContext;
|
|
|
|
await testApp.request(
|
|
`/admin/feeds/${feedId}/delete`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
},
|
|
mockEnv,
|
|
ctx,
|
|
);
|
|
|
|
await Promise.all(pending);
|
|
|
|
const counters = await getCounters(mockEnv.EMAIL_STORAGE);
|
|
expect(counters.unsubscribes_sent).toBe(0);
|
|
});
|
|
|
|
it("should allow bulk feed deletion with valid authentication", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
|
|
for (const title of ["Feed A", "Feed B"]) {
|
|
const formData = new FormData();
|
|
formData.append("title", title);
|
|
formData.append("description", "Test");
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
expect(createRes.status).toBe(302);
|
|
}
|
|
|
|
const feedListBefore = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as {
|
|
feeds: Array<{ id: string; title: string }>;
|
|
} | null;
|
|
expect(feedListBefore?.feeds.length).toBe(2);
|
|
|
|
const bulkForm = new FormData();
|
|
for (const feed of feedListBefore?.feeds || []) {
|
|
bulkForm.append("feedIds", feed.id);
|
|
}
|
|
|
|
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
|
method: "POST",
|
|
headers: { Cookie: authCookie, Origin: "https://test.getmynews.app" },
|
|
body: bulkForm,
|
|
});
|
|
|
|
expect(bulkDeleteRes.status).toBe(302);
|
|
expect(bulkDeleteRes.headers.get("Location")).toContain(
|
|
"/admin?view=list",
|
|
);
|
|
expect(bulkDeleteRes.headers.get("Location")).toContain(
|
|
"message=bulkDeleted",
|
|
);
|
|
|
|
const feedListAfter = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as {
|
|
feeds: Array<{ id: string; title: string }>;
|
|
} | null;
|
|
expect(feedListAfter?.feeds.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Proxy authentication", () => {
|
|
const TRUSTED_IP = "10.0.0.1";
|
|
const PROXY_SECRET = "my-proxy-secret";
|
|
|
|
function proxyEnv() {
|
|
return {
|
|
...createMockEnv(),
|
|
PROXY_TRUSTED_IPS: TRUSTED_IP,
|
|
PROXY_AUTH_SECRET: PROXY_SECRET,
|
|
} as unknown as Env;
|
|
}
|
|
|
|
function makeProxyRequest(
|
|
path: string,
|
|
headers: Record<string, string> = {},
|
|
) {
|
|
const proxyApp = new Hono();
|
|
proxyApp.route("/admin", app);
|
|
return proxyApp.request(path, { headers }, proxyEnv());
|
|
}
|
|
|
|
it("grants access when IP, secret and Remote-User are all valid", async () => {
|
|
const res = await makeProxyRequest("/admin", {
|
|
"CF-Connecting-IP": TRUSTED_IP,
|
|
"X-Auth-Proxy-Secret": PROXY_SECRET,
|
|
"Remote-User": "alice",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("grants access using X-Forwarded-User instead of Remote-User", async () => {
|
|
const res = await makeProxyRequest("/admin", {
|
|
"CF-Connecting-IP": TRUSTED_IP,
|
|
"X-Auth-Proxy-Secret": PROXY_SECRET,
|
|
"X-Forwarded-User": "bob",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects when IP is not in trusted list", async () => {
|
|
const res = await makeProxyRequest("/admin", {
|
|
"CF-Connecting-IP": "1.2.3.4",
|
|
"X-Auth-Proxy-Secret": PROXY_SECRET,
|
|
"Remote-User": "alice",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
});
|
|
|
|
it("rejects when secret is wrong", async () => {
|
|
const res = await makeProxyRequest("/admin", {
|
|
"CF-Connecting-IP": TRUSTED_IP,
|
|
"X-Auth-Proxy-Secret": "wrong-secret",
|
|
"Remote-User": "alice",
|
|
});
|
|
expect(res.status).toBe(302);
|
|
});
|
|
|
|
it("rejects when Remote-User is missing", async () => {
|
|
const res = await makeProxyRequest("/admin", {
|
|
"CF-Connecting-IP": TRUSTED_IP,
|
|
"X-Auth-Proxy-Secret": PROXY_SECRET,
|
|
});
|
|
expect(res.status).toBe(302);
|
|
});
|
|
|
|
it("falls back to cookie auth when proxy env vars are not configured", async () => {
|
|
const res = await request("/admin");
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
|
|
it("falls back to cookie auth when only one proxy env var is configured", async () => {
|
|
const partialProxyApp = new Hono();
|
|
partialProxyApp.route("/admin", app);
|
|
const partialEnv = {
|
|
...createMockEnv(),
|
|
PROXY_TRUSTED_IPS: "10.0.0.1",
|
|
// PROXY_AUTH_SECRET intentionally absent
|
|
} as unknown as Env;
|
|
|
|
const res = await partialProxyApp.request(
|
|
"/admin",
|
|
{
|
|
headers: {
|
|
"CF-Connecting-IP": "10.0.0.1",
|
|
"Remote-User": "alice",
|
|
// No X-Auth-Proxy-Secret — proxy auth should NOT activate
|
|
},
|
|
},
|
|
partialEnv,
|
|
);
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
|
});
|
|
});
|
|
|
|
describe("Email Management", () => {
|
|
it("should return JSON for email deletion when requested", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "Email Feed");
|
|
formData.append("description", "Test Description");
|
|
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
expect(createRes.status).toBe(302);
|
|
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
const emailKey = `feed:${feedId}:emails:123456`;
|
|
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
emailKey,
|
|
JSON.stringify({
|
|
subject: "Hello",
|
|
from: "sender@example.com",
|
|
content: "<p>Hi</p>",
|
|
receivedAt: 123456,
|
|
headers: {},
|
|
}),
|
|
);
|
|
|
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
|
const feedMetadata = (await mockEnv.EMAIL_STORAGE.get(
|
|
feedMetadataKey,
|
|
"json",
|
|
)) as {
|
|
emails: Array<{ key: string; subject: string; receivedAt: number }>;
|
|
} | null;
|
|
const updatedMetadata = {
|
|
emails: [
|
|
...(feedMetadata?.emails || []),
|
|
{ key: emailKey, subject: "Hello", receivedAt: 123456 },
|
|
],
|
|
};
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
feedMetadataKey,
|
|
JSON.stringify(updatedMetadata),
|
|
);
|
|
|
|
const deleteRes = await request(
|
|
`/admin/emails/${emailKey}/delete?feedId=${feedId}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Accept: "application/json",
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(deleteRes.status).toBe(200);
|
|
const payload = (await deleteRes.json()) as any;
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.emailKey).toBe(emailKey);
|
|
|
|
const deletedEmail = await mockEnv.EMAIL_STORAGE.get(emailKey, "json");
|
|
expect(deletedEmail).toBeNull();
|
|
|
|
const metadataAfter = (await mockEnv.EMAIL_STORAGE.get(
|
|
feedMetadataKey,
|
|
"json",
|
|
)) as {
|
|
emails: Array<{ key: string; subject: string; receivedAt: number }>;
|
|
} | null;
|
|
expect(metadataAfter?.emails.length).toBe(0);
|
|
});
|
|
|
|
it("should show a paperclip indicator only for emails with attachments", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const formData = new FormData();
|
|
formData.append("title", "Email Feed");
|
|
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: formData,
|
|
});
|
|
expect(createRes.status).toBe(302);
|
|
|
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
|
"feeds:list",
|
|
"json",
|
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
|
const feedId = feedList?.feeds[0].id as string;
|
|
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
`feed:${feedId}:metadata`,
|
|
JSON.stringify({
|
|
emails: [
|
|
{
|
|
key: `feed:${feedId}:1`,
|
|
subject: "With attachments",
|
|
receivedAt: 2,
|
|
attachmentIds: ["att-1", "att-2"],
|
|
},
|
|
{
|
|
key: `feed:${feedId}:2`,
|
|
subject: "No attachments",
|
|
receivedAt: 1,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const res = await request(`/admin/feeds/${feedId}/emails`, {
|
|
headers: { Cookie: authCookie },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
expect(body).toContain("2 attachments");
|
|
const indicatorCount = (body.match(/attachment-indicator/g) || [])
|
|
.length;
|
|
expect(indicatorCount).toBe(1);
|
|
});
|
|
|
|
it("lists attachments with download links on the email detail page", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const feedId = "detail-feed";
|
|
const emailKey = `feed:${feedId}:1`;
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
emailKey,
|
|
JSON.stringify({
|
|
subject: "With attachments",
|
|
from: "sender@example.com",
|
|
content: "<p>hello</p>",
|
|
receivedAt: 1,
|
|
headers: {},
|
|
attachments: [
|
|
{
|
|
id: "att-123",
|
|
filename: "report final.pdf",
|
|
contentType: "application/pdf",
|
|
size: 2048,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const res = await request(`/admin/emails/${emailKey}`, {
|
|
headers: { Cookie: authCookie },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
expect(body).toContain("Attachments");
|
|
expect(body).toContain(
|
|
`/files/att-123/${encodeURIComponent("report final.pdf")}`,
|
|
);
|
|
expect(body).toContain("report final.pdf");
|
|
expect(body).toContain("2.0 KB");
|
|
});
|
|
|
|
it("does not render an attachments section when the email has none", async () => {
|
|
const authCookie = await loginAndGetCookie();
|
|
const feedId = "detail-feed";
|
|
const emailKey = `feed:${feedId}:2`;
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
emailKey,
|
|
JSON.stringify({
|
|
subject: "No attachments",
|
|
from: "sender@example.com",
|
|
content: "<p>hello</p>",
|
|
receivedAt: 2,
|
|
headers: {},
|
|
}),
|
|
);
|
|
|
|
const res = await request(`/admin/emails/${emailKey}`, {
|
|
headers: { Cookie: authCookie },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
expect(body).not.toContain("Attachments");
|
|
});
|
|
|
|
it("form-based bulk-delete also removes R2 attachments", async () => {
|
|
const r2Env = createMockEnv({ withR2: true }) as unknown as Env;
|
|
const bucket = r2Env.ATTACHMENT_BUCKET as unknown as {
|
|
put: (k: string, v: string) => Promise<void>;
|
|
_has: (k: string) => boolean;
|
|
};
|
|
await bucket.put("att-1", "data1");
|
|
await bucket.put("att-2", "data2");
|
|
|
|
const loginForm = new FormData();
|
|
loginForm.append("password", "test-password");
|
|
const loginRes = await testApp.request(
|
|
"/admin/login",
|
|
{ method: "POST", body: loginForm },
|
|
r2Env,
|
|
);
|
|
const authCookie = (loginRes.headers.get("Set-Cookie") as string).split(
|
|
";",
|
|
)[0];
|
|
|
|
const feedId = "bulk-r2-feed";
|
|
await r2Env.EMAIL_STORAGE.put(
|
|
"feeds:list",
|
|
JSON.stringify({ feeds: [{ id: feedId, title: "F" }] }),
|
|
);
|
|
const emailKey = `feed:${feedId}:1`;
|
|
await r2Env.EMAIL_STORAGE.put(
|
|
emailKey,
|
|
JSON.stringify({
|
|
subject: "x",
|
|
from: "a@b.c",
|
|
content: "<p>x</p>",
|
|
receivedAt: 1,
|
|
headers: {},
|
|
attachments: [{ id: "att-1" }, { id: "att-2" }],
|
|
}),
|
|
);
|
|
await r2Env.EMAIL_STORAGE.put(
|
|
`feed:${feedId}:metadata`,
|
|
JSON.stringify({
|
|
emails: [
|
|
{
|
|
key: emailKey,
|
|
subject: "x",
|
|
receivedAt: 1,
|
|
attachmentIds: ["att-1", "att-2"],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const form = new FormData();
|
|
form.append("emailKeys", emailKey);
|
|
const res = await testApp.request(
|
|
`/admin/feeds/${feedId}/emails/bulk-delete`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: form,
|
|
},
|
|
r2Env,
|
|
);
|
|
|
|
expect(res.status).toBe(302);
|
|
expect(res.headers.get("Location")).toContain("bulkDeleted");
|
|
expect(await r2Env.EMAIL_STORAGE.get(emailKey, "json")).toBeNull();
|
|
expect(bucket._has("att-1")).toBe(false);
|
|
expect(bucket._has("att-2")).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Sender filter", () => {
|
|
let authCookie: string;
|
|
let feedId: string;
|
|
|
|
beforeEach(async () => {
|
|
authCookie = await loginAndGetCookie();
|
|
const createRes = await request("/admin/feeds/create", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: authCookie,
|
|
"Content-Type": "application/json",
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: JSON.stringify({ title: "Filter Test Feed" }),
|
|
});
|
|
const payload = (await createRes.json()) as { feedId: string };
|
|
feedId = payload.feedId;
|
|
});
|
|
|
|
const post = (body: object, cookie = authCookie) =>
|
|
request(`/admin/feeds/${feedId}/sender-filter`, {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: cookie,
|
|
"Content-Type": "application/json",
|
|
Origin: "https://test.getmynews.app",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
it("adds exact email to allowlist", async () => {
|
|
const res = await post({
|
|
action: "allow_sender",
|
|
value: "alice@example.com",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(((await res.json()) as any).ok).toBe(true);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(cfg.allowed_senders).toContain("alice@example.com");
|
|
});
|
|
|
|
it("adds domain to allowlist", async () => {
|
|
const res = await post({ action: "allow_domain", value: "example.com" });
|
|
expect(res.status).toBe(200);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(cfg.allowed_senders).toContain("example.com");
|
|
});
|
|
|
|
it("adds exact email to blocklist", async () => {
|
|
const res = await post({ action: "block_sender", value: "spam@bad.com" });
|
|
expect(res.status).toBe(200);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(cfg.blocked_senders).toContain("spam@bad.com");
|
|
});
|
|
|
|
it("adds domain to blocklist", async () => {
|
|
const res = await post({ action: "block_domain", value: "bad.com" });
|
|
expect(res.status).toBe(200);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(cfg.blocked_senders).toContain("bad.com");
|
|
});
|
|
|
|
it("returns 409 when value already exists in the opposite list", async () => {
|
|
await post({ action: "block_sender", value: "alice@example.com" });
|
|
const res = await post({
|
|
action: "allow_sender",
|
|
value: "alice@example.com",
|
|
});
|
|
expect(res.status).toBe(409);
|
|
const data = (await res.json()) as any;
|
|
expect(data.ok).toBe(false);
|
|
expect(data.error).toMatch(/blocklist/);
|
|
});
|
|
|
|
it("is idempotent when value already in target list", async () => {
|
|
await post({ action: "allow_sender", value: "alice@example.com" });
|
|
const res = await post({
|
|
action: "allow_sender",
|
|
value: "alice@example.com",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(
|
|
cfg.allowed_senders.filter((s: string) => s === "alice@example.com")
|
|
.length,
|
|
).toBe(1);
|
|
});
|
|
|
|
it("returns 400 for invalid action", async () => {
|
|
const res = await post({
|
|
action: "invalid_action",
|
|
value: "alice@example.com",
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("normalizes value to lowercase", async () => {
|
|
const res = await post({
|
|
action: "allow_sender",
|
|
value: "Alice@Example.COM",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const cfg = (await mockEnv.EMAIL_STORAGE.get(
|
|
`feed:${feedId}:config`,
|
|
"json",
|
|
)) as any;
|
|
expect(cfg.allowed_senders).toContain("alice@example.com");
|
|
});
|
|
});
|
|
});
|