Skip to content
Merged
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
6 changes: 4 additions & 2 deletions packages/knex/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ export class KnexAdapter<
const data = filters.$limit === 0 ? [] : await builder.catch(errorHandler)

if (paginate && paginate.default) {
const total = await countBuilder.then((count) => parseInt(count[0] ? count[0].total : 0))
const total = await countBuilder
.then((count) => parseInt(count[0] ? count[0].total : 0))
.catch(errorHandler)

return {
total,
Expand Down Expand Up @@ -302,7 +304,7 @@ export class KnexAdapter<
}
const builder = this.createQuery(updateParams)

await builder.update(data, [], { includeTriggerModifications: true })
await builder.update(data, [], { includeTriggerModifications: true }).catch(errorHandler)

const items = await this._findOrGet(null, updateParams)

Expand Down
51 changes: 44 additions & 7 deletions packages/knex/src/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,50 @@ import { errors } from '@feathersjs/errors'

export const ERROR = Symbol.for('@feathersjs/knex/error')

export function errorHandler(error: any) {
const { message } = error
/**
* Knex adds the query and its bound values to errors, as "<sql> - <message>" for driver
* errors and "<message> query: <sql>" for compile errors. Keep only the message so the
* query is not sent to the client.
*/
const omitQuery = (error: any) => {
const { message, code, sqlMessage } = error

if (typeof message !== 'string') {
return message
}

// MySQL drivers keep the original message
if (typeof sqlMessage === 'string' && sqlMessage !== '' && message.endsWith(sqlMessage)) {
const withCode = `${code}: ${sqlMessage}`

return message.endsWith(withCode) ? withCode : sqlMessage
}

if (message.startsWith('Undefined binding(s) detected')) {
const index = message.indexOf(' query: ')

return index === -1 ? message : message.slice(0, index)
}

// Only database driver errors have the query in front of the message
if (
code === undefined &&
error.errno === undefined &&
error.number === undefined &&
error.sqlState === undefined
) {
return message
}

// SQLite messages start with the error code
const codeIndex = typeof code === 'string' ? message.lastIndexOf(` - ${code}: `) : -1
const index = codeIndex !== -1 ? codeIndex : message.lastIndexOf(' - ')

return index === -1 ? message : message.slice(index + 3)
}

export function errorHandler(error: any): never {
const message = omitQuery(error)
let feathersError = error

if (error.sqlState && error.sqlState.length) {
Expand Down Expand Up @@ -64,11 +106,6 @@ export function errorHandler(error: any) {
} else if (typeof error.code === 'string' && error.severity && error.routine) {
// NOTE: Error codes taken from
// https://www.postgresql.org/docs/9.6/static/errcodes-appendix.html
// Omit query information
const messages = (error.message || '').split('-')

error.message = messages[messages.length - 1]

switch (error.code.slice(0, 2)) {
case '22':
feathersError = new errors.NotFound(message)
Expand Down
241 changes: 240 additions & 1 deletion packages/knex/test/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'assert'
import { errorHandler } from '../src'
import knex from 'knex'
import { errorHandler, ERROR, KnexService } from '../src'

describe('Knex Error handler', () => {
it('sqlState', () => {
Expand Down Expand Up @@ -62,4 +63,242 @@ describe('Knex Error handler', () => {
name: 'GeneralError'
})
})

it('postgresqlError omits query information from the Feathers error', () => {
const pgError = (code: string, message: string) => ({
code,
message,
severity: 'ERROR',
routine: 'ExecConstraints'
})

assert.throws(
() =>
errorHandler(
pgError(
'22P02',
'select "users".* from "users" where "id" = $1 limit $2 - invalid input syntax for type uuid: "1"'
)
),
{
name: 'NotFound',
message: 'invalid input syntax for type uuid: "1"'
}
)

assert.throws(
() =>
errorHandler(
pgError(
'23505',
'insert into "users" ("email") values ($1) returning * - duplicate key value violates unique constraint "users_email_unique"'
)
),
{
name: 'BadRequest',
message: 'duplicate key value violates unique constraint "users_email_unique"'
}
)

// A " - " inside the SQL and a hyphen in the error text are both handled
assert.throws(
() =>
errorHandler(pgError('42703', 'select "a" - "b" from "t" - column "non-existent" does not exist')),
{
name: 'Unprocessable',
message: 'column "non-existent" does not exist'
}
)
})

it('postgresqlError keeps the raw error for server-side handling', () => {
try {
errorHandler({
code: '23505',
message: 'insert into "users" ("email") values ($1) - duplicate key value',
severity: 'ERROR',
routine: 'ExecConstraints',
constraint: 'users_email_unique'
})
assert.fail('should have thrown')
} catch (error: any) {
assert.strictEqual(error[ERROR].code, '23505')
assert.strictEqual(error[ERROR].constraint, 'users_email_unique')
}
})

it('sqlState errors omit query information', () => {
assert.throws(
() =>
errorHandler({
sqlState: '#23000',
message:
"insert into `users` (`email`) values ('a@example.com') - ER_DUP_ENTRY: Duplicate entry 'a@example.com' for key 'email'"
}),
{ name: 'BadRequest', message: "ER_DUP_ENTRY: Duplicate entry 'a@example.com' for key 'email'" }
)
})

it('SQLite errors omit query information', () => {
assert.throws(
() =>
errorHandler({
code: 'SQLITE_ERROR',
errno: 1,
message: "select `users`.* from `users` where `nope` = 'x' - SQLITE_ERROR: no such column: nope"
}),
{ name: 'BadRequest', message: 'SQLITE_ERROR: no such column: nope' }
)
})

it('other database errors omit query information', () => {
assert.throws(
() =>
errorHandler({
code: 'SQLITE_CONSTRAINT',
errno: 19,
message:
"insert into `users` (`password`) values ('secret') - SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email"
}),
{ name: 'GeneralError', message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email' }
)
})

it('MySQL errors keep the driver message when values contain " - "', () => {
assert.throws(
() =>
errorHandler({
code: 'ER_DUP_ENTRY',
errno: 1062,
sqlState: '23000',
sqlMessage: "Duplicate entry 'foo - bar' for key 'name'",
message:
"insert into `t` (`name`) values ('foo - bar') - ER_DUP_ENTRY: Duplicate entry 'foo - bar' for key 'name'"
}),
{ name: 'BadRequest', message: "ER_DUP_ENTRY: Duplicate entry 'foo - bar' for key 'name'" }
)
assert.throws(
() =>
errorHandler({
code: 'ER_DUP_ENTRY',
errno: 1062,
sqlState: '23000',
sqlMessage: "Duplicate entry 'foo - bar' for key 'name'",
message:
"insert into `t` (`name`) values ('foo - bar') - Duplicate entry 'foo - bar' for key 'name'"
}),
{ name: 'BadRequest', message: "Duplicate entry 'foo - bar' for key 'name'" }
)
})

it('SQLite errors keep the driver message when values contain " - "', () => {
assert.throws(
() =>
errorHandler({
code: 'SQLITE_CONSTRAINT',
errno: 19,
message:
"insert into `t` (`name`) values ('a - b') - SQLITE_CONSTRAINT: UNIQUE constraint failed: t.name - x"
}),
{ name: 'GeneralError', message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: t.name - x' }
)
})

it('knex compile errors omit the query', () => {
assert.throws(
() =>
errorHandler(
new Error(
'Undefined binding(s) detected when compiling SELECT. Undefined column(s): [users.id] query: select "users".* from "users" where "users"."id" = ?'
)
),
{
name: 'GeneralError',
message: 'Undefined binding(s) detected when compiling SELECT. Undefined column(s): [users.id]'
}
)
})

it('errors without a message keep the default message', () => {
assert.throws(() => errorHandler({ code: 'ECONNRESET' }), { name: 'GeneralError', message: 'Error' })
})

it('errors that are not from the database keep their message', () => {
assert.throws(() => errorHandler(new Error('Pool - closed')), {
name: 'GeneralError',
message: 'Pool - closed'
})
})

it('MySQL errors with an empty sqlMessage use the fallback', () => {
assert.throws(
() =>
errorHandler({
code: 'ER_X',
errno: 1,
sqlState: '42000',
sqlMessage: '',
message: 'select 1 - ER_X: oops'
} as any),
{ name: 'BadRequest', message: 'ER_X: oops' }
)
})

it('MSSQL errors omit query information', () => {
assert.throws(
() =>
errorHandler({
code: 'EREQUEST',
number: 2627,
message:
"insert into [users] ([email]) values ('a@example.com') - Violation of UNIQUE KEY constraint 'UQ_users'."
} as any),
{ name: 'GeneralError', message: "Violation of UNIQUE KEY constraint 'UQ_users'." }
)
})

it('does not leak queries or bound values from a real database', async () => {
const db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })

try {
await db.schema.createTable('users', (table) => {
table.increments('id')
table.string('email').unique()
table.string('password')
})
const service = new KnexService({ Model: db, name: 'users' })

await service.create({ email: 'a@example.com', password: 'hash-1' })

await assert.rejects(
() => service.create({ email: 'a@example.com', password: 'secret-hash-2' }),
(error: any) => {
assert.ok(!error.message.includes('insert into'))
assert.ok(!error.message.includes('secret-hash-2'))
assert.ok(!error.toJSON().message.includes('insert into'))
assert.ok(!error.toJSON().message.includes('secret-hash-2'))
return true
}
)
const other = await service.create({ email: 'b@example.com', password: 'hash-3' })

await assert.rejects(
() => service.patch(other.id, { email: 'a@example.com', password: 'secret-hash-4' }),
(error: any) => {
assert.ok(!error.message.includes('update'))
assert.ok(!error.message.includes('secret-hash-4'))
return true
}
)
await assert.rejects(
() => service.find({ query: { nope: 'x' } } as any),
(error: any) => {
assert.ok(!error.message.includes('select'))
return true
}
)
} finally {
await db.destroy()
}
})
})
Loading