Skip to content
Open
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
221 changes: 220 additions & 1 deletion web/netlify/functions/_shared/__tests__/jwt-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1 +1,220 @@
$(cat jwt-validation.test.ts)
// @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<string, unknown> = {},
options: { secret?: string; alg?: string; exp?: number } = {},
): Promise<string> {
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<string, unknown>,
payload: Record<string, unknown>,
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')
})
})
139 changes: 138 additions & 1 deletion web/netlify/functions/_shared/__tests__/read-capped-request.test.ts
Original file line number Diff line number Diff line change
@@ -1 +1,138 @@
$(cat read-capped-request.test.ts)
// @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<typeof data>(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<number[]>(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')
})
})
2 changes: 1 addition & 1 deletion web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading