diff --git a/src/connectors/io.ts b/src/connectors/io.ts index e02d13cc..ab1ba52f 100644 --- a/src/connectors/io.ts +++ b/src/connectors/io.ts @@ -13,21 +13,43 @@ export async function readConnectorConfig( defaultConfig: T, ): Promise { await ensureConnectorHome(connectorId); + const configPath = getConnectorConfigPath(connectorId); + + let rawConfig: string; try { - return { - ...defaultConfig, - ...(JSON.parse( - await readFile(getConnectorConfigPath(connectorId), "utf8"), - ) as T), - }; + rawConfig = await readFile(configPath, "utf8"); } catch (error) { if (isFileNotFoundError(error)) { return defaultConfig; } - throw error; + throw new Error( + `Failed to read connector config for ${connectorId} at ${configPath}: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(rawConfig); + } catch (error) { + throw new Error( + `Invalid JSON in connector config for ${connectorId} at ${configPath}: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + + if (!isJsonObject(parsedConfig)) { + throw new Error( + `Invalid connector config for ${connectorId} at ${configPath}: expected a JSON object.`, + ); } + + return { + ...defaultConfig, + ...parsedConfig, + }; } export async function readConnectorState( @@ -106,3 +128,11 @@ function isFileNotFoundError(error: unknown): boolean { (error as NodeJS.ErrnoException).code === "ENOENT" ); } + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/test/connector-config.test.ts b/test/connector-config.test.ts index 1fdacd2a..a3778fe6 100644 --- a/test/connector-config.test.ts +++ b/test/connector-config.test.ts @@ -1,5 +1,137 @@ -import { describe, expect, test } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { normalizeStringArray } from "../src/connectors/config.ts"; +import type { ConnectorId } from "../src/connectors/types.ts"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempHomes: string[] = []; + +afterEach(async () => { + vi.resetModules(); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-connector-config-")); + tempHomes.push(home); + return home; +} + +async function writeConnectorConfigRaw( + home: string, + connectorId: ConnectorId, + contents: string, +): Promise { + const configPath = getTestConnectorConfigPath(home, connectorId); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, contents, "utf8"); + + return configPath; +} + +function getTestConnectorConfigPath( + home: string, + connectorId: ConnectorId, +): string { + return path.join(home, ".openwiki", "connectors", connectorId, "config.json"); +} + +async function loadConnectorIo(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + + return import("../src/connectors/io.ts"); +} + +describe("readConnectorConfig", () => { + test("returns the default config when the config file is missing", async () => { + const home = await createTempHome(); + const { readConnectorConfig } = await loadConnectorIo(home); + + await expect( + readConnectorConfig("google", { + enabled: false, + format: "metadata", + maxMessages: 10, + }), + ).resolves.toEqual({ + enabled: false, + format: "metadata", + maxMessages: 10, + }); + }); + + test("merges a valid object config over defaults", async () => { + const home = await createTempHome(); + await writeConnectorConfigRaw( + home, + "google", + `${JSON.stringify({ enabled: true, maxMessages: 5 }, null, 2)}\n`, + ); + const { readConnectorConfig } = await loadConnectorIo(home); + + await expect( + readConnectorConfig("google", { + enabled: false, + format: "metadata", + maxMessages: 10, + }), + ).resolves.toEqual({ + enabled: true, + format: "metadata", + maxMessages: 5, + }); + }); + + test("adds connector and path context for invalid JSON", async () => { + const home = await createTempHome(); + const configPath = await writeConnectorConfigRaw(home, "slack", "{"); + const { readConnectorConfig } = await loadConnectorIo(home); + + await expect( + readConnectorConfig("slack", { enabled: false }), + ).rejects.toThrow("Invalid JSON in connector config for slack"); + await expect( + readConnectorConfig("slack", { enabled: false }), + ).rejects.toThrow(configPath); + }); + + test.each([ + ["null", "null"], + ["array", "[]"], + ["boolean", "true"], + ["number", "42"], + ["string", JSON.stringify("enabled")], + ])("rejects %s JSON config values", async (_label, contents) => { + const home = await createTempHome(); + const configPath = await writeConnectorConfigRaw(home, "x", contents); + const { readConnectorConfig } = await loadConnectorIo(home); + + await expect(readConnectorConfig("x", { enabled: false })).rejects.toThrow( + `Invalid connector config for x at ${configPath}: expected a JSON object.`, + ); + }); +}); describe("normalizeStringArray", () => { test("keeps non-empty strings and trims each", () => {