From 6e546d31a0da7ca660e19214b4352b8fe17bc429 Mon Sep 17 00:00:00 2001 From: Young Lee <8462583+yl8976@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:18:29 -0800 Subject: [PATCH] Testing --- package.json | 9 +- src/index.ts | 28 +++-- src/routes/admin.test.ts | 238 +++++++++++++++++++++++++++++++++++++++ src/routes/admin.ts | 34 +++++- src/test/setup.ts | 150 ++++++++++++++++++++++++ vitest.config.ts | 34 ++++++ 6 files changed, 483 insertions(+), 10 deletions(-) create mode 100644 src/routes/admin.test.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 0664857..b2be997 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/index.ts b/src/index.ts index 24ce5ff..3f8a530 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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')); diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts new file mode 100644 index 0000000..4232e42 --- /dev/null +++ b/src/routes/admin.test.ts @@ -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(); + }); + }); + }); +}); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 069bff7..6dc301a 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -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) { + 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'), diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..c76b153 --- /dev/null +++ b/src/test/setup.ts @@ -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 = 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 = new Map(); + + async put(request: RequestInfo, response: Response): Promise { + const key = request instanceof Request ? request.url : request; + this.store.set(key, response.clone()); + return undefined; + } + + async match(request: RequestInfo, options?: CacheQueryOptions): Promise { + const key = request instanceof Request ? request.url : request; + const response = this.store.get(key); + return response?.clone(); + } + + async delete(request: RequestInfo, options?: CacheQueryOptions): Promise { + const key = request instanceof Request ? request.url : request; + return this.store.delete(key); + } + + // Required Cache interface methods with minimal implementations + async add(): Promise { throw new Error('Not implemented'); } + async addAll(): Promise { throw new Error('Not implemented'); } + async keys(): Promise { 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', +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..dcbb2b4 --- /dev/null +++ b/vitest.config.ts @@ -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 + } +});