Skip to content

feat: add ValidationException #694

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -11,8 +11,8 @@
"lint": "eslint --ext .ts ./src",
"lint:fix": "npm run lint -- --fix",
"pretest": "echo ' 🔑 Creating valid keypair for testing' && sh test/make-private-keys.sh &> /dev/null",
"test:unit": "mocha -r dotenv/config -r ts-node/register ./src/**/*.spec.ts",
"test:integration": "mocha -r dotenv/config -r ts-node/register --timeout 10000 ./test/**/*.test.ts",
"test:unit": "mocha -r dotenv/config -r ts-node/register './src/**/*.spec.ts'",
"test:integration": "mocha -r dotenv/config -r ts-node/register --timeout 10000 './test/**/*.test.ts'",
"test": "npm run test:unit && npm run test:integration",
"build": "rm -rf lib && tsc",
"build:docs": "typedoc --options .typedocrc.json src",
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export { getManagementToken } from './keys'
export { signRequest, verifyRequest, ContentfulHeader, ExpiredRequestException } from './requests'
export {
signRequest,
verifyRequest,
ContentfulHeader,
ExpiredRequestException,
ValidationException,
} from './requests'

export type {
AppActionCallContext,
12 changes: 12 additions & 0 deletions src/requests/exceptions.ts
Original file line number Diff line number Diff line change
@@ -8,3 +8,15 @@ export class ExpiredRequestException extends Error {
this.message = `[${this.constructor.name}]: Requests are expected to be verified within ${this.ttl}s from their signature.`
}
}

export class ValidationException extends Error {
constructor(
message: string,
/* eslint-disable no-unused-vars */
readonly constraintName?: string,
readonly key?: string,
/* eslint-enable no-unused-vars */
) {
super(message)
}
}
2 changes: 1 addition & 1 deletion src/requests/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { ExpiredRequestException } from './exceptions'
export { ExpiredRequestException, ValidationException } from './exceptions'
export { signRequest } from './sign-request'
export { verifyRequest } from './verify-request'
export { ContentfulHeader, ContentfulContextHeader } from './typings'
11 changes: 6 additions & 5 deletions src/requests/sign-request.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as assert from 'assert'
import { ValidationException } from './exceptions'
import { CanonicalRequest, Secret } from './typings'
import { signRequest } from './sign-request'

@@ -21,7 +22,7 @@ const assertThrowsForFieldInValues = (field: keyof CanonicalRequest, values: any
},
VALID_TIMESTAMP,
)
}, `Did not throw for ${field.toString()}:${value}`)
}, ValidationException)
}
}

