mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
Testing
This commit is contained in:
+8
-1
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user