From 552fb5c6f35d3bfeae5a6319208e05eaedb86d65 Mon Sep 17 00:00:00 2001 From: Miguel Cruces Date: Mon, 15 Jun 2026 10:53:17 +0200 Subject: [PATCH 1/4] fix(ai): return 404 instead of 500 when a conversation is not found --- packages/lib/src/tlm-ai/aiController.ts | 5 +++++ packages/lib/src/tlm-ai/aiService.ts | 3 ++- packages/lib/src/tlm-ai/exceptions.ts | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/lib/src/tlm-ai/exceptions.ts diff --git a/packages/lib/src/tlm-ai/aiController.ts b/packages/lib/src/tlm-ai/aiController.ts index d675b58..94436cc 100644 --- a/packages/lib/src/tlm-ai/aiController.ts +++ b/packages/lib/src/tlm-ai/aiController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { getAiService } from './aiService.js'; +import { ConversationNotFoundError } from './exceptions.js'; export async function createConversation(req: Request, res: Response) { try { @@ -60,6 +61,10 @@ export async function sendMessage(req: Request, res: Response) { const messages = await getAiService().sendMessage(conversationId, content); res.json(messages); } catch (err: any) { + if (err instanceof ConversationNotFoundError) { + res.status(404).json({ error: err.message }); + return; + } res.status(500).json({ error: err.message }); } } diff --git a/packages/lib/src/tlm-ai/aiService.ts b/packages/lib/src/tlm-ai/aiService.ts index 425a924..06f8585 100644 --- a/packages/lib/src/tlm-ai/aiService.ts +++ b/packages/lib/src/tlm-ai/aiService.ts @@ -1,6 +1,7 @@ import OpenAI from 'openai'; import { OasTlmConfig } from '../config/config.types.js'; import { agent } from './agent.js'; +import { ConversationNotFoundError } from './exceptions.js'; type Message = { role: 'user' | 'assistant' | 'function' | 'system'; content: string; name?: string; timestamp: string }; type Conversation = { id: string; messages: Message[]; name?: string }; @@ -40,7 +41,7 @@ class AIService { async sendMessage(conversationId: string, content: string, model?: string): Promise { const conversation = this.conversations.get(conversationId); - if (!conversation) throw new Error('Conversation not found'); + if (!conversation) throw new ConversationNotFoundError(); conversation.messages.push({ role: 'system', timestamp: new Date().toISOString(), diff --git a/packages/lib/src/tlm-ai/exceptions.ts b/packages/lib/src/tlm-ai/exceptions.ts new file mode 100644 index 0000000..12f3397 --- /dev/null +++ b/packages/lib/src/tlm-ai/exceptions.ts @@ -0,0 +1,7 @@ +export class ConversationNotFoundError extends Error { + constructor(message: string = 'Conversation not found') { + super(message); + this.name = 'ConversationNotFoundError'; + Object.setPrototypeOf(this, ConversationNotFoundError.prototype); + } +} From 9b2a79799263186922e6a9a970e5a159a1ece194 Mon Sep 17 00:00:00 2001 From: Miguel Cruces Date: Mon, 15 Jun 2026 10:57:18 +0200 Subject: [PATCH 2/4] test: add e2e test for utils & unit test for AI --- packages/lib/test/e2e/definitions/utils.ts | 180 ++++++++++++++++++ .../lib/test/e2e/fixtures/valid-spec.json | 19 ++ packages/lib/test/e2e/index.test.ts | 4 + .../lib/test/e2e/servers/otTestServer.cjs | 7 +- .../lib/test/e2e/servers/otTestServer.mjs | 7 +- packages/lib/test/e2e/servers/otTestServer.ts | 9 +- packages/lib/test/unit/ai.test.ts | 130 +++++++++++++ 7 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 packages/lib/test/e2e/definitions/utils.ts create mode 100644 packages/lib/test/e2e/fixtures/valid-spec.json create mode 100644 packages/lib/test/unit/ai.test.ts diff --git a/packages/lib/test/e2e/definitions/utils.ts b/packages/lib/test/e2e/definitions/utils.ts new file mode 100644 index 0000000..fec7e87 --- /dev/null +++ b/packages/lib/test/e2e/definitions/utils.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import axios from "axios"; +import { E2ETestConfig } from "../index.test"; +import { startServer } from "../utils/serverStarter"; +import { ChildProcess } from "child_process"; +import path from "path"; + +const validSpecPath = path.resolve(process.cwd(), "test/e2e/fixtures/valid-spec.json"); +const invalidSpecPath = path.resolve(process.cwd(), "test/e2e/fixtures/non-existent-spec.json"); + +function stopServer(proc: ChildProcess | undefined): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) { + resolve(); + return; + } + proc.once("exit", () => { + resolve(); + }); + proc.kill(); + }); +} + +export function defineUtilsApiTests(config: E2ETestConfig) { + const { label, port, telemetryPath } = config; + const baseUrl = `http://localhost:${port}`; + const telemetryUrl = `${baseUrl}${telemetryPath}`; + const utilsUrl = `${telemetryUrl}/utils`; + + describe(`Utils API Tests - ${label}`, () => { + + describe("Case: specFileName and spec given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv, + OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: validSpecPath + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return the spec loaded from the file", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(200); + expect(response.data.info.title).toBe("Valid Spec for Testing"); + }); + }); + + describe("Case: only specFileName given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv, + OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: validSpecPath, + OASTLM_TEST_NO_SPEC: "true" + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return the spec loaded from the file", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(200); + expect(response.data.info.title).toBe("Valid Spec for Testing"); + }); + }); + + describe("Case: invalid specFileName given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv, + OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: invalidSpecPath, + OASTLM_TEST_NO_SPEC: "true" + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return 404 not found", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(404); + }); + }); + + describe("Case: only spec given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return the spec defined in userConfig", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(200); + expect(response.data.paths).toBeDefined(); + expect(response.data.paths["/api/v1/pets"]).toBeDefined(); + }); + }); + + describe("Case: invalid spec given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv, + OASTLM_TEST_INVALID_SPEC: "true" + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return 404 not found", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(404); + }); + }); + + describe("Case: none given", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer({ + ...config, + additionalEnv: { + ...config.additionalEnv, + OASTLM_TEST_NO_SPEC: "true" + } + }); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + it("should return 404 not found", async () => { + const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); + expect(response.status).toBe(404); + }); + }); + + describe("Heap stats and OAS telemetry spec checks", () => { + let serverProcess: ChildProcess | undefined; + beforeAll(async () => { + serverProcess = await startServer(config); + }); + afterAll(async () => { + await stopServer(serverProcess); + }); + + it("should fetch heapStats without throwing errors", async () => { + const response = await axios.get(`${utilsUrl}/heapStats`).catch(err => err.response); + expect(response.status).toBe(200); + expect(response.data.units).toBe("MB"); + expect(typeof response.data.total_heap_size).toBe("number"); + }); + + it("should fetch oas-telemetry-spec without throwing errors", async () => { + const response = await axios.get(`${utilsUrl}/oas-telemetry-spec`).catch(err => err.response); + expect(response.status).toBe(200); + expect(response.data.paths).toBeDefined(); + }); + }); + }); +} diff --git a/packages/lib/test/e2e/fixtures/valid-spec.json b/packages/lib/test/e2e/fixtures/valid-spec.json new file mode 100644 index 0000000..31cbcaa --- /dev/null +++ b/packages/lib/test/e2e/fixtures/valid-spec.json @@ -0,0 +1,19 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Valid Spec for Testing", + "version": "1.0.0" + }, + "paths": { + "/api/v1/pets": { + "get": { + "summary": "Get pets", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/packages/lib/test/e2e/index.test.ts b/packages/lib/test/e2e/index.test.ts index 4117fcb..6bb666d 100644 --- a/packages/lib/test/e2e/index.test.ts +++ b/packages/lib/test/e2e/index.test.ts @@ -3,6 +3,7 @@ import { defineLogsApiTests } from './definitions/logs'; import { defineMetricsApiTests } from './definitions/metrics'; import { definePluginsApiTests } from './definitions/plugins'; import { defineAuthApiTests } from './definitions/auth'; +import { defineUtilsApiTests } from './definitions/utils'; const cjsConfig: E2ETestConfig = { @@ -35,6 +36,9 @@ definePluginsApiTests(esmConfig); defineAuthApiTests(cjsConfig); defineAuthApiTests(esmConfig); +defineUtilsApiTests(cjsConfig); +defineUtilsApiTests(esmConfig); + export interface E2ETestConfig { label: string; serverScript: string; diff --git a/packages/lib/test/e2e/servers/otTestServer.cjs b/packages/lib/test/e2e/servers/otTestServer.cjs index c92ae4a..c37828e 100644 --- a/packages/lib/test/e2e/servers/otTestServer.cjs +++ b/packages/lib/test/e2e/servers/otTestServer.cjs @@ -71,7 +71,12 @@ const spec = { } -app.use(oasTelemetry({ general: { spec: JSON.stringify(spec) } })); +let specValue = JSON.stringify(spec); +if (process.env.OASTLM_TEST_INVALID_SPEC) { + specValue = ": : :"; +} +const userConfig = process.env.OASTLM_TEST_NO_SPEC ? {} : { general: { spec: specValue } }; +app.use(oasTelemetry(userConfig)); const logger = logs.getLogger('PetClinic', '1.0.0'); const meter = metrics.getMeter('PetClinic', '1.0.0'); diff --git a/packages/lib/test/e2e/servers/otTestServer.mjs b/packages/lib/test/e2e/servers/otTestServer.mjs index 5abf2a0..fba12c3 100644 --- a/packages/lib/test/e2e/servers/otTestServer.mjs +++ b/packages/lib/test/e2e/servers/otTestServer.mjs @@ -70,7 +70,12 @@ const spec = { } } -app.use(oasTelemetry({ general: { spec: JSON.stringify(spec) } })); +let specValue = JSON.stringify(spec); +if (process.env.OASTLM_TEST_INVALID_SPEC) { + specValue = ": : :"; +} +const userConfig = process.env.OASTLM_TEST_NO_SPEC ? {} : { general: { spec: specValue } }; +app.use(oasTelemetry(userConfig)); const logger = logs.getLogger('PetClinic', '1.0.0'); const meter = metrics.getMeter('PetClinic', '1.0.0'); diff --git a/packages/lib/test/e2e/servers/otTestServer.ts b/packages/lib/test/e2e/servers/otTestServer.ts index fedc96c..de063bb 100644 --- a/packages/lib/test/e2e/servers/otTestServer.ts +++ b/packages/lib/test/e2e/servers/otTestServer.ts @@ -79,9 +79,14 @@ const spec = { } } +let specValue = JSON.stringify(spec); +if (process.env.OASTLM_TEST_INVALID_SPEC) { + specValue = ": : :"; +} + const oasTlmConfig: UserConfig = { - general: { - spec: JSON.stringify(spec), + general: process.env.OASTLM_TEST_NO_SPEC ? {} : { + spec: specValue, }, traces: { // extraExporters: [new ConsoleSpanExporter()], diff --git a/packages/lib/test/unit/ai.test.ts b/packages/lib/test/unit/ai.test.ts new file mode 100644 index 0000000..55a2483 --- /dev/null +++ b/packages/lib/test/unit/ai.test.ts @@ -0,0 +1,130 @@ +import { vi, describe, expect, it, beforeAll, afterAll } from "vitest"; +import express from "express"; +import axios from "axios"; +import { getAIRoutes } from "../../src/tlm-ai/aiRoutes"; +import { Server } from "http"; + +vi.mock("openai", () => { + return { + default: class MockOpenAI { + chat = { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Mocked AI Response", + }, + finish_reason: "stop", + }, + ], + }), + }, + }; + }, + }; +}); + +describe("AI API Unit/Integration Tests", () => { + let app: express.Express; + let server: Server; + let port: number; + let baseUrl: string; + + const mockConfig: any = { + ai: { + openAIKey: "mock-key", + openAIModel: "gpt-3.5-turbo", + extraContextPrompts: [] + } + }; + + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use("/ai", getAIRoutes(mockConfig)); + + return new Promise((resolve) => { + server = app.listen(0, () => { + const addr = server.address(); + if (addr && typeof addr !== "string") { + port = addr.port; + baseUrl = `http://localhost:${port}/ai`; + } + resolve(); + }); + }); + }); + + afterAll(() => { + return new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + it("should report AI service as healthy", async () => { + const response = await axios.get(`${baseUrl}/chat/health`); + expect(response.status).toBe(200); + expect(response.data).toBe("AI service is healthy"); + }); + + it("should create, list, retrieve, message, and delete a conversation", async () => { + // 1. Create conversation + const createResponse = await axios.post(`${baseUrl}/chat`); + expect(createResponse.status).toBe(201); + expect(createResponse.data).toHaveProperty("id"); + expect(Array.isArray(createResponse.data.messages)).toBe(true); + const conversationId = createResponse.data.id; + + // 2. List conversations + const listResponse = await axios.get(`${baseUrl}/chat`); + expect(listResponse.status).toBe(200); + expect(Array.isArray(listResponse.data)).toBe(true); + expect(listResponse.data.some((c: any) => c.id === conversationId)).toBe(true); + + // 3. Get conversation history + const historyResponse = await axios.get(`${baseUrl}/chat/${conversationId}`); + expect(historyResponse.status).toBe(200); + expect(historyResponse.data.id).toBe(conversationId); + + // 4. Send message + const messagePayload = { content: "Tell me about this API" }; + const messageResponse = await axios.post(`${baseUrl}/chat/${conversationId}/message`, messagePayload); + expect(messageResponse.status).toBe(200); + expect(Array.isArray(messageResponse.data)).toBe(true); + expect(messageResponse.data.some((m: any) => m.content === "Mocked AI Response")).toBe(true); + + // 5. Delete conversation + const deleteResponse = await axios.delete(`${baseUrl}/chat/${conversationId}`); + expect(deleteResponse.status).toBe(204); + + // 6. Verify deleted (should return 404) + const getDeletedResponse = await axios.get(`${baseUrl}/chat/${conversationId}`).catch((err) => err.response); + expect(getDeletedResponse.status).toBe(404); + }); + + it("should return 400 when sending message with missing content", async () => { + const createResponse = await axios.post(`${baseUrl}/chat`); + const conversationId = createResponse.data.id; + + const response = await axios.post(`${baseUrl}/chat/${conversationId}/message`, {}).catch((err) => err.response); + expect(response.status).toBe(400); + expect(response.data.error).toBe("Missing content"); + + await axios.delete(`${baseUrl}/chat/${conversationId}`); + }); + + it("should return 404 when sending message to a non-existent conversation", async () => { + const response = await axios.post(`${baseUrl}/chat/non-existent-conv/message`, { content: "hello" }).catch((err) => err.response); + expect(response.status).toBe(404); + expect(response.data.error).toContain("Conversation not found"); + }); + + it("should return 404 when deleting a non-existent conversation", async () => { + const response = await axios.delete(`${baseUrl}/chat/non-existent-conv`).catch((err) => err.response); + expect(response.status).toBe(404); + expect(response.data.error).toBe("Not found"); + }); +}); From 3af5d862e63f4c2e689a54bce769bcbb1e15c764 Mon Sep 17 00:00:00 2001 From: Miguel Cruces Date: Thu, 18 Jun 2026 10:15:23 +0200 Subject: [PATCH 3/4] test(utils): make it an unit test, mocking configs for each one Prevents spinning up a new server for every test --- packages/lib/test/e2e/definitions/utils.ts | 180 -------------------- packages/lib/test/e2e/index.test.ts | 3 - packages/lib/test/unit/utils.test.ts | 186 +++++++++++++++++++++ 3 files changed, 186 insertions(+), 183 deletions(-) delete mode 100644 packages/lib/test/e2e/definitions/utils.ts create mode 100644 packages/lib/test/unit/utils.test.ts diff --git a/packages/lib/test/e2e/definitions/utils.ts b/packages/lib/test/e2e/definitions/utils.ts deleted file mode 100644 index fec7e87..0000000 --- a/packages/lib/test/e2e/definitions/utils.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from "vitest"; -import axios from "axios"; -import { E2ETestConfig } from "../index.test"; -import { startServer } from "../utils/serverStarter"; -import { ChildProcess } from "child_process"; -import path from "path"; - -const validSpecPath = path.resolve(process.cwd(), "test/e2e/fixtures/valid-spec.json"); -const invalidSpecPath = path.resolve(process.cwd(), "test/e2e/fixtures/non-existent-spec.json"); - -function stopServer(proc: ChildProcess | undefined): Promise { - return new Promise((resolve) => { - if (!proc || proc.killed) { - resolve(); - return; - } - proc.once("exit", () => { - resolve(); - }); - proc.kill(); - }); -} - -export function defineUtilsApiTests(config: E2ETestConfig) { - const { label, port, telemetryPath } = config; - const baseUrl = `http://localhost:${port}`; - const telemetryUrl = `${baseUrl}${telemetryPath}`; - const utilsUrl = `${telemetryUrl}/utils`; - - describe(`Utils API Tests - ${label}`, () => { - - describe("Case: specFileName and spec given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv, - OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: validSpecPath - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return the spec loaded from the file", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(200); - expect(response.data.info.title).toBe("Valid Spec for Testing"); - }); - }); - - describe("Case: only specFileName given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv, - OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: validSpecPath, - OASTLM_TEST_NO_SPEC: "true" - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return the spec loaded from the file", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(200); - expect(response.data.info.title).toBe("Valid Spec for Testing"); - }); - }); - - describe("Case: invalid specFileName given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv, - OASTLM_CONFIG_GENERAL_SPEC_FILE_NAME: invalidSpecPath, - OASTLM_TEST_NO_SPEC: "true" - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return 404 not found", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(404); - }); - }); - - describe("Case: only spec given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return the spec defined in userConfig", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(200); - expect(response.data.paths).toBeDefined(); - expect(response.data.paths["/api/v1/pets"]).toBeDefined(); - }); - }); - - describe("Case: invalid spec given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv, - OASTLM_TEST_INVALID_SPEC: "true" - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return 404 not found", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(404); - }); - }); - - describe("Case: none given", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer({ - ...config, - additionalEnv: { - ...config.additionalEnv, - OASTLM_TEST_NO_SPEC: "true" - } - }); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - it("should return 404 not found", async () => { - const response = await axios.get(`${utilsUrl}/spec`).catch(err => err.response); - expect(response.status).toBe(404); - }); - }); - - describe("Heap stats and OAS telemetry spec checks", () => { - let serverProcess: ChildProcess | undefined; - beforeAll(async () => { - serverProcess = await startServer(config); - }); - afterAll(async () => { - await stopServer(serverProcess); - }); - - it("should fetch heapStats without throwing errors", async () => { - const response = await axios.get(`${utilsUrl}/heapStats`).catch(err => err.response); - expect(response.status).toBe(200); - expect(response.data.units).toBe("MB"); - expect(typeof response.data.total_heap_size).toBe("number"); - }); - - it("should fetch oas-telemetry-spec without throwing errors", async () => { - const response = await axios.get(`${utilsUrl}/oas-telemetry-spec`).catch(err => err.response); - expect(response.status).toBe(200); - expect(response.data.paths).toBeDefined(); - }); - }); - }); -} diff --git a/packages/lib/test/e2e/index.test.ts b/packages/lib/test/e2e/index.test.ts index 6bb666d..3938325 100644 --- a/packages/lib/test/e2e/index.test.ts +++ b/packages/lib/test/e2e/index.test.ts @@ -3,7 +3,6 @@ import { defineLogsApiTests } from './definitions/logs'; import { defineMetricsApiTests } from './definitions/metrics'; import { definePluginsApiTests } from './definitions/plugins'; import { defineAuthApiTests } from './definitions/auth'; -import { defineUtilsApiTests } from './definitions/utils'; const cjsConfig: E2ETestConfig = { @@ -36,8 +35,6 @@ definePluginsApiTests(esmConfig); defineAuthApiTests(cjsConfig); defineAuthApiTests(esmConfig); -defineUtilsApiTests(cjsConfig); -defineUtilsApiTests(esmConfig); export interface E2ETestConfig { label: string; diff --git a/packages/lib/test/unit/utils.test.ts b/packages/lib/test/unit/utils.test.ts new file mode 100644 index 0000000..825e084 --- /dev/null +++ b/packages/lib/test/unit/utils.test.ts @@ -0,0 +1,186 @@ +import { vi, describe, expect, it, beforeEach } from "vitest"; +import { specLoader, heapStats, getOasTelemetrySpec } from "../../src/tlm-util/utilController"; +import { readFileSync } from "fs"; +import v8 from "node:v8"; + +vi.mock("fs", () => ({ + readFileSync: vi.fn() +})); + +vi.mock("node:v8", () => ({ + default: { + getHeapStatistics: vi.fn(() => ({ + total_heap_size: 1024 * 1024 * 10, + used_heap_size: 1024 * 1024 * 5 + })) + } +})); + +const makeMockResponse = () => { + const res: any = { + headers: {} as Record, + statusCode: 200, + body: null as any, + setHeader(name: string, value: string) { + this.headers[name] = value; + return this; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + send(data: any) { + this.body = typeof data === "object" && data !== null ? JSON.stringify(data) : data; + return this; + } + }; + return res; +}; + +describe("Utils API Unit Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Case: specFileName and spec given", () => { + it("should return the spec loaded from the file", () => { + const mockConfig: any = { + general: { + specFileName: "mock-spec.json", + spec: JSON.stringify({ info: { title: "Config Spec" } }) + } + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ info: { title: "Valid Spec for Testing" } })); + + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(readFileSync).toHaveBeenCalledWith("mock-spec.json", { encoding: "utf8", flag: "r" }); + expect(res.statusCode).toBe(200); + expect(res.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(res.body); + expect(parsed.info.title).toBe("Valid Spec for Testing"); + }); + }); + + describe("Case: only specFileName given", () => { + it("should return the spec loaded from the file", () => { + const mockConfig: any = { + general: { + specFileName: "mock-spec.json" + } + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ info: { title: "Valid Spec for Testing" } })); + + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(readFileSync).toHaveBeenCalledWith("mock-spec.json", { encoding: "utf8", flag: "r" }); + expect(res.statusCode).toBe(200); + expect(res.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(res.body); + expect(parsed.info.title).toBe("Valid Spec for Testing"); + }); + }); + + describe("Case: invalid specFileName given", () => { + it("should return 404 not found", () => { + const mockConfig: any = { + general: { + specFileName: "non-existent.json" + } + }; + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(res.statusCode).toBe(404); + }); + }); + + describe("Case: only spec given", () => { + it("should return the spec defined in userConfig", () => { + const mockConfig: any = { + general: { + spec: JSON.stringify({ paths: { "/api/v1/pets": {} } }) + } + }; + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(res.statusCode).toBe(200); + expect(res.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(res.body); + expect(parsed.paths).toBeDefined(); + expect(parsed.paths["/api/v1/pets"]).toBeDefined(); + }); + }); + + describe("Case: invalid spec given", () => { + it("should return 404 not found", () => { + const mockConfig: any = { + general: { + spec: ": : :" + } + }; + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(res.statusCode).toBe(404); + }); + }); + + describe("Case: none given", () => { + it("should return 404 not found", () => { + const mockConfig: any = { + general: {} + }; + const req: any = {}; + const res = makeMockResponse(); + + specLoader(req, res, mockConfig); + + expect(res.statusCode).toBe(404); + }); + }); + + describe("Heap stats and OAS telemetry spec checks", () => { + it("should fetch heapStats without throwing errors", () => { + const req: any = {}; + const res = makeMockResponse(); + + heapStats(req, res); + + expect(res.statusCode).toBe(200); + const parsed = JSON.parse(res.body); + expect(parsed.units).toBe("MB"); + expect(typeof parsed.total_heap_size).toBe("number"); + }); + + it("should fetch oas-telemetry-spec without throwing errors", () => { + vi.mocked(readFileSync).mockReturnValue("paths:\n /api/v1/telemetry:\n get:\n summary: telemetry"); + const req: any = {}; + const res = makeMockResponse(); + + getOasTelemetrySpec(req, res); + + expect(res.statusCode).toBe(200); + expect(res.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(res.body); + expect(parsed.paths).toBeDefined(); + }); + }); +}); From a24654b289374cba425d97b4f3db3e047491877a Mon Sep 17 00:00:00 2001 From: Miguel Cruces Date: Thu, 18 Jun 2026 10:15:31 +0200 Subject: [PATCH 4/4] test(auth): incorrect payload types, different secrets and expired tokens --- packages/lib/test/e2e/definitions/auth.ts | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/lib/test/e2e/definitions/auth.ts b/packages/lib/test/e2e/definitions/auth.ts index 37d254f..f8e775d 100644 --- a/packages/lib/test/e2e/definitions/auth.ts +++ b/packages/lib/test/e2e/definitions/auth.ts @@ -1,5 +1,6 @@ import { describe, expect, it, beforeAll, afterAll } from "vitest"; import axios from "axios"; +import jwt from "jsonwebtoken"; import { E2ETestConfig } from "../index.test"; import { startServer } from "../utils/serverStarter"; import { ChildProcess } from "child_process"; @@ -96,5 +97,56 @@ export function defineAuthApiTests(config: E2ETestConfig) { const response = await axios.get(`${baseUrl}/api/v1/pets`); expect(response.status).toBe(200); }); + + it("[e2e][Auth][-] should reject access token signed with a different JWT secret", async () => { + const invalidToken = jwt.sign({ type: "access" }, "different_secret", { expiresIn: 300 }); + const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, { + headers: { Cookie: `oas-tlm-access-token=${invalidToken}` } + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); + + it("[e2e][Auth][-] should reject refresh token signed with a different JWT secret", async () => { + const invalidRefreshToken = jwt.sign({ type: "refresh" }, "different_secret", { expiresIn: 300 }); + const response = await axios.post(`${authUrl}/refresh`, {}, { + headers: { Cookie: `oas-tlm-refresh-token=${invalidRefreshToken}` }, + withCredentials: true + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); + + it("[e2e][Auth][-] should reject access token with incorrect payload type", async () => { + const wrongTypeToken = jwt.sign({ type: "refresh" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: 300 }); + const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, { + headers: { Cookie: `oas-tlm-access-token=${wrongTypeToken}` } + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); + + it("[e2e][Auth][-] should reject refresh token with incorrect payload type", async () => { + const wrongTypeRefreshToken = jwt.sign({ type: "access" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: 300 }); + const response = await axios.post(`${authUrl}/refresh`, {}, { + headers: { Cookie: `oas-tlm-refresh-token=${wrongTypeRefreshToken}` }, + withCredentials: true + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); + + it("[e2e][Auth][-] should reject expired access token", async () => { + const expiredToken = jwt.sign({ type: "access" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: -10 }); + const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, { + headers: { Cookie: `oas-tlm-access-token=${expiredToken}` } + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); + + it("[e2e][Auth][-] should reject expired refresh token", async () => { + const expiredRefreshToken = jwt.sign({ type: "refresh" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: -10 }); + const response = await axios.post(`${authUrl}/refresh`, {}, { + headers: { Cookie: `oas-tlm-refresh-token=${expiredRefreshToken}` }, + withCredentials: true + }).catch((err) => err.response); + expect(response.status).toBe(401); + }); }); }