diff --git a/docs/API_VERSIONING_POLICY.md b/docs/API_VERSIONING_POLICY.md index 811d8679..32c7d137 100644 --- a/docs/API_VERSIONING_POLICY.md +++ b/docs/API_VERSIONING_POLICY.md @@ -15,6 +15,18 @@ This project uses URL-based API versioning to protect clients from breaking chan - Path-based versioning is the primary version selection mechanism - API clients should prefer explicit `/api/v1/...` paths when available +## Supported version numbers + +The middleware validates version strings against the pattern `/^v\d+$/` (the letter +`v` followed by one or more digits). Any other format is rejected with `400 Bad Request`. + +| Version | Status | Notes | +|---------|---------|-------------------------| +| `v1` | Active | Current stable version | +| `v2` | Planned | Reserved for future use | + +Examples of **invalid** version strings that are rejected: `vABC`, `v1.2`, `../v1`, `123`. + ## Compatibility layer The middleware rewrites legacy API requests from `/api/*` to `/api/v1/*`. diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..51840533 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'TeachLink - Sign In or Create an Account', + description: + 'Access your TeachLink account to continue learning offline. Sign in, sign up, or verify your email.', + openGraph: { + title: 'TeachLink - Sign In or Create an Account', + description: 'Access your TeachLink account to continue learning.', + type: 'website', + siteName: 'TeachLink', + }, + twitter: { + card: 'summary', + site: '@teachlink', + title: 'TeachLink - Sign In or Create an Account', + description: 'Access your TeachLink account to continue learning.', + }, +}; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/__tests__/twitter-cards.test.ts b/src/app/__tests__/twitter-cards.test.ts new file mode 100644 index 00000000..277e40fb --- /dev/null +++ b/src/app/__tests__/twitter-cards.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { metadata as rootMetadata } from '@/app/layout'; +import { metadata as authMetadata } from '@/app/(auth)/layout'; +import { metadata as dashboardMetadata } from '@/app/dashboard/layout'; +import { metadata as profileMetadata } from '@/app/profile/layout'; + +describe('Twitter Cards metadata', () => { + describe('Root layout', () => { + it('exports a twitter card field', () => { + expect(rootMetadata.twitter).toBeDefined(); + }); + + it('uses summary_large_image card type', () => { + expect(rootMetadata.twitter?.card).toBe('summary_large_image'); + }); + + it('includes a twitter title', () => { + expect(rootMetadata.twitter?.title).toBeTruthy(); + }); + + it('includes a twitter description', () => { + expect(rootMetadata.twitter?.description).toBeTruthy(); + }); + + it('includes twitter site handle', () => { + expect(rootMetadata.twitter?.site).toBe('@teachlink'); + }); + + it('exports openGraph metadata', () => { + expect(rootMetadata.openGraph).toBeDefined(); + expect(rootMetadata.openGraph?.siteName).toBe('TeachLink'); + }); + }); + + describe('Auth layout', () => { + it('exports a twitter card field', () => { + expect(authMetadata.twitter).toBeDefined(); + }); + + it('uses summary card type', () => { + expect(authMetadata.twitter?.card).toBe('summary'); + }); + + it('includes a twitter title', () => { + expect(authMetadata.twitter?.title).toBeTruthy(); + }); + + it('includes a twitter description', () => { + expect(authMetadata.twitter?.description).toBeTruthy(); + }); + + it('includes twitter site handle', () => { + expect(authMetadata.twitter?.site).toBe('@teachlink'); + }); + }); + + describe('Dashboard layout', () => { + it('exports a twitter card field', () => { + expect(dashboardMetadata.twitter).toBeDefined(); + }); + + it('uses summary card type', () => { + expect(dashboardMetadata.twitter?.card).toBe('summary'); + }); + }); + + describe('Profile layout', () => { + it('exports a twitter card field', () => { + expect(profileMetadata.twitter).toBeDefined(); + }); + + it('uses summary card type', () => { + expect(profileMetadata.twitter?.card).toBe('summary'); + }); + }); +}); diff --git a/src/app/courses/[courseId]/page.tsx b/src/app/courses/[courseId]/page.tsx index 7545d78d..9b15f34a 100644 --- a/src/app/courses/[courseId]/page.tsx +++ b/src/app/courses/[courseId]/page.tsx @@ -12,6 +12,18 @@ export async function generateMetadata({ params }: CoursePageProps): Promise { expect(response.headers.get(API_VERSION_HEADER)).toBe('v1'); expect(response.headers.get(API_DEPRECATION_HEADER)).toBeNull(); }); -}); + + describe('valid version strings — should route correctly', () => { + it('accepts v1 and sets X-Api-Version header', () => { + const request = createMockRequest('/api/v1/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).not.toBe(400); + expect(response.headers.get(API_VERSION_HEADER)).toBe('v1'); + }); + + it('accepts v2 and sets X-Api-Version header', () => { + const request = createMockRequest('/api/v2/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).not.toBe(400); + expect(response.headers.get(API_VERSION_HEADER)).toBe('v2'); + }); + + it('accepts large version numbers like v10', () => { + const request = createMockRequest('/api/v10/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).not.toBe(400); + expect(response.headers.get(API_VERSION_HEADER)).toBe('v10'); + }); + }); + + describe('malformed version strings — should return 400', () => { + it('rejects alphabetic version string (vABC)', () => { + const request = createMockRequest('/api/vABC/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + + it('rejects path-traversal characters (/../)', () => { + const request = createMockRequest('/api/../v1/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + + it('rejects empty version segment (/api/v/)', () => { + const request = createMockRequest('/api/v/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + + it('rejects version with special characters (v1.2)', () => { + const request = createMockRequest('/api/v1.2/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + + it('rejects version with injection attempt (v1;drop)', () => { + const request = createMockRequest('/api/v1;drop/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + + it('rejects purely numeric version without v prefix (123)', () => { + const request = createMockRequest('/api/123/posts'); + const response = middleware(request) as NextResponse; + expect(response.status).toBe(400); + }); + }); +}); \ No newline at end of file