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); + } +} 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); + }); }); } 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..3938325 100644 --- a/packages/lib/test/e2e/index.test.ts +++ b/packages/lib/test/e2e/index.test.ts @@ -35,6 +35,7 @@ definePluginsApiTests(esmConfig); defineAuthApiTests(cjsConfig); defineAuthApiTests(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"); + }); +}); 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(); + }); + }); +});