Skip to content

Commit

Permalink
chore: clean up error handling (#33)
Browse files Browse the repository at this point in the history
This change:

- Normalizes response bodies to something like this:

  ```
  {
    "error": {
      "code": "SOMETHING_BAD",
      "message": "Something went wrong"
    }
  }
  ```

- Adds error codes for various errors

- Changes some 403s to 401s for correctness

See also: <digidem/comapeo-core#955>
  • Loading branch information
EvanHahn authored Nov 6, 2024
1 parent 458f706 commit 5ad90ff
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 33 deletions.
53 changes: 53 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class HttpError extends Error {
/**
* @readonly
* @prop {number}
*/
statusCode

/**
* @readonly
* @prop {string}
*/
code

/**
* @param {number} statusCode
* @param {Uppercase<string>} code
* @param {string} message
*/
constructor(statusCode, code, message) {
super(message)
this.statusCode = statusCode
this.code = code
}
}

export const invalidBearerToken = () =>
new HttpError(401, 'UNAUTHORIZED', 'Invalid bearer token')

export const projectNotInAllowlist = () =>
new HttpError(403, 'PROJECT_NOT_IN_ALLOWLIST', 'Project not allowed')

export const tooManyProjects = () =>
new HttpError(
403,
'TOO_MANY_PROJECTS',
'Server is already linked to the maximum number of projects',
)

export const projectNotFoundError = () =>
new HttpError(404, 'PROJECT_NOT_FOUND', 'Project not found')

/**
* @param {string} str
* @returns {string}
*/
export const normalizeCode = (str) => {
switch (str) {
case 'FST_ERR_VALIDATION':
return 'BAD_REQUEST'
default:
return str.toUpperCase().replace(/[^A-Z]/gu, '_')
}
}
53 changes: 36 additions & 17 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import timingSafeEqual from 'string-timing-safe-equal'

import assert from 'node:assert/strict'
import * as fs from 'node:fs'
import { STATUS_CODES } from 'node:http'

import * as errors from './errors.js'
import * as schemas from './schemas.js'
import { wsCoreReplicator } from './ws-core-replicator.js'

Expand Down Expand Up @@ -41,10 +43,32 @@ export default async function routes(
*/
const verifyBearerAuth = (req) => {
if (!isBearerTokenValid(req.headers.authorization, serverBearerToken)) {
throw fastify.httpErrors.forbidden('Invalid bearer token')
throw errors.invalidBearerToken()
}
}

fastify.setErrorHandler((error, _req, reply) => {
/** @type {number} */
let statusCode = error.statusCode || 500
if (
!Number.isInteger(statusCode) ||
statusCode < 400 ||
statusCode >= 600
) {
statusCode = 500
}

const code = errors.normalizeCode(
typeof error.code === 'string'
? error.code
: STATUS_CODES[statusCode] || 'ERROR',
)

const { message = 'Server error' } = error

reply.status(statusCode).send({ error: { code, message } })
})

fastify.get('/', (_req, reply) => {
const stream = fs.createReadStream(INDEX_HTML_PATH)
reply.header('Content-Type', 'text/html')
Expand All @@ -62,7 +86,6 @@ export default async function routes(
name: Type.String(),
}),
}),
500: { $ref: 'HttpError' },
},
},
},
Expand All @@ -86,11 +109,11 @@ export default async function routes(
data: Type.Array(
Type.Object({
projectId: Type.String(),
name: Type.String(),
name: Type.Optional(Type.String()),
}),
),
}),
403: { $ref: 'HttpError' },
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
Expand Down Expand Up @@ -122,7 +145,7 @@ export default async function routes(
deviceId: schemas.HEX_STRING_32_BYTES,
}),
}),
400: { $ref: 'HttpError' },
400: schemas.errorResponse,
},
},
},
Expand Down Expand Up @@ -152,16 +175,14 @@ export default async function routes(
allowedProjectsSetOrNumber instanceof Set &&
!allowedProjectsSetOrNumber.has(projectPublicId)
) {
throw fastify.httpErrors.forbidden('Project not allowed')
throw errors.projectNotInAllowlist()
}

if (
typeof allowedProjectsSetOrNumber === 'number' &&
existingProjects.length >= allowedProjectsSetOrNumber
) {
throw fastify.httpErrors.forbidden(
'Server is already linked to the maximum number of projects',
)
throw errors.tooManyProjects()
}
}

