Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
"builder": "@angular/build:unit-test",
"options": {
"exclude": [
"src/worker.spec.ts"
]
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
Expand Down
98 changes: 98 additions & 0 deletions src/worker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import worker, { Env } from './worker';

// Mock the dynamically generated headers text so the test doesn't depend on build artifacts
vi.mock('../dist/app/browser/_headers', () => {
return {
default: `
# Dummy headers for testing
/*
X-Test-Header: WorkerTest
`
};
});

describe('Edge Worker Case Redirection', () => {
let mockFetch: any;
let env: Env;

beforeEach(() => {
mockFetch = vi.fn(async (request: Request | string) => {
const urlStr = typeof request === 'string' ? request : request.url;
const url = new URL(urlStr);
// Mock different behavior based on file types
if (url.pathname.endsWith('.PNG') || url.pathname.endsWith('.png')) {
return new Response('Mock Image content', {
status: 200,
headers: { 'Content-Type': 'image/png' }
});
}
return new Response('Mock Asset Response', {
status: 200,
headers: { 'Content-Type': 'text/html' }
});
});

env = {
ASSETS: {
fetch: mockFetch
}
};
});

it('should redirect capitalized SPA pages to their lowercase equivalents (301)', async () => {
const request = new Request('https://example.com/ABOUT', { method: 'GET' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(301);
expect(response.headers.get('Location')).toBe('https://example.com/about');
});

it('should preserve and append existing query parameters during redirect', async () => {
const request = new Request('https://example.com/Generator?ref=search&utm_source=test', { method: 'GET' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(301);
expect(response.headers.get('Location')).toBe('https://example.com/generator?ref=search&utm_source=test');
});

it('should handle trailing slash redirection while preserving query parameters', async () => {
const request = new Request('https://example.com/Verify/?test=true', { method: 'GET' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(301);
expect(response.headers.get('Location')).toBe('https://example.com/verify/?test=true');
});

it('should bypass redirect for valid lowercase SPA pages and return 200 OK (SPA Fallback)', async () => {
const request = new Request('https://example.com/about', { method: 'GET' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalled();
});

it('should bypass redirect for truly non-existent paths (capitalized or not)', async () => {
const request1 = new Request('https://example.com/Non-Existent-Route', { method: 'GET' });
const response1 = await worker.fetch(request1, env);
expect(response1.status).toBe(200);

const request2 = new Request('https://example.com/non-existent-route', { method: 'GET' });
const response2 = await worker.fetch(request2, env);
expect(response2.status).toBe(200);
});

it('should bypass redirect for static assets and media files (even with uppercase)', async () => {
const request = new Request('https://example.com/assets/LOGO.PNG', { method: 'GET' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(200);
});

it('should only redirect GET and HEAD requests', async () => {
const request = new Request('https://example.com/ABOUT', { method: 'POST' });
const response = await worker.fetch(request, env);

expect(response.status).toBe(200);
});
});
24 changes: 23 additions & 1 deletion src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,31 @@ export default {
return env.ASSETS.fetch(request);
}

const rules = await getRules(request, env);
const isFile = isFileRequest(pathname);

// Redirect capitalized valid SPA pages to lowercase
if (!isFile) {
const lowercasePath = pathname.toLowerCase();
const normalizedPath = lowercasePath.endsWith('/') && lowercasePath.length > 1
? lowercasePath.slice(0, -1)
: lowercasePath;

const validPages = ['/about', '/generator', '/verify', '/exception-report'];

if ((validPages.includes(normalizedPath) || normalizedPath === '/') && /[A-Z]/.test(pathname)) {
const redirectUrl = new URL(request.url);
redirectUrl.pathname = lowercasePath;
return new Response(null, {
status: 301,
headers: {
'Location': redirectUrl.toString()
}
});
}
}

const rules = await getRules(request, env);

if (isFile) {
// Fetch the file directly
const assetResponse = await env.ASSETS.fetch(request);
Expand Down
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.worker.json"
}
]
}
3 changes: 2 additions & 1 deletion tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"outDir": "./out-tsc/spec",
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"],
"exclude": ["src/worker.ts", "src/worker.spec.ts"]
}
8 changes: 8 additions & 0 deletions tsconfig.worker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"types": ["vitest/globals", "node"]
},
"include": ["src/worker.ts", "src/worker.spec.ts"]
}
Loading