diff --git a/src/compose.ts b/src/compose.ts index 1ac3155b..7bc1b19f 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -1,13 +1,12 @@ import type { AnyElysia } from '.' -import { Value } from '@sinclair/typebox/value' import { Kind, - OptionalKind, TypeBoxError, type TAnySchema, type TSchema } from '@sinclair/typebox' +import { Value } from '@sinclair/typebox/value' import decode from 'fast-decode-uri-component' import { @@ -16,33 +15,33 @@ import { parseQueryStandardSchema } from './parse-query' +import { ParseError, status } from './error' +import { isBun } from './universal/utils' import { ELYSIA_REQUEST_ID, + encodePath, getLoosePath, + getResponseLength, + isNotEmpty, lifeCycleToFn, + mergeCookie, randomId, redirect, - signCookie, - isNotEmpty, - encodePath, - mergeCookie, - getResponseLength + signCookie } from './utils' -import { isBun } from './universal/utils' -import { ParseError, status } from './error' import { - NotFoundError, - ValidationError, + ElysiaCustomStatusResponse, ERROR_CODE, - ElysiaCustomStatusResponse + NotFoundError, + ValidationError } from './error' import { ELYSIA_TRACE, type TraceHandler } from './trace' +import { parseCookie, type CookieOptions } from './cookies' import { coercePrimitiveRoot, ElysiaTypeCheck, - getCookieValidator, getSchemaValidator, hasElysiaMeta, hasType, @@ -50,7 +49,6 @@ import { unwrapImportSchema } from './schema' import { Sucrose, sucrose } from './sucrose' -import { parseCookie, type CookieOptions } from './cookies' import { fileType } from './type-system/utils' import type { TraceEvent } from './trace' @@ -96,16 +94,16 @@ const createReport = ({ return () => { return { resolveChild() { - return () => {} + return () => { } }, - resolve() {} + resolve() { } } } for (let i = 0; i < trace.length; i++) addFn( `let report${i},reportChild${i},reportErr${i},reportErrChild${i};` + - `let trace${i}=${context}[ELYSIA_TRACE]?.[${i}]??trace[${i}](${context});\n` + `let trace${i}=${context}[ELYSIA_TRACE]?.[${i}]??trace[${i}](${context});\n` ) return ( @@ -135,12 +133,12 @@ const createReport = ({ for (let i = 0; i < trace.length; i++) addFn( `${reporter}${i} = trace${i}.${event}({` + - `id,` + - `event:'${event}',` + - `name:'${name}',` + - `begin:performance.now(),` + - `total:${total}` + - `})\n` + `id,` + + `event:'${event}',` + + `name:'${name}',` + + `begin:performance.now(),` + + `total:${total}` + + `})\n` ) return { @@ -152,11 +150,11 @@ const createReport = ({ for (let i = 0; i < trace.length; i++) addFn( `${reporter}Child${i}=${reporter}${i}.resolveChild?.shift()?.({` + - `id,` + - `event:'${event}',` + - `name:'${name}',` + - `begin:performance.now()` + - `})\n` + `id,` + + `event:'${event}',` + + `name:'${name}',` + + `begin:performance.now()` + + `})\n` ) return (binding?: string) => { @@ -169,10 +167,10 @@ const createReport = ({ // } else addFn( `if(${binding} instanceof Error){` + - `${reporter}Child${i}?.(${binding}) ` + - `}else{` + - `${reporter}Child${i}?.()` + - '}' + `${reporter}Child${i}?.(${binding}) ` + + `}else{` + + `${reporter}Child${i}?.()` + + '}' ) else addFn(`${reporter}Child${i}?.()\n`) } @@ -272,7 +270,7 @@ const composeValidationFactory = ({ : refKey const referencedDef = value.schema.$defs[ - defKey as keyof typeof value.schema.$defs + defKey as keyof typeof value.schema.$defs ] if (referencedDef?.noValidate === true) { @@ -310,11 +308,11 @@ const composeValidationFactory = ({ `}catch{` + (applyErrorCleaner ? `try{\n` + - clean({ ignoreTryCatch: true }) + - `${name}=validator.response[${status}].Encode(${name})\n` + - `}catch{` + - `throw new ValidationError('response',validator.response[${status}],${name})` + - `}` + clean({ ignoreTryCatch: true }) + + `${name}=validator.response[${status}].Encode(${name})\n` + + `}catch{` + + `throw new ValidationError('response',validator.response[${status}],${name})` + + `}` : `throw new ValidationError('response',validator.response[${status}],${name})`) + `}` } else { @@ -549,8 +547,8 @@ export const composeHandler = ({ sign: string[] | true properties: { [x: string]: Object } } = validator.cookie?.config - ? mergeCookie(validator?.cookie?.config, app.config.cookie as any) - : app.config.cookie + ? mergeCookie(validator?.cookie?.config, app.config.cookie as any) + : app.config.cookie let _encodeCookie = '' const encodeCookie = () => { @@ -629,39 +627,37 @@ export const composeHandler = ({ } const options = cookieMeta - ? `{secrets:${ - cookieMeta.secrets !== undefined - ? typeof cookieMeta.secrets === 'string' - ? `'${cookieMeta.secrets}'` - : '[' + - cookieMeta.secrets.reduce( - (a, b) => a + `'${b}',`, - '' - ) + - ']' - : 'undefined' - },` + - `sign:${ - cookieMeta.sign === true - ? true - : cookieMeta.sign !== undefined - ? '[' + - cookieMeta.sign.reduce( - (a, b) => a + `'${b}',`, - '' - ) + - ']' - : 'undefined' - },` + - get('domain') + - get('expires') + - get('httpOnly') + - get('maxAge') + - get('path', '/') + - get('priority') + - get('sameSite') + - get('secure') + - '}' + ? `{secrets:${cookieMeta.secrets !== undefined + ? typeof cookieMeta.secrets === 'string' + ? `'${cookieMeta.secrets}'` + : '[' + + cookieMeta.secrets.reduce( + (a, b) => a + `'${b}',`, + '' + ) + + ']' + : 'undefined' + },` + + `sign:${cookieMeta.sign === true + ? true + : cookieMeta.sign !== undefined + ? '[' + + cookieMeta.sign.reduce( + (a, b) => a + `'${b}',`, + '' + ) + + ']' + : 'undefined' + },` + + get('domain') + + get('expires') + + get('httpOnly') + + get('maxAge') + + get('path', '/') + + get('priority') + + get('sameSite') + + get('secure') + + '}' : 'undefined' if (hasHeaders) @@ -699,11 +695,11 @@ export const composeHandler = ({ 'c.query=Object.create(null)' + '}else{' + `c.query=parseQueryFromURL(c.url,c.qi+1,${ - // - hasArrayProperty ? JSON.stringify(arrayProperties) : undefined + // + hasArrayProperty ? JSON.stringify(arrayProperties) : undefined },${ - // - hasObjectProperty ? JSON.stringify(objectProperties) : undefined + // + hasObjectProperty ? JSON.stringify(objectProperties) : undefined })` + '}' } @@ -1300,7 +1296,7 @@ export const composeHandler = ({ validator.body.schema, validator.body.schema.type === 'object' || unwrapImportSchema(validator.body.schema)[Kind] === - 'Object' + 'Object' ? {} : undefined ) @@ -1502,7 +1498,7 @@ export const composeHandler = ({ // ! Get latest app.config.cookie validator.cookie.config = mergeCookie( validator.cookie.config, - validator.cookie?.config ?? {} + app.config.cookie ?? {} ) fnLiteral += @@ -1524,7 +1520,7 @@ export const composeHandler = ({ fnLiteral += `for(const k of Object.keys(cookieValue))` + `c.cookie[k].value=cookieValue[k]\n` - } else if (validator.body?.schema?.noValidate !== true) { + } else if (validator.cookie?.schema?.noValidate !== true) { fnLiteral += `if(validator.cookie.Check(cookieValue)===false){` + validation.validate('cookie', 'cookieValue') + @@ -1533,8 +1529,8 @@ export const composeHandler = ({ if (validator.cookie.hasTransform) fnLiteral += coerceTransformDecodeError( `for(const [key,value] of Object.entries(validator.cookie.Decode(cookieValue))){` + - `c.cookie[key].value=value` + - `}`, + `c.cookie[key].value=value` + + `}`, 'cookie' ) } @@ -1622,6 +1618,8 @@ export const composeHandler = ({ }) if (hooks.afterHandle?.length) { + fnLiteral += `c.response = be\n` + for (let i = 0; i < hooks.afterHandle.length; i++) { const hook = hooks.afterHandle[i] const returning = hasReturn(hook) @@ -1681,9 +1679,8 @@ export const composeHandler = ({ fnLiteral += afterResponse() fnLiteral += encodeCookie() - fnLiteral += `return mapEarlyResponse(${saveResponse}be,c.set${ - mapResponseContext - })}\n` + fnLiteral += `return mapEarlyResponse(${saveResponse}be,c.set${mapResponseContext + })}\n` } } } @@ -1767,8 +1764,7 @@ export const composeHandler = ({ ) fnLiteral += - `mr=${ - isAsyncName(mapResponse) ? 'await ' : '' + `mr=${isAsyncName(mapResponse) ? 'await ' : '' }e.mapResponse[${i}](c)\n` + `if(mr!==undefined)r=c.response=c.responseValue=mr\n` @@ -1824,13 +1820,12 @@ export const composeHandler = ({ fnLiteral += inference.set ? `if(` + - `isNotEmpty(c.set.headers)||` + - `c.set.status!==200||` + - `c.set.redirect||` + - `c.set.cookie)return mapResponse(${saveResponse}${handle}.clone(),c.set${ - mapResponseContext - })\n` + - `else return ${handle}.clone()` + `isNotEmpty(c.set.headers)||` + + `c.set.status!==200||` + + `c.set.redirect||` + + `c.set.cookie)return mapResponse(${saveResponse}${handle}.clone(),c.set${mapResponseContext + })\n` + + `else return ${handle}.clone()` : `return ${handle}.clone()` fnLiteral += '\n' @@ -1877,13 +1872,12 @@ export const composeHandler = ({ fnLiteral += inference.set ? `if(isNotEmpty(c.set.headers)||` + - `c.set.status!==200||` + - `c.set.redirect||` + - `c.set.cookie)` + - `return mapResponse(${saveResponse}${handle}.clone(),c.set${ - mapResponseContext - })\n` + - `else return ${handle}.clone()\n` + `c.set.status!==200||` + + `c.set.redirect||` + + `c.set.cookie)` + + `return mapResponse(${saveResponse}${handle}.clone(),c.set${mapResponseContext + })\n` + + `else return ${handle}.clone()\n` : `return ${handle}.clone()\n` } else fnLiteral += mapResponse(handled) } @@ -1950,7 +1944,7 @@ export const composeHandler = ({ fnLiteral += `c.response=c.responseValue=er\n` + `mep=e.mapResponse[${i}](c)\n` + - `if(mep instanceof Promise)er=await er\n` + + `if(mep instanceof Promise)mep=await mep\n` + `if(mep!==undefined)er=mep\n` endUnit() @@ -2447,11 +2441,10 @@ export const composeErrorHandler = (app: AnyElysia) => { adapterVariables + `}=inject\n` - fnLiteral += `return ${ - app.event.error?.find(isAsync) || app.event.mapResponse?.find(isAsync) - ? 'async ' - : '' - }function(context,error,skipGlobal){` + fnLiteral += `return ${app.event.error?.find(isAsync) || app.event.mapResponse?.find(isAsync) + ? 'async ' + : '' + }function(context,error,skipGlobal){` fnLiteral += '' @@ -2520,9 +2513,8 @@ export const composeErrorHandler = (app: AnyElysia) => { for (let i = 0; i < app.event.error.length; i++) { const handler = app.event.error[i] - const response = `${ - isAsync(handler) ? 'await ' : '' - }onError[${i}](context)\n` + const response = `${isAsync(handler) ? 'await ' : '' + }onError[${i}](context)\n` fnLiteral += 'if(skipGlobal!==true){' diff --git a/test/cookie/validation.test.ts b/test/cookie/validation.test.ts new file mode 100644 index 00000000..527e7617 --- /dev/null +++ b/test/cookie/validation.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia, t } from '../../src' +import { req } from '../utils' + +describe('Cookie Validation', () => { + it('validate required cookie', async () => { + const app = new Elysia().get('/', ({ cookie: { session } }) => session.value, { + cookie: t.Cookie({ + session: t.String() + }) + }) + + const [valid, invalid] = await Promise.all([ + app.handle(req('/', { headers: { Cookie: 'session=value' } })), + app.handle(req('/')) + ]) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('value') + expect(invalid.status).toBe(422) + }) + + it('validate optional cookie', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { session } }) => session.value ?? 'empty', + { + cookie: t.Cookie({ + session: t.Optional(t.String()) + }) + } + ) + + const [withCookie, withoutCookie] = await Promise.all([ + app.handle(req('/', { headers: { Cookie: 'session=value' } })), + app.handle(req('/')) + ]) + + expect(withCookie.status).toBe(200) + expect(await withCookie.text()).toBe('value') + expect(withoutCookie.status).toBe(200) + expect(await withoutCookie.text()).toBe('empty') + }) + + it('validate cookie type - numeric', async () => { + const app = new Elysia().get('/', ({ cookie: { count } }) => count.value, { + cookie: t.Cookie({ + count: t.Numeric() + }) + }) + + const [valid, invalid] = await Promise.all([ + app.handle(req('/', { headers: { Cookie: 'count=42' } })), + app.handle(req('/', { headers: { Cookie: 'count=invalid' } })) + ]) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('42') + expect(invalid.status).toBe(422) + }) + + it('validate cookie type - boolean', async () => { + const app = new Elysia().get('/', ({ cookie: { active } }) => active.value, { + cookie: t.Cookie({ + active: t.BooleanString() + }) + }) + + const [validTrue, validFalse, invalid] = await Promise.all([ + app.handle(req('/', { headers: { Cookie: 'active=true' } })), + app.handle(req('/', { headers: { Cookie: 'active=false' } })), + app.handle(req('/', { headers: { Cookie: 'active=maybe' } })) + ]) + + expect(validTrue.status).toBe(200) + expect(await validTrue.text()).toBe('true') + expect(validFalse.status).toBe(200) + expect(await validFalse.text()).toBe('false') + expect(invalid.status).toBe(422) + }) + + it('validate cookie with object schema', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { profile } }) => profile.value.name, + { + cookie: t.Cookie({ + profile: t.Object({ + name: t.String(), + age: t.Numeric() + }) + }) + } + ) + + const valid = await app.handle( + req('/', { + headers: { + Cookie: 'profile=' + encodeURIComponent(JSON.stringify({ name: 'Himari', age: 16 })) + } + }) + ) + + const invalid = await app.handle( + req('/', { + headers: { + Cookie: 'profile=' + encodeURIComponent(JSON.stringify({ name: 'Himari' })) + } + }) + ) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('Himari') + expect(invalid.status).toBe(422) + }) + + it('validate multiple cookies', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { session, userId } }) => `${session.value}:${userId.value}`, + { + cookie: t.Cookie({ + session: t.String(), + userId: t.Numeric() + }) + } + ) + + const [valid, missingSession, missingUserId, invalidUserId] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'session=abc123; userId=42' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'userId=42' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'session=abc123' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'session=abc123; userId=invalid' } + }) + ) + ]) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('abc123:42') + expect(missingSession.status).toBe(422) + expect(missingUserId.status).toBe(422) + expect(invalidUserId.status).toBe(422) + }) + + it('validate cookie with string constraints', async () => { + const app = new Elysia().get('/', ({ cookie: { token } }) => token.value, { + cookie: t.Cookie({ + token: t.String({ minLength: 10, maxLength: 50 }) + }) + }) + + const [valid, tooShort, tooLong] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'token=validtoken123' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'token=short' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'token=' + 'a'.repeat(51) } + }) + ) + ]) + + expect(valid.status).toBe(200) + expect(tooShort.status).toBe(422) + expect(tooLong.status).toBe(422) + }) + + it('validate cookie with numeric constraints', async () => { + const app = new Elysia().get('/', ({ cookie: { age } }) => age.value, { + cookie: t.Cookie({ + age: t.Numeric({ minimum: 0, maximum: 120 }) + }) + }) + + const [valid, tooLow, tooHigh] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'age=25' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'age=-1' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'age=150' } + }) + ) + ]) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('25') + expect(tooLow.status).toBe(422) + expect(tooHigh.status).toBe(422) + }) + + it('validate cookie with pattern', async () => { + const app = new Elysia().get('/', ({ cookie: { email } }) => email.value, { + cookie: t.Cookie({ + email: t.String({ format: 'email' }) + }) + }) + + const [valid, invalid] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'email=user@example.com' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'email=notanemail' } + }) + ) + ]) + + expect(valid.status).toBe(200) + expect(await valid.text()).toBe('user@example.com') + expect(invalid.status).toBe(422) + }) + + it('validate cookie with transform', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { timestamp } }) => timestamp.value, + { + cookie: t.Cookie({ + timestamp: t.Transform(t.String()) + .Decode((value) => new Date(value)) + .Encode((value) => value.toISOString()) + }) + } + ) + + const date = new Date('2024-01-01T00:00:00.000Z') + const response = await app.handle( + req('/', { + headers: { Cookie: `timestamp=${date.toISOString()}` } + }) + ) + + expect(response.status).toBe(200) + }) + + it('validate optional cookie with isOptional check', async () => { + const app = new Elysia().get( + '/', + ({ cookie }) => { + const keys = Object.keys(cookie) + return keys.length > 0 ? 'has cookies' : 'no cookies' + }, + { + cookie: t.Optional( + t.Cookie({ + session: t.Optional(t.String()) + }) + ) + } + ) + + const [withCookie, withoutCookie] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'session=value' } + }) + ), + app.handle(req('/')) + ]) + + expect(withCookie.status).toBe(200) + expect(withoutCookie.status).toBe(200) + }) + + it('validate cookie with array type', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { tags } }) => tags.value.join(','), + { + cookie: t.Cookie({ + tags: t.Array(t.String()) + }) + } + ) + + const response = await app.handle( + req('/', { + headers: { + Cookie: 'tags=' + encodeURIComponent(JSON.stringify(['tag1', 'tag2', 'tag3'])) + } + }) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('tag1,tag2,tag3') + }) + + it('validate cookie with union type', async () => { + const app = new Elysia().get( + '/', + ({ cookie: { value } }) => String(value.value), + { + cookie: t.Cookie({ + value: t.Union([t.String(), t.Numeric()]) + }) + } + ) + + const [stringValue, numericValue, invalid] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'value=text' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'value=123' } + }) + ), + app.handle( + req('/', { + headers: { Cookie: 'value=' + encodeURIComponent(JSON.stringify({ obj: true })) } + }) + ) + ]) + + expect(stringValue.status).toBe(200) + expect(numericValue.status).toBe(200) + expect(invalid.status).toBe(422) + }) + + it('inherits cookie validation on guard', async () => { + const app = new Elysia() + .guard({ + cookie: t.Cookie({ session: t.String() }) + }) + .get('/', ({ cookie: { session } }) => session.value) + .get('/profile', ({ cookie: { session } }) => `Profile: ${session.value}`) + + const [validRoot, validProfile, invalid] = await Promise.all([ + app.handle( + req('/', { + headers: { Cookie: 'session=abc123' } + }) + ), + app.handle( + req('/profile', { + headers: { Cookie: 'session=abc123' } + }) + ), + app.handle(req('/')) + ]) + + expect(validRoot.status).toBe(200) + expect(await validRoot.text()).toBe('abc123') + expect(validProfile.status).toBe(200) + expect(await validProfile.text()).toBe('Profile: abc123') + expect(invalid.status).toBe(422) + }) + + it('merge cookie config from app', async () => { + const app = new Elysia({ + cookie: { + httpOnly: true, + secure: true + } + }).get('/', ({ cookie: { session } }) => session.value ?? 'empty', { + cookie: t.Cookie({ + session: t.Optional(t.String()) + }) + }) + + const response = await app.handle( + req('/', { + headers: { Cookie: 'session=test' } + }) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('test') + }) + + it('validate empty cookie object when optional', async () => { + const app = new Elysia().get( + '/', + ({ cookie }) => (Object.keys(cookie).length === 0 ? 'empty' : 'not empty'), + { + cookie: t.Optional( + t.Cookie({ + session: t.Optional(t.String()) + }) + ) + } + ) + + const response = await app.handle(req('/')) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('empty') + }) +})