Expand Down Expand Up @@ -222,7 +243,7 @@ export default async function routes(
projectPublicId: BASE32_STRING_32_BYTES,
}),
response: {
404: { $ref: 'HttpError' },
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
Expand Down Expand Up @@ -253,8 +274,7 @@ export default async function routes(
200: Type.Object({
data: Type.Array(schemas.observationResult),
}),
403: { $ref: 'HttpError' },
404: { $ref: 'HttpError' },
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
Expand Down Expand Up @@ -305,8 +325,7 @@ export default async function routes(
body: schemas.remoteDetectionAlertToAdd,
response: {
201: Type.Literal(''),
403: { $ref: 'HttpError' },
404: { $ref: 'HttpError' },
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
Expand Down Expand Up @@ -352,8 +371,8 @@ export default async function routes(
),
}),
response: {
403: { $ref: 'HttpError' },
404: { $ref: 'HttpError' },
200: {},
'4xx': schemas.errorResponse,
},
},
async preHandler(req) {
Expand Down Expand Up @@ -413,7 +432,7 @@ async function ensureProjectExists(fastify, req) {
await fastify.comapeo.getProject(req.params.projectPublicId)
} catch (e) {
if (e instanceof Error && e.message.startsWith('NotFound')) {
throw fastify.httpErrors.notFound('Project not found')
throw errors.projectNotFoundError()
}
throw e
}
Expand Down
7 changes: 7 additions & 0 deletions src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ const dateTimeString = Type.String({ format: 'date-time' })
const latitude = Type.Number({ minimum: -90, maximum: 90 })
const longitude = Type.Number({ minimum: -180, maximum: 180 })

export const errorResponse = Type.Object({
error: Type.Object({
code: Type.String(),
message: Type.String(),
}),
})

export const projectToAdd = Type.Object({
projectName: Type.String({ minLength: 1 }),
projectKey: HEX_STRING_32_BYTES,
Expand Down
18 changes: 11 additions & 7 deletions test/add-alerts-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
/** @import { RemoteDetectionAlertValue } from '@comapeo/schema'*/
/** @import { FastifyInstance } from 'fastify' */

test('returns a 403 if no auth is provided', async (t) => {
test('returns a 401 if no auth is provided', async (t) => {
const server = createTestServer(t)

const response = await server.inject({
Expand All @@ -30,10 +30,11 @@ test('returns a 403 if no auth is provided', async (t) => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
assert.equal(response.statusCode, 401)
assert.equal(response.json().error.code, 'UNAUTHORIZED')
})

test('returns a 403 if incorrect auth is provided', async (t) => {
test('returns a 401 if incorrect auth is provided', async (t) => {
const server = createTestServer(t)

const projectPublicId = await addProject(server)
Expand All @@ -47,22 +48,24 @@ test('returns a 403 if incorrect auth is provided', async (t) => {
},
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
assert.equal(response.statusCode, 401)
assert.equal(response.json().error.code, 'UNAUTHORIZED')
})

test('returns a 403 if trying to add alerts to a non-existent project', async (t) => {
test('returns a 404 if trying to add alerts to a non-existent project', async (t) => {
const server = createTestServer(t)

const response = await server.inject({
method: 'POST',
url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`,
headers: {
Authorization: 'Bearer bad',
Authorization: 'Bearer ' + BEARER_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(generateAlert()),
})
assert.equal(response.statusCode, 403)
assert.equal(response.statusCode, 404)
assert.equal(response.json().error.code, 'PROJECT_NOT_FOUND')
})

test('returns a 400 if trying to add invalid alerts', async (t) => {
Expand Down Expand Up @@ -154,6 +157,7 @@ test('returns a 400 if trying to add invalid alerts', async (t) => {
400,
`${body} should be invalid and return a 400`,
)
assert.equal(response.json().error.code, 'BAD_REQUEST')
}),
)
})
Expand Down
14 changes: 12 additions & 2 deletions test/add-project-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('request missing project name', async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test('request with empty project name', async (t) => {
Expand All @@ -32,6 +33,7 @@ test('request with empty project name', async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test('request missing project key', async (t) => {
Expand All @@ -44,6 +46,7 @@ test('request missing project key', async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test("request with a project key that's too short", async (t) => {
Expand All @@ -56,6 +59,7 @@ test("request with a project key that's too short", async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test('request missing any encryption keys', async (t) => {
Expand All @@ -68,6 +72,7 @@ test('request missing any encryption keys', async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test('request missing an encryption key', async (t) => {
Expand All @@ -84,6 +89,7 @@ test('request missing an encryption key', async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test("request with an encryption key that's too short", async (t) => {
Expand All @@ -100,6 +106,7 @@ test("request with an encryption key that's too short", async (t) => {
})

assert.equal(response.statusCode, 400)
assert.equal(response.json().error.code, 'BAD_REQUEST')
})

test('adding a project', async (t) => {
Expand Down Expand Up @@ -133,7 +140,8 @@ test('adding a second project fails by default', async (t) => {
body: randomAddProjectBody(),
})
assert.equal(response.statusCode, 403)
assert.match(response.json().message, /maximum number of projects/u)
assert.equal(response.json().error.code, 'TOO_MANY_PROJECTS')
assert.match(response.json().error.message, /maximum number of projects/u)
})

test('allowing a maximum number of projects', async (t) => {
Expand All @@ -157,7 +165,8 @@ test('allowing a maximum number of projects', async (t) => {
body: randomAddProjectBody(),
})
assert.equal(response.statusCode, 403)
assert.match(response.json().message, /maximum number of projects/u)
assert.equal(response.json().error.code, 'TOO_MANY_PROJECTS')
assert.match(response.json().error.message, /maximum number of projects/u)
})
})

Expand Down Expand Up @@ -189,6 +198,7 @@ test(
body: randomAddProjectBody(),
})
assert.equal(response.statusCode, 403)
assert.equal(response.json().error.code, 'PROJECT_NOT_IN_ALLOWLIST')
})
},
)
Expand Down
1 change: 1 addition & 0 deletions test/allowed-hosts.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ test('disallowed host', async (t) => {
})

assert.equal(response.statusCode, 403)
assert.equal(response.json().error.code, 'FORBIDDEN')
})
3 changes: 2 additions & 1 deletion test/list-projects-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ test('listing projects', async (t) => {
url: '/projects',
headers: { Authorization: 'Bearer bad' },
})
assert.equal(response.statusCode, 403)
assert.equal(response.statusCode, 401)
assert.equal(response.json().error.code, 'UNAUTHORIZED')
})

await t.test('with no projects', async () => {
Expand Down
Loading

0 comments on commit 5ad90ff

Please sign in to comment.