Skip to content

Commit

Permalink
feat: add header validation utilities (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
mrnagydavid authored Oct 21, 2024
1 parent 6d89f74 commit ac1e459
Show file tree
Hide file tree
Showing 4 changed files with 227 additions and 7 deletions.
104 changes: 103 additions & 1 deletion src/server/validation/validateMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { jsonSchema, StringMap } from '@naturalcycles/js-lib'
import { debugResource } from '../../test/debug.resource'
import { expressTestService } from '../../testing'
import { ExpressApp, expressTestService } from '../../testing'
import { getDefaultRouter } from '../getDefaultRouter'
import { validateHeaders } from './validateMiddleware'

const app = expressTestService.createAppFromResource(debugResource)
afterAll(async () => {
Expand Down Expand Up @@ -39,3 +42,102 @@ Input: { pw: 'REDACTED' }",
}
`)
})

describe('validateHeader', () => {
let app: ExpressApp
interface TestResponse {
ok: 1
headers: StringMap<any>
}

beforeAll(async () => {
const resource = getDefaultRouter()
const schema = jsonSchema.object({
shortstring: jsonSchema.string().min(8).max(16),
numeric: jsonSchema.string(),
bool: jsonSchema.string(),
sessionid: jsonSchema.string(),
})
resource.get('/', validateHeaders(schema, { redactPaths: ['sessionid'] }), async (req, res) => {
res.json({ ok: 1, headers: req.headers })
})
app = expressTestService.createAppFromResource(resource)
})

afterAll(async () => {
await app.close()
})

test('should pass valid headers', async () => {
const response = await app.get<TestResponse>('', {
headers: {
shortstring: 'shortstring',
numeric: '123',
bool: '1',
sessionid: 'sessionid',
},
})

expect(response).toMatchObject({ ok: 1 })
expect(response.headers).toEqual({
shortstring: 'shortstring',
numeric: '123',
bool: '1',
sessionid: 'sessionid',
})
})

test('should throw error on invalid headers', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
numeric: '123',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain(
'request headers/shortstring must NOT have fewer than 8 characters',
)
})

test('should list all errors (and not stop at the first error)', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
// numeric: '123',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain(
'request headers/shortstring must NOT have fewer than 8 characters',
)
expect(err.cause.message).toContain("request headers must have required property 'numeric'")
})

test('should redact sensitive data', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
numeric: '127',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain("REDACTED: 'REDACTED'")
expect(err.cause.message).not.toContain('sessionid')
})
})
11 changes: 9 additions & 2 deletions src/server/validation/validateMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ export function validateQuery(
return validateObject('query', schema, opt)
}

export function validateHeaders(
schema: JsonSchema | JsonSchemaBuilder | AjvSchema,
opt: ReqValidationOptions<AjvValidationError> = {},
): BackendRequestHandler {
return validateObject('headers', schema, opt)
}

/**
* Validates req property (body, params or query).
* Supports Joi schema or AjvSchema (from nodejs-lib).
* Able to redact sensitive data from the error message.
* Throws http 400 on error.
*/
function validateObject(
prop: 'body' | 'params' | 'query',
prop: 'body' | 'params' | 'query' | 'headers',
schema: JsonSchema | JsonSchemaBuilder | AjvSchema,
opt: ReqValidationOptions<AjvValidationError> = {},
): BackendRequestHandler {
Expand Down Expand Up @@ -75,6 +82,6 @@ function redact(redactPaths: string[], obj: any, error: Error): void {
.map(path => _get(obj, path) as string)
.filter(Boolean)
.forEach(secret => {
error.message = error.message.replace(secret, REDACTED)
error.message = error.message.replaceAll(secret, REDACTED)
})
}
107 changes: 105 additions & 2 deletions src/server/validation/validateRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { _inspect } from '@naturalcycles/nodejs-lib'
import { StringMap } from '@naturalcycles/js-lib'
import { _inspect, numberSchema, objectSchema, stringSchema } from '@naturalcycles/nodejs-lib'
import { debugResource } from '../../test/debug.resource'
import { expressTestService } from '../../testing'
import { ExpressApp, expressTestService } from '../../testing'
import { getDefaultRouter } from '../getDefaultRouter'
import { validateRequest } from './validateRequest'

const app = expressTestService.createAppFromResource(debugResource)
afterAll(async () => {
Expand Down Expand Up @@ -52,3 +55,103 @@ test('validateRequest', async () => {
[1] "pw" length must be at least 8 characters long"
`)
})

describe('validateRequest.headers', () => {
let app: ExpressApp
interface TestResponse {
ok: 1
headers: StringMap<any>
}

beforeAll(async () => {
const resource = getDefaultRouter()
resource.get('/', async (req, res) => {
validateRequest.headers(
req,
objectSchema<any>({
shortstring: stringSchema.min(8).max(16),
numeric: numberSchema,
bool: stringSchema,
sessionid: stringSchema,
}),
{ redactPaths: ['sessionid'] },
)

res.json({ ok: 1, headers: req.headers })
})
app = expressTestService.createAppFromResource(resource)
})

afterAll(async () => {
await app.close()
})

test('should pass valid headers', async () => {
const response = await app.get<TestResponse>('', {
headers: {
shortstring: 'shortstring',
numeric: '123',
bool: '1',
sessionid: 'sessionid',
},
})

expect(response).toMatchObject({ ok: 1 })
expect(response.headers).toEqual({
shortstring: 'shortstring',
numeric: 123,
bool: '1',
sessionid: 'sessionid',
})
})

test('should throw error on invalid headers', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
numeric: '123',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain('"shortstring" length must be at least 8 characters long')
})

test('should list all errors (and not stop at the first error)', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
numeric: 'text',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain('"shortstring" length must be at least 8 characters long')
expect(err.cause.message).toContain('"numeric" must be a number')
})

test('should redact sensitive data', async () => {
const err = await app.expectError({
url: '',
method: 'GET',
headers: {
shortstring: 'short',
numeric: '127',
bool: '1',
sessionid: 'sessionid',
},
})

expect(err.data.responseStatusCode).toBe(400)
expect(err.cause.message).toContain('"REDACTED": "REDACTED"')
expect(err.cause.message).not.toContain('sessionid')
})
})
12 changes: 10 additions & 2 deletions src/server/validation/validateRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function redact(redactPaths: string[], obj: any, error: Error): void {
.map(path => _get(obj, path) as string)
.filter(Boolean)
.forEach(secret => {
error.message = error.message.replace(secret, REDACTED)
error.message = error.message.replaceAll(secret, REDACTED)
})
}

Expand Down Expand Up @@ -55,9 +55,17 @@ class ValidateRequest {
return this.validate(req, 'params', schema, opt)
}

headers<T>(
req: BackendRequest,
schema: AnySchema<T>,
opt: ReqValidationOptions<JoiValidationError> = {},
): T {
return this.validate(req, 'headers', schema, opt)
}

private validate<T>(
req: BackendRequest,
reqProperty: 'body' | 'params' | 'query',
reqProperty: 'body' | 'params' | 'query' | 'headers',
schema: AnySchema<T>,
opt: ReqValidationOptions<JoiValidationError> = {},
): T {
Expand Down

0 comments on commit ac1e459

Please sign in to comment.