Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions src/connectors/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,43 @@ export async function readConnectorConfig<T extends object>(
defaultConfig: T,
): Promise<T> {
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(
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
134 changes: 133 additions & 1 deletion test/connector-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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", () => {
Expand Down