diff --git a/.changeset/provider-admin-error-status.md b/.changeset/provider-admin-error-status.md new file mode 100644 index 0000000000..cab4c6216c --- /dev/null +++ b/.changeset/provider-admin-error-status.md @@ -0,0 +1,5 @@ +--- +"@prosopo/api-express-router": patch +--- + +Honour the adapter's `errorStatusCode` and the `{ error: ... }` JSON envelope in the default endpoint adapter's catch block. `ProsopoBaseError`s now surface their own status code via `unwrapError`; unexpected errors fall back to the configured `errorStatusCode` as JSON, instead of a hardcoded 500 plain-text response. diff --git a/packages/api-express-router/src/endpointAdapter/apiExpressDefaultEndpointAdapter.ts b/packages/api-express-router/src/endpointAdapter/apiExpressDefaultEndpointAdapter.ts index 305c07262d..a0a53d5128 100644 --- a/packages/api-express-router/src/endpointAdapter/apiExpressDefaultEndpointAdapter.ts +++ b/packages/api-express-router/src/endpointAdapter/apiExpressDefaultEndpointAdapter.ts @@ -13,7 +13,11 @@ // limitations under the License. import type { ApiEndpoint } from "@prosopo/api-route"; -import { ProsopoApiError } from "@prosopo/common"; +import { + ProsopoApiError, + ProsopoBaseError, + unwrapError, +} from "@prosopo/common"; import type { LogLevel } from "@prosopo/logger"; import { stringifyBigInts } from "@prosopo/util"; import type { NextFunction, Request, Response } from "express"; @@ -58,7 +62,29 @@ class ApiExpressDefaultEndpointAdapter implements ApiExpressEndpointAdapter { err: error, })); - response.status(500).send("An internal server error occurred."); + // Errors that already carry an explicit HTTP status code (e.g. a 400 + // admin auth/validation error) should surface that code with the + // standard `{ error: ... }` JSON envelope. Everything else — base + // errors without a status code, or non-Prosopo errors — is treated as + // an unexpected failure and mapped to this adapter's configured + // errorStatusCode. In both cases the envelope is produced by + // `unwrapError` so the message/key stay consistent and localised. + const responseError = + error instanceof ProsopoApiError || + (error instanceof ProsopoBaseError && + typeof error.context?.code === "number") + ? error + : new ProsopoApiError("API.UNKNOWN", { + context: { code: this.errorStatusCode }, + silent: true, + }); + + const { code, statusMessage, jsonError } = unwrapError( + responseError, + request.i18n, + ); + response.statusMessage = statusMessage; + response.status(code).json({ error: jsonError }); } } } diff --git a/packages/api-express-router/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts b/packages/api-express-router/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts new file mode 100644 index 0000000000..a4415fac32 --- /dev/null +++ b/packages/api-express-router/src/tests/unit/apiExpressDefaultEndpointAdapter.unit.test.ts @@ -0,0 +1,141 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ApiEndpoint } from "@prosopo/api-route"; +import { ProsopoApiError, ProsopoEnvError } from "@prosopo/common"; +import { loadI18next } from "@prosopo/locale"; +import type { Logger } from "@prosopo/logger"; +import type { NextFunction, Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import type { ZodType } from "zod"; +import { ApiExpressDefaultEndpointAdapter } from "../../endpointAdapter/apiExpressDefaultEndpointAdapter.js"; + +describe("ApiExpressDefaultEndpointAdapter.handleRequest", async () => { + const i18n = await loadI18next(true); + await i18n.changeLanguage("en"); + + const ERROR_STATUS_CODE = 500; + + const makeLogger = (): Logger => + ({ + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }) as unknown as Logger; + + const makeRequest = (): Request => + ({ i18n, logger: makeLogger(), body: {} }) as unknown as Request; + + const makeResponse = (): Response => + ({ + json: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + statusMessage: "", + }) as unknown as Response; + + const makeEndpoint = ( + processRequest: () => Promise, + ): ApiEndpoint => + ({ + getRequestArgsSchema: () => undefined, + processRequest, + }) as unknown as ApiEndpoint; + + it("surfaces a ProsopoApiError's own code and JSON error envelope", async () => { + const adapter = new ApiExpressDefaultEndpointAdapter( + "info", + ERROR_STATUS_CODE, + ); + const error = new ProsopoApiError("CONTRACT.INVALID_DATA_FORMAT", { + context: { code: 400 }, + i18n, + silent: true, + }); + const endpoint = makeEndpoint(() => Promise.reject(error)); + const response = makeResponse(); + + await adapter.handleRequest( + endpoint, + makeRequest(), + response, + vi.fn() as unknown as NextFunction, + ); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: { + code: 400, + key: "CONTRACT.INVALID_DATA_FORMAT", + message: "Invalid data format", + }, + }); + }); + + it("maps non-Prosopo errors to errorStatusCode with the API.UNKNOWN envelope", async () => { + const adapter = new ApiExpressDefaultEndpointAdapter( + "info", + ERROR_STATUS_CODE, + ); + const endpoint = makeEndpoint(() => + Promise.reject(new Error("boom from processRequest")), + ); + const response = makeResponse(); + + await adapter.handleRequest( + endpoint, + makeRequest(), + response, + vi.fn() as unknown as NextFunction, + ); + + expect(response.status).toHaveBeenCalledWith(ERROR_STATUS_CODE); + expect(response.json).toHaveBeenCalledWith({ + error: { + code: ERROR_STATUS_CODE, + key: "API.UNKNOWN", + message: i18n.t("API.UNKNOWN"), + }, + }); + }); + + it("maps a base error without a status code to errorStatusCode (does not leak a 400)", async () => { + const adapter = new ApiExpressDefaultEndpointAdapter( + "info", + ERROR_STATUS_CODE, + ); + const envError = new ProsopoEnvError("GENERAL.ENVIRONMENT_NOT_READY", { + i18n, + silent: true, + }); + const endpoint = makeEndpoint(() => Promise.reject(envError)); + const response = makeResponse(); + + await adapter.handleRequest( + endpoint, + makeRequest(), + response, + vi.fn() as unknown as NextFunction, + ); + + expect(response.status).toHaveBeenCalledWith(ERROR_STATUS_CODE); + expect(response.json).toHaveBeenCalledWith({ + error: { + code: ERROR_STATUS_CODE, + key: "API.UNKNOWN", + message: i18n.t("API.UNKNOWN"), + }, + }); + }); +});