diff --git a/src/connectors/sources/gmail.ts b/src/connectors/sources/gmail.ts index 56cd3aba..64611b50 100644 --- a/src/connectors/sources/gmail.ts +++ b/src/connectors/sources/gmail.ts @@ -83,16 +83,19 @@ async function ingest( options: ConnectorIngestOptions = {}, ): Promise { const runId = createRunId(); - const config = await readConnectorConfig("google", { - enabled: true, - format: "full", - includeSpamTrash: false, - labelIds: [], - maxMessages: 100, - metadataHeaders: DEFAULT_METADATA_HEADERS, - pageSize: 100, - query: "newer_than:1d", - }); + const config = { + ...(await readConnectorConfig("google", { + enabled: true, + format: "full", + includeSpamTrash: false, + labelIds: [], + maxMessages: 100, + metadataHeaders: DEFAULT_METADATA_HEADERS, + pageSize: 100, + query: "newer_than:1d", + })), + ...((options.connectorConfig ?? {}) as GmailConfig), + }; const state = await readConnectorState("google"); const warnings: string[] = []; const rawFiles: string[] = []; diff --git a/src/connectors/sources/slack.ts b/src/connectors/sources/slack.ts index bc7a13b7..c91014cb 100644 --- a/src/connectors/sources/slack.ts +++ b/src/connectors/sources/slack.ts @@ -154,16 +154,19 @@ async function ingest( options: ConnectorIngestOptions = {}, ): Promise { const runId = createRunId(); - const config = await readConnectorConfig("slack", { - assistantSearchQueries: [], - conversationScanLimit: 500, - conversationTypes: DEFAULT_CONVERSATION_TYPES, - enabled: false, - maxConversations: 50, - messagesPerConversation: 50, - myMessagesSearchLimit: 20, - streams: DEFAULT_STREAMS, - }); + const config = { + ...(await readConnectorConfig("slack", { + assistantSearchQueries: [], + conversationScanLimit: 500, + conversationTypes: DEFAULT_CONVERSATION_TYPES, + enabled: false, + maxConversations: 50, + messagesPerConversation: 50, + myMessagesSearchLimit: 20, + streams: DEFAULT_STREAMS, + })), + ...((options.connectorConfig ?? {}) as SlackConfig), + }; const state = await readConnectorState("slack"); const warnings: string[] = []; const rawFiles: string[] = []; diff --git a/src/connectors/sources/x.ts b/src/connectors/sources/x.ts index ff0ff56d..1ea53a1c 100644 --- a/src/connectors/sources/x.ts +++ b/src/connectors/sources/x.ts @@ -67,12 +67,15 @@ async function ingest( options: ConnectorIngestOptions = {}, ): Promise { const runId = createRunId(); - const config = await readConnectorConfig("x", { - enabled: false, - listIds: [], - maxPagesPerStream: 2, - streams: DEFAULT_STREAMS, - }); + const config = { + ...(await readConnectorConfig("x", { + enabled: false, + listIds: [], + maxPagesPerStream: 2, + streams: DEFAULT_STREAMS, + })), + ...((options.connectorConfig ?? {}) as XConfig), + }; const state = await readConnectorState("x"); const warnings: string[] = []; const rawFiles: string[] = []; diff --git a/test/connector-config-overrides.test.ts b/test/connector-config-overrides.test.ts new file mode 100644 index 00000000..ba1b8153 --- /dev/null +++ b/test/connector-config-overrides.test.ts @@ -0,0 +1,171 @@ +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"; + +// The gmail, slack, and x connectors read their on-disk config from +// `~/.openwiki/connectors//config.json` (a path derived from +// `os.homedir()`, which honors $HOME). These tests point $HOME at a throwaway +// temp dir and write a config file there, so we can assert that per-instance +// `options.connectorConfig` overrides are merged on top of the on-disk config +// — the bug this PR fixes, where gmail/slack/x silently ignored the overrides. +// +// Each connector short-circuits before any network call: +// 1. an `enabled` gate -> returns status "skipped" when disabled +// 2. a credentials gate -> returns status "error" when the token env is unset +// so the observable effect of the merge is which gate the run stops at, with no +// `fetch` involved. + +const originalHome = process.env.HOME; +const tempHomes: string[] = []; + +const CONNECTOR_ENV_KEYS = [ + "OPENWIKI_X_ACCESS_TOKEN", + "OPENWIKI_SLACK_USER_TOKEN", +] as const; +const savedEnv: Record = {}; + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-connector-config-")); + tempHomes.push(home); + return home; +} + +async function writeConnectorConfig( + home: string, + connectorId: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", connectorId); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +afterEach(async () => { + vi.resetModules(); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + for (const key of CONNECTOR_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +function clearConnectorTokens(): void { + for (const key of CONNECTOR_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +} + +async function loadXConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + clearConnectorTokens(); + const { createXConnector } = await import("../src/connectors/sources/x.ts"); + return createXConnector(); +} + +async function loadSlackConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + clearConnectorTokens(); + const { createSlackConnector } = + await import("../src/connectors/sources/slack.ts"); + return createSlackConnector(); +} + +async function loadGmailConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + clearConnectorTokens(); + const { createGmailConnector } = + await import("../src/connectors/sources/gmail.ts"); + return createGmailConnector(); +} + +describe("x connector honors options.connectorConfig", () => { + test("skips when on-disk config is disabled and no override is given", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, "x", { enabled: false }); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + }); + + test("override enabled=true moves past the enabled gate to the token gate", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, "x", { enabled: false }); + const connector = await loadXConnector(home); + + const result = await connector.ingest({ + connectorConfig: { enabled: true }, + }); + + // Merge honored: enabled gate passed, so it stops at the missing-token gate. + // If the override were ignored (the bug), status would still be "skipped". + expect(result.status).toBe("error"); + expect(result.message).toContain("OPENWIKI_X_ACCESS_TOKEN"); + }); +}); + +describe("slack connector honors options.connectorConfig", () => { + test("skips when on-disk config is disabled and no override is given", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, "slack", { enabled: false }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + }); + + test("override enabled=true moves past the enabled gate to the token gate", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, "slack", { enabled: false }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest({ + connectorConfig: { enabled: true }, + }); + + expect(result.status).toBe("error"); + expect(result.message).toContain("OPENWIKI_SLACK_USER_TOKEN"); + }); +}); + +describe("gmail connector honors options.connectorConfig", () => { + test("override enabled=false skips even though on-disk config is enabled", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, "google", { enabled: true }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest({ + connectorConfig: { enabled: false }, + }); + + // Merge honored: the override disables the run before any Gmail API call. + // If the override were ignored (the bug), it would proceed past this gate. + expect(result.status).toBe("skipped"); + }); +});