Skip to content
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

feat: add ValidationException #694

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -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",
Expand Down
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,
Expand Down
12 changes: 12 additions & 0 deletions src/requests/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
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'

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

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
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)
})
})
})
37 changes: 37 additions & 0 deletions src/requests/typings/validators/proxy-validation-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 object>(constraint: T): T {
// eslint-disable-next-line no-undef
return new Proxy(constraint, {
get(target, property) {
const value = target[property as keyof T]
if (typeof value !== 'function') {
return value
}

return (...args: unknown[]) => {
try {
return value(...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
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -222,7 +222,7 @@ describe('verifyRequest', () => {

delete incomingRequest.headers[ContentfulHeader.SignedHeaders]

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

Expand Down