test: add OAuth2 authentication test files (TDD RED phase)
This commit is contained in:
218
app/utils/__tests__/validateRedirect.test.ts
Normal file
218
app/utils/__tests__/validateRedirect.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* TDD Tests for validateRedirect utility (Phase 1: RED)
|
||||
*
|
||||
* Purpose: Prevent open redirect vulnerabilities by validating redirect URLs
|
||||
* Specification: specs/001-oauth2-authentication/spec.md - US3 (Protected Routes)
|
||||
*
|
||||
* Security Requirements:
|
||||
* - FR-019: Validate redirect URLs to prevent open redirect attacks
|
||||
* - NFR-005: Only allow same-origin paths starting with /
|
||||
*
|
||||
* These tests are written FIRST (TDD Red phase) and should FAIL
|
||||
* until the validateRedirect implementation is created in T002.
|
||||
*/
|
||||
|
||||
describe('validateRedirect', () => {
|
||||
describe('Valid same-origin paths', () => {
|
||||
it('should return valid path starting with single slash', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/dashboard');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return valid nested path', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/projects/123');
|
||||
expect(result).toBe('/projects/123');
|
||||
});
|
||||
|
||||
it('should return valid path with query parameters', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/tasks?id=456');
|
||||
expect(result).toBe('/tasks?id=456');
|
||||
});
|
||||
|
||||
it('should return valid path with hash fragment', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/dashboard#section');
|
||||
expect(result).toBe('/dashboard#section');
|
||||
});
|
||||
|
||||
it('should return root path', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/');
|
||||
expect(result).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rejection of external URLs (open redirect protection)', () => {
|
||||
it('should reject fully-qualified external URL with https', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('https://evil.com');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should reject fully-qualified external URL with http', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('http://evil.com');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should reject protocol-relative URL (double slash)', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('//evil.com');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should reject protocol-relative URL with path', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('//evil.com/dashboard');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should reject javascript: protocol', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('javascript:alert(1)');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should reject data: protocol', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('data:text/html,<script>alert(1)</script>');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid input handling', () => {
|
||||
it('should return fallback when redirect is null', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(null);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is undefined', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(undefined);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is a number', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(123);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is an object', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect({ path: '/dashboard' });
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is an empty string', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is only whitespace', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(' ');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return fallback when redirect is an array', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(['/dashboard']);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom fallback parameter', () => {
|
||||
it('should use custom fallback when redirect is invalid', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('https://evil.com', '/projects');
|
||||
expect(result).toBe('/projects');
|
||||
});
|
||||
|
||||
it('should use custom fallback when redirect is null', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(null, '/home');
|
||||
expect(result).toBe('/home');
|
||||
});
|
||||
|
||||
it('should use custom fallback when redirect is undefined', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(undefined, '/login');
|
||||
expect(result).toBe('/login');
|
||||
});
|
||||
|
||||
it('should return valid redirect even when custom fallback is provided', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/dashboard', '/home');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should reject URL with multiple slashes at start', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('///evil.com');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should handle path with encoded characters', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/dashboard%20test');
|
||||
expect(result).toBe('/dashboard%20test');
|
||||
});
|
||||
|
||||
it('should handle path with special characters', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/projects?name=test&id=123');
|
||||
expect(result).toBe('/projects?name=test&id=123');
|
||||
});
|
||||
|
||||
it('should reject backslash-based bypass attempt', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/\\evil.com');
|
||||
expect(result).toBe('/\\evil.com'); // Valid local path (backslash is literal)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety', () => {
|
||||
it('should accept string type for redirect parameter', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const redirect: string = '/dashboard';
|
||||
const result = validateRedirect(redirect);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should accept unknown type for redirect parameter', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const redirect: unknown = '/dashboard';
|
||||
const result = validateRedirect(redirect);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should return string type', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect('/dashboard');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should accept optional fallback parameter', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
// Should not throw when fallback is omitted
|
||||
const result = validateRedirect('/dashboard');
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('should use default fallback when not provided', async () => {
|
||||
const { validateRedirect } = await import('../validateRedirect');
|
||||
const result = validateRedirect(null);
|
||||
expect(result).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user