diff --git a/web/netlify/functions/_shared/__tests__/jwt-validation.test.ts b/web/netlify/functions/_shared/__tests__/jwt-validation.test.ts index b6c47524f3..137fa4a864 100644 --- a/web/netlify/functions/_shared/__tests__/jwt-validation.test.ts +++ b/web/netlify/functions/_shared/__tests__/jwt-validation.test.ts @@ -1 +1,220 @@ -$(cat jwt-validation.test.ts) \ No newline at end of file +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SignJWT } from 'jose' +import { validateJWT, validateBearerToken } from '../jwt-validation' + +const TEST_SECRET = 'test-secret-for-jwt-validation-at-least-32-chars' + +async function makeToken( + payload: Record = {}, + options: { secret?: string; alg?: string; exp?: number } = {}, +): Promise { + const secret = options.secret ?? TEST_SECRET + const alg = options.alg ?? 'HS256' + const key = new TextEncoder().encode(secret) + + let builder = new SignJWT(payload).setProtectedHeader({ alg }) + + if (options.exp !== undefined) { + builder = builder.setExpirationTime(options.exp) + } + + return builder.sign(key) +} + +function makeUnsignedToken( + header: Record, + payload: Record, + signature = '', +): string { + const encodeB64url = (obj: unknown) => + Buffer.from(JSON.stringify(obj)).toString('base64url') + return `${encodeB64url(header)}.${encodeB64url(payload)}.${signature}` +} + +describe('validateJWT', () => { + describe('structural validation', () => { + it('rejects empty token', async () => { + const result = await validateJWT('', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('required') + }) + + it('rejects non-string token', async () => { + const result = await validateJWT(null as unknown as string, TEST_SECRET) + expect(result.valid).toBe(false) + }) + + it('rejects token with wrong number of parts', async () => { + const result = await validateJWT('only.two', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('3 parts') + }) + + it('rejects token with 4 parts', async () => { + const result = await validateJWT('a.b.c.d', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('3 parts') + }) + + it('rejects token with invalid header JSON', async () => { + const invalidHeader = Buffer.from('not-json').toString('base64url') + const payload = Buffer.from('{}').toString('base64url') + const result = await validateJWT(`${invalidHeader}.${payload}.sig`, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('header') + }) + + it('rejects token with invalid payload JSON', async () => { + const header = Buffer.from('{"alg":"HS256"}').toString('base64url') + const invalidPayload = Buffer.from('not-json').toString('base64url') + const result = await validateJWT(`${header}.${invalidPayload}.sig`, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('payload') + }) + }) + + describe('algorithm validation', () => { + it('rejects alg "none" (unsigned token attack)', async () => { + const token = makeUnsignedToken({ alg: 'none' }, { sub: 'attacker' }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('none') + }) + + it('rejects unsupported algorithm', async () => { + const token = makeUnsignedToken({ alg: 'RS256' }, { sub: 'user' }, 'fakesig') + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('unsupported') + }) + + it('rejects non-string alg header', async () => { + const token = makeUnsignedToken({ alg: 123 }, { sub: 'user' }, 'sig') + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('alg') + }) + + it('rejects missing signature with valid header', async () => { + const token = makeUnsignedToken({ alg: 'HS256' }, { sub: 'user' }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('signature') + }) + }) + + describe('expiry validation', () => { + it('rejects expired token', async () => { + const pastExp = Math.floor(Date.now() / 1000) - 3600 // 1 hour ago + const token = await makeToken({ sub: 'user' }, { exp: pastExp }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('expired') + }) + + it('rejects non-numeric exp claim', async () => { + const token = makeUnsignedToken( + { alg: 'HS256' }, + { sub: 'user', exp: 'not-a-number' }, + 'fakesig', + ) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('exp') + }) + }) + + describe('secret validation', () => { + it('rejects when no secret provided', async () => { + const token = await makeToken({ sub: 'user' }) + const result = await validateJWT(token, undefined) + expect(result.valid).toBe(false) + expect(result.error).toContain('secret') + }) + + it('rejects when secret is empty string', async () => { + const token = await makeToken({ sub: 'user' }) + const result = await validateJWT(token, '') + expect(result.valid).toBe(false) + expect(result.error).toContain('secret') + }) + + it('rejects when secret is whitespace-only', async () => { + const token = await makeToken({ sub: 'user' }) + const result = await validateJWT(token, ' ') + expect(result.valid).toBe(false) + expect(result.error).toContain('secret') + }) + }) + + describe('signature verification', () => { + it('accepts valid token with correct secret', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600 + const token = await makeToken({ sub: 'user-123' }, { exp: futureExp }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(true) + expect(result.payload?.sub).toBe('user-123') + }) + + it('accepts valid token without exp claim', async () => { + const token = await makeToken({ sub: 'user-456' }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(true) + expect(result.payload?.sub).toBe('user-456') + }) + + it('rejects token signed with wrong secret', async () => { + const token = await makeToken({ sub: 'user' }, { secret: 'wrong-secret-that-is-long-enough' }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(false) + }) + + it('preserves custom claims in payload', async () => { + const token = await makeToken({ sub: 'admin', role: 'superuser', org: 'kubestellar' }) + const result = await validateJWT(token, TEST_SECRET) + expect(result.valid).toBe(true) + expect(result.payload?.role).toBe('superuser') + expect(result.payload?.org).toBe('kubestellar') + }) + }) +}) + +describe('validateBearerToken', () => { + it('rejects empty auth header', async () => { + const result = await validateBearerToken('', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('Authorization') + }) + + it('rejects null auth header', async () => { + const result = await validateBearerToken(null as unknown as string, TEST_SECRET) + expect(result.valid).toBe(false) + }) + + it('rejects header without Bearer prefix', async () => { + const result = await validateBearerToken('Basic dXNlcjpwYXNz', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('Bearer') + }) + + it('rejects Bearer header with empty token', async () => { + const result = await validateBearerToken('Bearer ', TEST_SECRET) + expect(result.valid).toBe(false) + expect(result.error).toContain('empty') + }) + + it('accepts valid Bearer token', async () => { + const token = await makeToken({ sub: 'bearer-user' }) + const result = await validateBearerToken(`Bearer ${token}`, TEST_SECRET) + expect(result.valid).toBe(true) + expect(result.payload?.sub).toBe('bearer-user') + }) + + it('handles extra whitespace in Bearer header', async () => { + const token = await makeToken({ sub: 'padded-user' }) + const result = await validateBearerToken(` Bearer ${token} `, TEST_SECRET) + expect(result.valid).toBe(true) + expect(result.payload?.sub).toBe('padded-user') + }) +}) diff --git a/web/netlify/functions/_shared/__tests__/read-capped-request.test.ts b/web/netlify/functions/_shared/__tests__/read-capped-request.test.ts index 410041285d..eedb5d7d85 100644 --- a/web/netlify/functions/_shared/__tests__/read-capped-request.test.ts +++ b/web/netlify/functions/_shared/__tests__/read-capped-request.test.ts @@ -1 +1,138 @@ -$(cat read-capped-request.test.ts) \ No newline at end of file +// @vitest-environment node +import { describe, it, expect } from 'vitest' +import { + readCappedRequestBuffer, + readCappedRequestText, + readCappedRequestJson, + RequestBodyTooLargeError, +} from '../read-capped-request' + +function makeRequest(body: string | null): Request { + return new Request('http://localhost/test', { + method: 'POST', + body, + }) +} + +function makeLargeRequest(size: number): Request { + const data = 'x'.repeat(size) + return makeRequest(data) +} + +describe('readCappedRequestBuffer', () => { + it('returns empty Uint8Array for request with no body', async () => { + const req = new Request('http://localhost/test', { method: 'GET' }) + const result = await readCappedRequestBuffer(req, 1024) + expect(result).toBeInstanceOf(Uint8Array) + expect(result.byteLength).toBe(0) + }) + + it('reads body within size limit', async () => { + const req = makeRequest('hello') + const result = await readCappedRequestBuffer(req, 1024) + expect(new TextDecoder().decode(result)).toBe('hello') + }) + + it('reads body at exact size limit', async () => { + const body = 'x'.repeat(10) + const req = makeRequest(body) + const result = await readCappedRequestBuffer(req, 10) + expect(result.byteLength).toBe(10) + }) + + it('throws RequestBodyTooLargeError when body exceeds limit', async () => { + const req = makeLargeRequest(100) + await expect(readCappedRequestBuffer(req, 50, 'test-label')).rejects.toThrow( + RequestBodyTooLargeError, + ) + }) + + it('includes label in error message', async () => { + const req = makeLargeRequest(100) + await expect(readCappedRequestBuffer(req, 50, 'my-endpoint')).rejects.toThrow( + /my-endpoint/, + ) + }) + + it('includes actual byte count in error', async () => { + const req = makeLargeRequest(200) + try { + await readCappedRequestBuffer(req, 10, 'test') + expect.fail('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(RequestBodyTooLargeError) + expect((err as Error).message).toContain('limit 10') + } + }) + + it('returns empty Uint8Array for empty string body', async () => { + const req = makeRequest('') + const result = await readCappedRequestBuffer(req, 1024) + expect(result.byteLength).toBe(0) + }) +}) + +describe('readCappedRequestText', () => { + it('reads text body within limit', async () => { + const req = makeRequest('hello world') + const result = await readCappedRequestText(req, 1024) + expect(result).toBe('hello world') + }) + + it('throws on oversized body', async () => { + const req = makeLargeRequest(100) + await expect(readCappedRequestText(req, 50)).rejects.toThrow(RequestBodyTooLargeError) + }) + + it('handles UTF-8 content', async () => { + const req = makeRequest('héllo wörld 🌍') + const result = await readCappedRequestText(req, 1024) + expect(result).toBe('héllo wörld 🌍') + }) +}) + +describe('readCappedRequestJson', () => { + it('parses valid JSON within limit', async () => { + const data = { user: 'alice', role: 'admin' } + const req = makeRequest(JSON.stringify(data)) + const result = await readCappedRequestJson(req, 1024) + expect(result).toEqual(data) + }) + + it('throws on oversized JSON body', async () => { + const bigObj = { data: 'x'.repeat(200) } + const req = makeRequest(JSON.stringify(bigObj)) + await expect(readCappedRequestJson(req, 50)).rejects.toThrow(RequestBodyTooLargeError) + }) + + it('throws on invalid JSON', async () => { + const req = makeRequest('not valid json {{{') + await expect(readCappedRequestJson(req, 1024)).rejects.toThrow() + }) + + it('parses JSON arrays', async () => { + const data = [1, 2, 3] + const req = makeRequest(JSON.stringify(data)) + const result = await readCappedRequestJson(req, 1024) + expect(result).toEqual([1, 2, 3]) + }) +}) + +describe('RequestBodyTooLargeError', () => { + it('has correct name', () => { + const err = new RequestBodyTooLargeError('test', 100, 200) + expect(err.name).toBe('RequestBodyTooLargeError') + }) + + it('is an instance of Error', () => { + const err = new RequestBodyTooLargeError('test', 100, 200) + expect(err).toBeInstanceOf(Error) + }) + + it('includes all details in message', () => { + const err = new RequestBodyTooLargeError('upload', 1024, 2048) + expect(err.message).toContain('upload') + expect(err.message).toContain('1024') + expect(err.message).toContain('2048') + }) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 97195a0922..5e6e9c6251 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -329,7 +329,7 @@ export default defineConfig(({ mode }) => ({ environment: 'jsdom', setupFiles: './src/test/setup.ts', css: true, - include: ['src/**/*.{test,spec}.{ts,tsx}', 'netlify/functions/__tests__/*.{test,spec}.{ts,tsx}', 'netlify/edge-functions/__tests__/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'netlify/functions/**/__tests__/*.{test,spec}.{ts,tsx}', 'netlify/edge-functions/__tests__/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'e2e/**/*'], // Retry flaky tests up to 2 times in CI to reduce false-positive workflow failures (#11872) retry: process.env.CI ? 2 : 0,