History

Merge remote-tracking branch 'origin/codex/batch11-local-mcp-migration-test' into codex/effect-lint-integration

Rhys Sullivan committed 6 months ago

Commit 73f312b2 parents 0237029 4d967931 file · +180 −175Browse files

Unified · Split

apps/local/src/server/migrate-mcp-bindings.test.ts+180−175
@@ -4,200 +4,205 @@ · 3 unmodified lines
44 // queryParams), runs the migration runner, asserts the auth columns
55 // are populated and the child tables hold the secret-backed entries.
66
7−import { describe, expect, it } from "@effect/vitest";
7+import { afterEach, describe, expect, it } from "@effect/vitest";
88 import { Database } from "bun:sqlite";
99 import { mkdtempSync, rmSync } from "node:fs";
1010 import { join } from "node:path";
1111 import { tmpdir } from "node:os";
1212 import { drizzle } from "drizzle-orm/bun-sqlite";
1313 import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14+import { Schema } from "effect";
1415
1516 import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
1617
1718 const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
1819
20+const ConfigJson = Schema.fromJsonString(
21+ Schema.Struct({
22+ auth: Schema.optional(Schema.Unknown),
23+ command: Schema.optional(Schema.String),
24+ endpoint: Schema.optional(Schema.String),
25+ transport: Schema.String,
26+ }),
27+);
28+
29+const tempDirs: Array<string> = [];
30+
31+const makeDbPath = () => {
32+ const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
33+ tempDirs.push(dir);
34+ return join(dir, "test.sqlite");
35+};
36+
1937 describe("0007_normalize_plugin_secret_refs (mcp)", () => {
20− it("flattens header auth into auth_kind/auth_secret_id columns", () => {
21− const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
22− const dbPath = join(dir, "test.sqlite");
23− try {
24− const db = new Database(dbPath);
25− db.exec(PRE_0007_SQL);
26− stampPriorMigrationsApplied(db);
27−
28− db.prepare(
29− "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
30− ).run(
31− "default-scope",
32− "remote-headers",
33− "Remote Headers",
34− JSON.stringify({
35− transport: "remote",
36− endpoint: "https://example.com/mcp",
37− auth: {
38− kind: "header",
39− headerName: "X-API-Key",
40− secretId: "tok-secret",
41− prefix: "Bearer ",
42− },
43− }),
44− Date.now(),
45− );
46−
47− db.close();
48− const drizzleDb = drizzle(new Database(dbPath));
49− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
50−
51− const after = new Database(dbPath, { readonly: true });
52− const row = after
53− .prepare(
54− "SELECT auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, config FROM mcp_source WHERE id = ?",
55− )
56− .get("remote-headers") as {
57− auth_kind: string;
58− auth_header_name: string;
59− auth_secret_id: string;
60− auth_secret_prefix: string;
61− config: string;
62− };
63− expect(row.auth_kind).toBe("header");
64− expect(row.auth_header_name).toBe("X-API-Key");
65− expect(row.auth_secret_id).toBe("tok-secret");
66− expect(row.auth_secret_prefix).toBe("Bearer ");
67− // The auth key should be stripped from config json after migration.
68− const config = JSON.parse(row.config);
69− expect(config.auth).toBeUndefined();
70− expect(config.transport).toBe("remote");
71− expect(config.endpoint).toBe("https://example.com/mcp");
72− after.close();
73− } finally {
38+ afterEach(() => {
39+ for (const dir of tempDirs.splice(0)) {
7440 rmSync(dir, { recursive: true, force: true });
7541 }
7642 });
7743
44+ it("flattens header auth into auth_kind/auth_secret_id columns", () => {
45+ const dbPath = makeDbPath();
46+ const db = new Database(dbPath);
47+ db.exec(PRE_0007_SQL);
48+ stampPriorMigrationsApplied(db);
49+
50+ db.prepare(
51+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
52+ ).run(
53+ "default-scope",
54+ "remote-headers",
55+ "Remote Headers",
56+ JSON.stringify({
57+ transport: "remote",
58+ endpoint: "https://example.com/mcp",
59+ auth: {
60+ kind: "header",
61+ headerName: "X-API-Key",
62+ secretId: "tok-secret",
63+ prefix: "Bearer ",
64+ },
65+ }),
66+ Date.now(),
67+ );
68+
69+ db.close();
70+ const drizzleDb = drizzle(new Database(dbPath));
71+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
72+
73+ const after = new Database(dbPath, { readonly: true });
74+ const row = after
75+ .prepare(
76+ "SELECT auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, config FROM mcp_source WHERE id = ?",
77+ )
78+ .get("remote-headers") as {
79+ auth_kind: string;
80+ auth_header_name: string;
81+ auth_secret_id: string;
82+ auth_secret_prefix: string;
83+ config: string;
84+ };
85+ expect(row.auth_kind).toBe("header");
86+ expect(row.auth_header_name).toBe("X-API-Key");
87+ expect(row.auth_secret_id).toBe("tok-secret");
88+ expect(row.auth_secret_prefix).toBe("Bearer ");
89+ // The auth key should be stripped from config json after migration.
90+ const config = Schema.decodeUnknownSync(ConfigJson)(row.config);
91+ expect(config.auth).toBeUndefined();
92+ expect(config.transport).toBe("remote");
93+ expect(config.endpoint).toBe("https://example.com/mcp");
94+ after.close();
95+ });
96+
7897 it("flattens oauth2 auth and explodes headers into child rows", () => {
79− const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
80− const dbPath = join(dir, "test.sqlite");
81− try {
82− const db = new Database(dbPath);
83− db.exec(PRE_0007_SQL);
84− stampPriorMigrationsApplied(db);
85−
86− db.prepare(
87− "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
88− ).run(
89− "default-scope",
90− "remote-oauth",
91− "Remote OAuth",
92− JSON.stringify({
93− transport: "remote",
94− endpoint: "https://oauth.example/mcp",
95− headers: {
96− "X-Trace": "static",
97− "X-Token": { secretId: "extra-tok" },
98− },
99− queryParams: {
100− org: { secretId: "org-id-secret" },
101− },
102− auth: {
103− kind: "oauth2",
104− connectionId: "conn-1",
105− clientIdSecretId: "client-id-sec",
106− clientSecretSecretId: "client-secret-sec",
107− },
108− }),
109− Date.now(),
110− );
111−
112− db.close();
113− const drizzleDb = drizzle(new Database(dbPath));
114− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
115−
116− const after = new Database(dbPath, { readonly: true });
117− const row = after
118− .prepare(
119− "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id FROM mcp_source WHERE id = ?",
120− )
121− .get("remote-oauth") as Record<string, string | null>;
122− expect(row.auth_kind).toBe("oauth2");
123− expect(row.auth_connection_id).toBe("conn-1");
124− expect(row.auth_client_id_secret_id).toBe("client-id-sec");
125− expect(row.auth_client_secret_secret_id).toBe("client-secret-sec");
126−
127− const headers = after
128− .prepare(
129− "SELECT name, kind, text_value, secret_id FROM mcp_source_header WHERE source_id = ? ORDER BY name",
130− )
131− .all("remote-oauth") as ReadonlyArray<Record<string, string | null>>;
132− expect(headers).toHaveLength(2);
133− const byName = new Map(headers.map((h) => [h.name, h]));
134− expect(byName.get("X-Trace")).toMatchObject({
135− kind: "text",
136− text_value: "static",
137− });
138− expect(byName.get("X-Token")).toMatchObject({
139− kind: "secret",
140− secret_id: "extra-tok",
141− });
142−
143− const params = after
144− .prepare(
145− "SELECT name, secret_id FROM mcp_source_query_param WHERE source_id = ?",
146− )
147− .all("remote-oauth") as ReadonlyArray<Record<string, string>>;
148− expect(params).toHaveLength(1);
149− expect(params[0]).toMatchObject({ name: "org", secret_id: "org-id-secret" });
150−
151− after.close();
152− } finally {
153− rmSync(dir, { recursive: true, force: true });
154− }
98+ const dbPath = makeDbPath();
99+ const db = new Database(dbPath);
100+ db.exec(PRE_0007_SQL);
101+ stampPriorMigrationsApplied(db);
102+
103+ db.prepare(
104+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
105+ ).run(
106+ "default-scope",
107+ "remote-oauth",
108+ "Remote OAuth",
109+ JSON.stringify({
110+ transport: "remote",
111+ endpoint: "https://oauth.example/mcp",
112+ headers: {
113+ "X-Trace": "static",
114+ "X-Token": { secretId: "extra-tok" },
115+ },
116+ queryParams: {
117+ org: { secretId: "org-id-secret" },
118+ },
119+ auth: {
120+ kind: "oauth2",
121+ connectionId: "conn-1",
122+ clientIdSecretId: "client-id-sec",
123+ clientSecretSecretId: "client-secret-sec",
124+ },
125+ }),
126+ Date.now(),
127+ );
128+
129+ db.close();
130+ const drizzleDb = drizzle(new Database(dbPath));
131+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
132+
133+ const after = new Database(dbPath, { readonly: true });
134+ const row = after
135+ .prepare(
136+ "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id FROM mcp_source WHERE id = ?",
137+ )
138+ .get("remote-oauth") as Record<string, string | null>;
139+ expect(row.auth_kind).toBe("oauth2");
140+ expect(row.auth_connection_id).toBe("conn-1");
141+ expect(row.auth_client_id_secret_id).toBe("client-id-sec");
142+ expect(row.auth_client_secret_secret_id).toBe("client-secret-sec");
143+
144+ const headers = after
145+ .prepare(
146+ "SELECT name, kind, text_value, secret_id FROM mcp_source_header WHERE source_id = ? ORDER BY name",
147+ )
148+ .all("remote-oauth") as ReadonlyArray<Record<string, string | null>>;
149+ expect(headers).toHaveLength(2);
150+ const byName = new Map(headers.map((h) => [h.name, h]));
151+ expect(byName.get("X-Trace")).toMatchObject({
152+ kind: "text",
153+ text_value: "static",
154+ });
155+ expect(byName.get("X-Token")).toMatchObject({
156+ kind: "secret",
157+ secret_id: "extra-tok",
158+ });
159+
160+ const params = after
161+ .prepare("SELECT name, secret_id FROM mcp_source_query_param WHERE source_id = ?")
162+ .all("remote-oauth") as ReadonlyArray<Record<string, string>>;
163+ expect(params).toHaveLength(1);
164+ expect(params[0]).toMatchObject({ name: "org", secret_id: "org-id-secret" });
165+
166+ after.close();
155167 });
156168
157169 it("leaves stdio sources alone (no auth, no headers, no queryParams)", () => {
158− const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
159− const dbPath = join(dir, "test.sqlite");
160− try {
161− const db = new Database(dbPath);
162− db.exec(PRE_0007_SQL);
163− stampPriorMigrationsApplied(db);
164−
165− db.prepare(
166− "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
167− ).run(
168− "default-scope",
169− "stdio-only",
170− "Stdio",
171− JSON.stringify({
172− transport: "stdio",
173− command: "/usr/bin/server",
174− args: ["--flag"],
175− }),
176− Date.now(),
177− );
178−
179− db.close();
180− const drizzleDb = drizzle(new Database(dbPath));
181− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
182−
183− const after = new Database(dbPath, { readonly: true });
184− const row = after
185− .prepare(
186− "SELECT auth_kind, auth_secret_id, config FROM mcp_source WHERE id = ?",
187− )
188− .get("stdio-only") as {
189− auth_kind: string;
190− auth_secret_id: string | null;
191− config: string;
192− };
193− expect(row.auth_kind).toBe("none");
194− expect(row.auth_secret_id).toBeNull();
195− const config = JSON.parse(row.config);
196− expect(config.transport).toBe("stdio");
197− expect(config.command).toBe("/usr/bin/server");
198− after.close();
199− } finally {
200− rmSync(dir, { recursive: true, force: true });
201− }
170+ const dbPath = makeDbPath();
171+ const db = new Database(dbPath);
172+ db.exec(PRE_0007_SQL);
173+ stampPriorMigrationsApplied(db);
174+
175+ db.prepare(
176+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
177+ ).run(
178+ "default-scope",
179+ "stdio-only",
180+ "Stdio",
181+ JSON.stringify({
182+ transport: "stdio",
183+ command: "/usr/bin/server",
184+ args: ["--flag"],
185+ }),
186+ Date.now(),
187+ );
188+
189+ db.close();
190+ const drizzleDb = drizzle(new Database(dbPath));
191+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
192+
193+ const after = new Database(dbPath, { readonly: true });
194+ const row = after
195+ .prepare("SELECT auth_kind, auth_secret_id, config FROM mcp_source WHERE id = ?")
196+ .get("stdio-only") as {
197+ auth_kind: string;
198+ auth_secret_id: string | null;
199+ config: string;
200+ };
201+ expect(row.auth_kind).toBe("none");
202+ expect(row.auth_secret_id).toBeNull();
203+ const config = Schema.decodeUnknownSync(ConfigJson)(row.config);
204+ expect(config.transport).toBe("stdio");
205+ expect(config.command).toBe("/usr/bin/server");
206+ after.close();
202207 });
203208 });