@@ -59,9 +60,9 @@ describe('create-signature', () => {

for (const secret of invalidSecrets) {
assert.throws(() => {
// @ts-ignore
// @ts-expect-error
signRequest(secret, VALID_REQUEST, VALID_TIMESTAMP)
}, `Did not throw for ${secret}`)
}, ValidationException)
}
})
it('does not throw if valid', () => {
@@ -77,9 +78,9 @@ describe('create-signature', () => {

for (const timestamp of invalidTimestamps) {
assert.throws(() => {
// @ts-ignore
// @ts-expect-error
signRequest(VALID_SECRET, VALID_REQUEST, timestamp)
}, `Did not throw for ${timestamp}`)
}, ValidationException)
}
})
it('does not throw if missing', () => {
59 changes: 0 additions & 59 deletions src/requests/typings/validators.ts

This file was deleted.

78 changes: 78 additions & 0 deletions src/requests/typings/validators/index.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This is the same as src/typings/validators.ts. The only difference is that all objects are wrapped in proxyValidationError().

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as runtypes from 'runtypes'
import { proxyValidationError } from './proxy-validation-error'

const MethodValidator = proxyValidationError(
runtypes.Union(
runtypes.Literal('GET'),
runtypes.Literal('PATCH'),
runtypes.Literal('HEAD'),
runtypes.Literal('POST'),
runtypes.Literal('DELETE'),
runtypes.Literal('OPTIONS'),
runtypes.Literal('PUT'),
),
)

const PathValidator = proxyValidationError(
runtypes.String.withConstraint((s) => s.startsWith('/'), {
name: 'CanonicalURI',
}),
)

const SignatureValidator = proxyValidationError(
runtypes.String.withConstraint((s) => s.length === 64, {
name: 'SignatureLength',
}),
)

export const CanonicalRequestValidator = proxyValidationError(
runtypes
.Record({
method: MethodValidator,
path: PathValidator,
})
.And(
runtypes.Partial({
headers: runtypes.Dictionary(runtypes.String, 'string'),
body: runtypes.String,
}),
),
)
export type CanonicalRequest = runtypes.Static<typeof CanonicalRequestValidator>

export const SecretValidator = proxyValidationError(
runtypes.String.withConstraint((s) => s.length === 64, {
name: 'SecretLength',
}),
)
export type Secret = runtypes.Static<typeof SecretValidator>

// Only dates after 01-01-2020
export const TimestampValidator = proxyValidationError(
runtypes.Number.withConstraint((n) => n > 1577836800000, {
name: 'TimestampAge',
}),
)
export type Timestamp = runtypes.Static<typeof TimestampValidator>

const SignedHeadersValidator = proxyValidationError(
runtypes
.Array(runtypes.String)
.withConstraint((l) => l.length >= 2, { name: 'MissingTimestampOrSignedHeaders' }),
)

export const RequestMetadataValidator = proxyValidationError(
runtypes.Record({
signature: SignatureValidator,
timestamp: TimestampValidator,
signedHeaders: SignedHeadersValidator,
}),
)
export type RequestMetadata = runtypes.Static<typeof RequestMetadataValidator>

export const TimeToLiveValidator = proxyValidationError(
runtypes.Number.withConstraint((n) => n >= 0, {
name: 'PositiveNumber',
}),
)
export type TimeToLive = runtypes.Static<typeof TimeToLiveValidator>
88 changes: 88 additions & 0 deletions src/requests/typings/validators/proxy-validation-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as assert from 'assert'
import * as runtypes from 'runtypes'
import { ValidationException } from '../../exceptions'
import { proxyValidationError } from './proxy-validation-error'

describe('proxyValidationError', () => {
describe('scalar without named constraint', () => {
it('converts to ValidationException', () => {
assert.throws(() => {
try {
proxyValidationError(
runtypes.Union(runtypes.Literal('val1'), runtypes.Literal('val2')),
).check('invalid')
} catch (error) {
if (error instanceof ValidationException) {
assert.strictEqual(error.constraintName, undefined)
assert.strictEqual(error.key, undefined)
}

throw error
}
}, ValidationException)
})
})

describe('scalar with named constraint', () => {
it('converts to ValidationException without constraint name', () => {
assert.throws(() => {
try {
proxyValidationError(
runtypes.String.withConstraint((s) => s === 'value', { name: 'constraint-name' }),
).check('invalid')
} catch (error) {
if (error instanceof ValidationException) {
assert.strictEqual(error.constraintName, 'constraint-name')
assert.strictEqual(error.key, undefined)
}

throw error
}
}, ValidationException)
})
})

describe('object without named constraint', () => {
it('converts to ValidationException with key', () => {
assert.throws(() => {
try {
proxyValidationError(
runtypes.Record({
field: runtypes.Union(runtypes.Literal('val1'), runtypes.Literal('val2')),
}),
).check({ field: 'invalid' })
} catch (error) {
if (error instanceof ValidationException) {
assert.strictEqual(error.constraintName, undefined)
assert.strictEqual(error.key, 'field')
}

throw error
}
}, ValidationException)
})
})

describe('object with named constraint', () => {
it('converts to ValidationException with key and constraint name', () => {
assert.throws(() => {
try {
proxyValidationError(
runtypes.Record({
field: runtypes.String.withConstraint((s) => s === 'value', {
name: 'constraint-name',
}),
}),
).check({ field: 'invalid' })
} catch (error) {
if (error instanceof ValidationException) {
assert.strictEqual(error.constraintName, 'constraint-name')
assert.strictEqual(error.key, 'field')
}

throw error
}
}, ValidationException)
})
})
})
38 changes: 38 additions & 0 deletions src/requests/typings/validators/proxy-validation-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as runtypes from 'runtypes'
import { ValidationException } from '../../exceptions'

const NAMED_CONSTRAINT_FAILURE_MSG = /^Failed (.+?) check/

// eslint-disable-next-line no-unused-vars
export function proxyValidationError<T extends { check: (...args: unknown[]) => unknown }>(
constraint: T,
): T {
// eslint-disable-next-line no-undef
return new Proxy(constraint, {
get(target, property) {
if (property !== 'check') {
return target[property as keyof T]
}

return (...args: unknown[]) => {
try {
return target.check(...args)
} catch (error) {
if (error instanceof runtypes.ValidationError) {
let constraintName = undefined
if ('name' in constraint && typeof constraint.name === 'string') {
constraintName = constraint.name
} else {
const result = NAMED_CONSTRAINT_FAILURE_MSG.exec(error.message)
constraintName = result ? result[1] : undefined
}

throw new ValidationException(error.message, constraintName, error.key)
}

throw error
}
}
},
})
}
8 changes: 4 additions & 4 deletions src/requests/verify-request.spec.ts
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import {
Context,
} from './typings'
import { signRequest } from './sign-request'
import { ExpiredRequestException } from './exceptions'
import { ExpiredRequestException, ValidationException } from './exceptions'

const makeContextHeaders = (subject?: { appId: string } | { userId: string }) => {
return subject
@@ -203,14 +203,14 @@ describe('verifyRequest', () => {

delete incomingRequest.headers[ContentfulHeader.Signature]

assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest))
assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest), ValidationException)
})
it('throws when missing timestamp', () => {
const incomingRequest = makeIncomingRequest({}, makeContextHeaders(contextHeaders))

delete incomingRequest.headers[ContentfulHeader.Timestamp]

assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest))
assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest), ValidationException)
})
it('throws when missing signed headers', () => {
const incomingRequest = makeIncomingRequest(
@@ -222,7 +222,7 @@ describe('verifyRequest', () => {

delete incomingRequest.headers[ContentfulHeader.SignedHeaders]

assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest))
assert.throws(() => verifyRequest(VALID_SECRET, incomingRequest), ValidationException)
})
})