This commit is contained in:
Young Lee
2026-02-05 22:18:29 -08:00
parent 610823cb96
commit 6e546d31a0
6 changed files with 483 additions and 10 deletions
+8 -1
View File
@@ -7,7 +7,10 @@
"build": "wrangler deploy --dry-run --outdir=dist",
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
"dev": "wrangler dev",
"deploy": "wrangler deploy --env production"
"deploy": "wrangler deploy --env production",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"author": "",
"license": "MIT",
@@ -15,8 +18,12 @@
"@cloudflare/workers-types": "^4.20250224.0",
"@types/mailparser": "^3.4.5",
"@types/rss": "^0.0.32",
"@vitest/coverage-v8": "^1.3.1",
"happy-dom": "^13.3.8",
"msw": "^2.2.1",
"prettier": "^3.5.2",
"typescript": "^5.7.3",
"vitest": "^1.3.1",
"wrangler": "^3.111.0"
},
"dependencies": {
+21 -7
View File
@@ -96,8 +96,13 @@ app.use('*', async (c, next) => {
await next();
});
// Webhook security middleware for /api/inbound - verify ForwardEmail.net IP
app.use('/api/inbound', async (c, next) => {
// Group routes by functionality
const api = new Hono();
const rss = new Hono();
const admin = new Hono();
// Webhook security middleware for /inbound - verify ForwardEmail.net IP
api.use('/inbound', async (c, next) => {
// Get the client IP
const clientIP = c.req.header('CF-Connecting-IP') || // Cloudflare-specific header
c.req.header('X-Forwarded-For')?.split(',')[0].trim() ||
@@ -113,14 +118,23 @@ app.use('/api/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();
});
// Route handlers
app.post('/api/inbound', handleInbound);
app.get('/rss/:feedId', handleRSS);
app.route('/admin', handleAdmin);
// API routes (inbound webhook)
api.post('/inbound', handleInbound);
// RSS feed routes (public)
rss.get('/:feedId', handleRSS);
// Admin routes (protected)
admin.route('/', handleAdmin);
// Mount the route groups
app.route('/api', api);
app.route('/rss', rss);
app.route('/admin', admin);
// Root path redirects to admin dashboard
app.get('/', (c) => c.redirect('/admin'));
+238
View File
@@ -0,0 +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';
describe('Admin Routes', () => {
let testApp: Hono;
let mockEnv: Env;
beforeEach(() => {
mockEnv = createMockEnv();
testApp = new Hono();
testApp.route('/admin', app);
});
describe('Authentication', () => {
it('should redirect to login page when not authenticated', async () => {
const res = await testApp.request('/admin', {
env: mockEnv
});
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 testApp.request('/admin/login', {
env: mockEnv
});
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 testApp.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=/');
});
it('should reject login with incorrect password', async () => {
const formData = new FormData();
formData.append('password', 'wrong-password');
const res = await testApp.request('/admin/login', {
method: 'POST',
body: formData,
env: mockEnv
});
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 testApp.request('/admin/login', {
method: 'POST',
body: formData,
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login?error=invalid');
});
});
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', {
headers: {
Cookie: authCookie
},
env: mockEnv
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('text/html');
});
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 testApp.request('/admin/feeds/create', {
method: 'POST',
body: formData,
env: mockEnv
});
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', 'json');
expect(feedList).toBeNull();
});
it('should allow feed creation with valid authentication', async () => {
const formData = new FormData();
formData.append('title', 'Test Feed');
formData.append('description', 'Test Description');
const res = await testApp.request('/admin/feeds/create', {
method: 'POST',
headers: {
Cookie: authCookie
},
body: formData,
env: mockEnv
});
expect(res.status).toBe(302); // Redirects back to dashboard
expect(res.headers.get('Location')).toBe('/admin');
// Verify feed was created in KV
const feedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
expect(feedList).toBeTruthy();
expect(feedList.length).toBe(1);
expect(feedList[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');
expect(feedConfig).toBeTruthy();
expect(feedConfig.title).toBe('Test Feed');
expect(feedConfig.description).toBe('Test Description');
});
it('should reject feed creation with missing title', async () => {
const formData = new FormData();
formData.append('description', 'Test Description');
const res = await testApp.request('/admin/feeds/create', {
method: 'POST',
headers: {
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');
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
});
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 testApp.request('/admin/api/feeds/test-feed/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Updated Title',
description: 'Updated Description'
}),
env: mockEnv
});
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
});
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');
const createRes = await testApp.request('/admin/feeds/create', {
method: 'POST',
headers: {
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;
// Now delete it
const deleteRes = await testApp.request(`/admin/feeds/${feedId}/delete`, {
method: 'POST',
headers: {
Cookie: authCookie
},
env: mockEnv
});
expect(deleteRes.status).toBe(302);
expect(deleteRes.headers.get('Location')).toBe('/admin');
// Verify feed was deleted
const updatedFeedList = await mockEnv.EMAIL_STORAGE.get('feeds', 'json');
expect(updatedFeedList).toBeTruthy();
expect(updatedFeedList.length).toBe(0);
// Verify feed config was deleted
const feedConfig = await mockEnv.EMAIL_STORAGE.get(`feed:${feedId}:config`, 'json');
expect(feedConfig).toBeNull();
});
});
});
});
+32 -2
View File
@@ -1,4 +1,4 @@
import { Hono } from 'hono';
import { Context, Hono } from 'hono';
import { html, raw } from 'hono/html';
import { z } from 'zod';
import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types';
@@ -6,9 +6,39 @@ import { generateFeedId } from '../utils/id-generator';
import { designSystem } from '../styles/index';
import { interactiveScripts, authHelpers } from '../scripts/index';
// Create a Hono app for admin routes
/**
* Admin routes handler for Email-to-RSS
* Provides a secure interface for managing RSS feeds and viewing emails
*
* Security:
* - All routes except /login are protected by server-side cookie authentication
* - Uses HttpOnly cookies to prevent XSS attacks
* - Implements SameSite=Strict to prevent CSRF attacks
*/
const app = new Hono();
// Export for testing
export default app;
// Authentication middleware for admin routes
async function authMiddleware(c: Context, next: () => Promise<void>) {
const path = new URL(c.req.url).pathname;
// Skip auth check for login page - note that path includes /admin prefix
if (path === '/admin/login') {
return next();
}
const authCookie = c.req.cookie('admin_auth');
if (!authCookie || authCookie !== 'true') {
return c.redirect('/admin/login');
}
await next();
}
// Apply auth middleware to all admin routes
app.use('*', authMiddleware);
// Schema for feed creation
const createFeedSchema = z.object({
title: z.string().min(1, 'Title is required'),
+150
View File
@@ -0,0 +1,150 @@
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
/**
* Mock implementation of Cloudflare Workers runtime environment
* Based on: https://developers.cloudflare.com/workers/testing/
*/
// Define Cloudflare Workers runtime globals
declare global {
// CF Worker specific globals
var caches: CacheStorage;
var crypto: Crypto;
var Response: typeof Response;
var Request: typeof Request;
var URLSearchParams: typeof URLSearchParams;
var URL: typeof URL;
var Headers: typeof Headers;
var FormData: typeof FormData;
var Blob: typeof Blob;
var atob: (data: string) => string;
var btoa: (data: string) => string;
}
/**
* Mock KV namespace implementation
* Simulates Cloudflare Workers KV storage using an in-memory Map
*/
class MockKV {
private store: Map<string, any> = new Map();
async get(key: string, type: 'text' | 'json' | 'arrayBuffer' | 'stream' = 'text') {
const value = this.store.get(key);
if (!value) return null;
return type === 'json' ? JSON.parse(value) : value;
}
async put(key: string, value: any) {
this.store.set(key, typeof value === 'string' ? value : JSON.stringify(value));
return undefined; // Match CF Workers KV behavior
}
async delete(key: string) {
this.store.delete(key);
return undefined; // Match CF Workers KV behavior
}
async list(options?: { prefix?: string; cursor?: string; limit?: number }) {
const keys = Array.from(this.store.keys())
.filter(key => !options?.prefix || key.startsWith(options.prefix))
.slice(0, options?.limit || undefined)
.map(name => ({ name }));
return {
keys,
list_complete: true,
cursor: ''
};
}
}
/**
* Mock Cache implementation
* Simulates Cloudflare Workers Cache API using an in-memory Map
*/
class MockCache implements Cache {
private store: Map<string, Response> = new Map();
async put(request: RequestInfo, response: Response): Promise<undefined> {
const key = request instanceof Request ? request.url : request;
this.store.set(key, response.clone());
return 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> {
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 []; }
}
// Create MSW server for mocking external requests
export const server = setupServer();
// Setup before tests
beforeAll(() => {
// Setup MSW server
server.listen({ onUnhandledRequest: 'error' });
// Mock Cloudflare Workers runtime globals
global.caches = {
default: new MockCache(),
open: async () => new MockCache()
} as unknown as CacheStorage;
// Mock crypto for generating random values
if (!global.crypto) {
global.crypto = require('crypto').webcrypto;
}
// Ensure other required globals are available
if (!global.FormData) {
const { FormData } = require('undici');
global.FormData = FormData;
}
if (!global.Headers) {
const { Headers } = require('undici');
global.Headers = Headers;
}
if (!global.Request) {
const { Request } = require('undici');
global.Request = Request;
}
if (!global.Response) {
const { Response } = require('undici');
global.Response = Response;
}
});
// Clean up after tests
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
});
/**
* Create a mock environment for testing
* @returns Mock environment with KV storage and configuration
*/
export const createMockEnv = () => ({
EMAIL_STORAGE: new MockKV(),
DOMAIN: 'test.getmynews.app',
ADMIN_PASSWORD: 'test-password',
});
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Use happy-dom for browser API simulation
environment: 'happy-dom',
// Include source files for coverage
include: ['src/**/*.{test,spec}.{js,ts}'],
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.{test,spec}.ts',
'src/types/**',
'src/scripts/**',
'src/styles/**'
]
},
// Global setup files
setupFiles: ['src/test/setup.ts'],
// Mock Cloudflare Workers runtime
globals: true,
// Timeouts
testTimeout: 10000,
hookTimeout: 10000
}
});