chore: modernize setup, dependencies, and project docs

This commit is contained in:
Young Lee
2026-02-05 22:34:13 -08:00
parent 6e546d31a0
commit daf54a0fc0
10 changed files with 476 additions and 420 deletions
+2 -2
View File
@@ -118,7 +118,7 @@ api.use('/inbound', async (c, next) => {
return c.text('Unauthorized', 401);
}
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP}`);
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`);
await next();
});
@@ -143,4 +143,4 @@ app.get('/', (c) => c.redirect('/admin'));
app.all('*', (c) => c.text('Not Found', 404));
// Export the worker handler
export default app;
export default app;
+110 -108
View File
@@ -1,236 +1,238 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Hono } from 'hono';
import app from './admin';
import { createMockEnv } from '../test/setup';
import { Env } from '../types';
import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from "hono";
import app from "./admin";
import { createMockEnv } from "../test/setup";
import { Env } from "../types";
describe('Admin Routes', () => {
describe("Admin Routes", () => {
let testApp: Hono;
let mockEnv: Env;
let request: (path: string, init?: RequestInit) => Promise<Response>;
beforeEach(() => {
mockEnv = createMockEnv();
testApp = new Hono();
testApp.route('/admin', app);
testApp.route("/admin", app);
request = (path, init = {}) => testApp.request(path, init, mockEnv);
});
describe('Authentication', () => {
it('should redirect to login page when not authenticated', async () => {
const res = await testApp.request('/admin', {
env: mockEnv
});
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');
expect(res.headers.get("Location")).toBe("/admin/login");
});
it('should allow access to login page without authentication', async () => {
const res = await testApp.request('/admin/login', {
env: mockEnv
});
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');
expect(res.headers.get("Content-Type")).toContain("text/html");
});
it('should set auth cookie and redirect on successful login', async () => {
it("should set auth cookie and redirect on successful login", async () => {
const formData = new FormData();
formData.append('password', 'test-password');
formData.append("password", "test-password");
const res = await testApp.request('/admin/login', {
method: 'POST',
const res = await request("/admin/login", {
method: "POST",
body: formData,
env: mockEnv
});
expect(res.status).toBe(200);
const cookie = res.headers.get('Set-Cookie');
expect(cookie).toContain('admin_auth=true');
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).toContain('Path=/');
const cookie = res.headers.get("Set-Cookie");
expect(cookie).toContain("admin_auth=true");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Strict");
expect(cookie).toContain("Path=/");
});
it('should reject login with incorrect password', async () => {
it("should reject login with incorrect password", async () => {
const formData = new FormData();
formData.append('password', 'wrong-password');
formData.append("password", "wrong-password");
const res = await testApp.request('/admin/login', {
method: 'POST',
const res = await request("/admin/login", {
method: "POST",
body: formData,
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login?error=invalid');
expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
});
it('should reject login with missing password', async () => {
it("should reject login with missing password", async () => {
const formData = new FormData();
const res = await testApp.request('/admin/login', {
method: 'POST',
const res = await request("/admin/login", {
method: "POST",
body: formData,
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login?error=invalid');
expect(res.headers.get("Location")).toBe("/admin/login?error=invalid");
});
});
describe('Protected Routes', () => {
const authCookie = 'admin_auth=true';
describe("Protected Routes", () => {
const authCookie = "admin_auth=true";
it('should allow access to dashboard with valid auth cookie', async () => {
const res = await testApp.request('/admin', {
it("should allow access to dashboard with valid auth cookie", async () => {
const res = await request("/admin", {
headers: {
Cookie: authCookie
Cookie: authCookie,
},
env: mockEnv
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('text/html');
expect(res.headers.get("Content-Type")).toContain("text/html");
});
describe('Feed Creation', () => {
it('should prevent feed creation without authentication', async () => {
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');
formData.append("title", "Test Feed");
formData.append("description", "Test Description");
const res = await testApp.request('/admin/feeds/create', {
method: 'POST',
const res = await request("/admin/feeds/create", {
method: "POST",
body: formData,
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
expect(res.headers.get("Location")).toBe("/admin/login");
// Verify no feed was created
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
expect(feedList).toBeNull();
});
it('should allow feed creation with valid authentication', async () => {
it("should allow feed creation with valid authentication", async () => {
const formData = new FormData();
formData.append('title', 'Test Feed');
formData.append('description', 'Test Description');
formData.append("title", "Test Feed");
formData.append("description", "Test Description");
const res = await testApp.request('/admin/feeds/create', {
method: 'POST',
const res = await request("/admin/feeds/create", {
method: "POST",
headers: {
Cookie: authCookie
Cookie: authCookie,
},
body: formData,
env: mockEnv
});
expect(res.status).toBe(302); // Redirects back to dashboard
expect(res.headers.get('Location')).toBe('/admin');
expect(res.headers.get("Location")).toBe("/admin");
// Verify feed was created in KV
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
const feedList = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
expect(feedList).toBeTruthy();
expect(feedList.length).toBe(1);
expect(feedList[0].title).toBe('Test Feed');
expect(feedList?.feeds.length).toBe(1);
expect(feedList?.feeds[0].title).toBe("Test Feed");
// Verify feed config was created
const feedId = feedList[0].id;
const feedConfig = await mockEnv.EMAIL_STORAGE.get(`feed:${feedId}:config`, 'json');
const feedId = feedList?.feeds[0].id as string;
const feedConfig = await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
);
expect(feedConfig).toBeTruthy();
expect(feedConfig.title).toBe('Test Feed');
expect(feedConfig.description).toBe('Test Description');
expect(feedConfig.title).toBe("Test Feed");
expect(feedConfig.description).toBe("Test Description");
});
it('should reject feed creation with missing title', async () => {
it("should reject feed creation with missing title", async () => {
const formData = new FormData();
formData.append('description', 'Test Description');
formData.append("description", "Test Description");
const res = await testApp.request('/admin/feeds/create', {
method: 'POST',
const res = await request("/admin/feeds/create", {
method: "POST",
headers: {
Cookie: authCookie
Cookie: authCookie,
},
body: formData,
env: mockEnv
});
expect(res.status).toBe(400);
// Verify no feed was created
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
const feedList = await mockEnv.EMAIL_STORAGE.get("feeds:list", "json");
expect(feedList).toBeNull();
});
});
describe('Feed Management', () => {
it('should prevent feed deletion without authentication', async () => {
const res = await testApp.request('/admin/feeds/test-feed/delete', {
method: 'POST',
env: mockEnv
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');
expect(res.headers.get("Location")).toBe("/admin/login");
});
it('should prevent API feed updates without authentication', async () => {
const res = await testApp.request('/admin/api/feeds/test-feed/update', {
method: 'POST',
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'
"Content-Type": "application/json",
},
body: JSON.stringify({
title: 'Updated Title',
description: 'Updated Description'
title: "Updated Title",
description: "Updated Description",
}),
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
expect(res.headers.get("Location")).toBe("/admin/login");
});
it('should allow feed deletion with valid authentication', async () => {
it("should allow feed deletion with valid authentication", async () => {
// First create a feed
const formData = new FormData();
formData.append('title', 'Test Feed');
formData.append('description', 'Test Description');
formData.append("title", "Test Feed");
formData.append("description", "Test Description");
const createRes = await testApp.request('/admin/feeds/create', {
method: 'POST',
const createRes = await request("/admin/feeds/create", {
method: "POST",
headers: {
Cookie: authCookie
Cookie: authCookie,
},
body: formData,
env: mockEnv
});
expect(createRes.status).toBe(302);
// Get the feed ID
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
const feedId = feedList[0].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 testApp.request(`/admin/feeds/${feedId}/delete`, {
method: 'POST',
const deleteRes = await request(`/admin/feeds/${feedId}/delete`, {
method: "POST",
headers: {
Cookie: authCookie
Cookie: authCookie,
},
env: mockEnv
});
expect(deleteRes.status).toBe(302);
expect(deleteRes.headers.get('Location')).toBe('/admin');
expect(deleteRes.headers.get("Location")).toBe("/admin");
// Verify feed was deleted
const updatedFeedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
expect(updatedFeedList).toBeTruthy();
expect(updatedFeedList.length).toBe(0);
expect(updatedFeedList?.feeds.length).toBe(0);
// Verify feed config was deleted
const feedConfig = await mockEnv.EMAIL_STORAGE.get(`feed:${feedId}:config`, 'json');
const feedConfig = await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
);
expect(feedConfig).toBeNull();
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { Context, Hono } from 'hono';
import { getCookie } from 'hono/cookie';
import { html, raw } from 'hono/html';
import { z } from 'zod';
import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types';
@@ -28,7 +29,7 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
return next();
}
const authCookie = c.req.cookie('admin_auth');
const authCookie = getCookie(c, 'admin_auth');
if (!authCookie || authCookie !== 'true') {
return c.redirect('/admin/login');
}
@@ -1047,4 +1048,4 @@ app.post('/api/feeds/:feedId/update', async (c) => {
});
// Export the Hono app
export const handle = app;
export const handle = app;
+48 -23
View File
@@ -1,5 +1,5 @@
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node";
/**
* Mock implementation of Cloudflare Workers runtime environment
@@ -29,14 +29,27 @@ declare global {
class MockKV {
private store: Map<string, any> = new Map();
async get(key: string, type: 'text' | 'json' | 'arrayBuffer' | 'stream' = 'text') {
async get(
key: string,
typeOrOptions:
| "text"
| "json"
| "arrayBuffer"
| "stream"
| { type: "text" | "json" | "arrayBuffer" | "stream" } = "text",
) {
const type =
typeof typeOrOptions === "string" ? typeOrOptions : typeOrOptions.type;
const value = this.store.get(key);
if (!value) return null;
return type === 'json' ? JSON.parse(value) : value;
return type === "json" ? JSON.parse(value) : value;
}
async put(key: string, value: any) {
this.store.set(key, typeof value === 'string' ? value : JSON.stringify(value));
this.store.set(
key,
typeof value === "string" ? value : JSON.stringify(value),
);
return undefined; // Match CF Workers KV behavior
}
@@ -47,14 +60,14 @@ class MockKV {
async list(options?: { prefix?: string; cursor?: string; limit?: number }) {
const keys = Array.from(this.store.keys())
.filter(key => !options?.prefix || key.startsWith(options.prefix))
.filter((key) => !options?.prefix || key.startsWith(options.prefix))
.slice(0, options?.limit || undefined)
.map(name => ({ name }));
.map((name) => ({ name }));
return {
keys,
list_complete: true,
cursor: ''
cursor: "",
};
}
}
@@ -72,21 +85,33 @@ class MockCache implements Cache {
return undefined;
}
async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response | undefined> {
async match(
request: RequestInfo,
options?: CacheQueryOptions,
): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request;
const response = this.store.get(key);
return response?.clone();
}
async delete(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> {
async delete(
request: RequestInfo,
options?: CacheQueryOptions,
): Promise<boolean> {
const key = request instanceof Request ? request.url : request;
return this.store.delete(key);
}
// Required Cache interface methods with minimal implementations
async add(): Promise<void> { throw new Error('Not implemented'); }
async addAll(): Promise<void> { throw new Error('Not implemented'); }
async keys(): Promise<Request[]> { return []; }
async add(): Promise<void> {
throw new Error("Not implemented");
}
async addAll(): Promise<void> {
throw new Error("Not implemented");
}
async keys(): Promise<Request[]> {
return [];
}
}
// Create MSW server for mocking external requests
@@ -95,37 +120,37 @@ export const server = setupServer();
// Setup before tests
beforeAll(() => {
// Setup MSW server
server.listen({ onUnhandledRequest: 'error' });
server.listen({ onUnhandledRequest: "error" });
// Mock Cloudflare Workers runtime globals
global.caches = {
default: new MockCache(),
open: async () => new MockCache()
open: async () => new MockCache(),
} as unknown as CacheStorage;
// Mock crypto for generating random values
if (!global.crypto) {
global.crypto = require('crypto').webcrypto;
global.crypto = require("crypto").webcrypto;
}
// Ensure other required globals are available
if (!global.FormData) {
const { FormData } = require('undici');
const { FormData } = require("undici");
global.FormData = FormData;
}
if (!global.Headers) {
const { Headers } = require('undici');
const { Headers } = require("undici");
global.Headers = Headers;
}
if (!global.Request) {
const { Request } = require('undici');
const { Request } = require("undici");
global.Request = Request;
}
if (!global.Response) {
const { Response } = require('undici');
const { Response } = require("undici");
global.Response = Response;
}
});
@@ -145,6 +170,6 @@ afterEach(() => {
*/
export const createMockEnv = () => ({
EMAIL_STORAGE: new MockKV(),
DOMAIN: 'test.getmynews.app',
ADMIN_PASSWORD: 'test-password',
DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: "test-password",
});