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
4// queryParams), runs the migration runner, asserts the auth columns4// queryParams), runs the migration runner, asserts the auth columns
5// are populated and the child tables hold the secret-backed entries.5// are populated and the child tables hold the secret-backed entries.
6 6
7import { describe, expect, it } from "@effect/vitest";7import { afterEach, describe, expect, it } from "@effect/vitest";
8import { Database } from "bun:sqlite";8import { Database } from "bun:sqlite";
9import { mkdtempSync, rmSync } from "node:fs";9import { mkdtempSync, rmSync } from "node:fs";
10import { join } from "node:path";10import { join } from "node:path";
11import { tmpdir } from "node:os";11import { tmpdir } from "node:os";
12import { drizzle } from "drizzle-orm/bun-sqlite";12import { drizzle } from "drizzle-orm/bun-sqlite";
13import { migrate } from "drizzle-orm/bun-sqlite/migrator";13import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14import { Schema } from "effect";
14 15
15import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";16import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16 17
17const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");18const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18 19
20const 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
29const tempDirs: Array<string> = [];
30
31const makeDbPath = () => {
32 const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
33 tempDirs.push(dir);
34 return join(dir, "test.sqlite");
35};
36
19describe("0007_normalize_plugin_secret_refs (mcp)", () => {37describe("0007_normalize_plugin_secret_refs (mcp)", () => {
20 it("flattens header auth into auth_kind/auth_secret_id columns", () => {38 afterEach(() => {
21 const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));39 for (const dir of tempDirs.splice(0)) {
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 {
74 rmSync(dir, { recursive: true, force: true });40 rmSync(dir, { recursive: true, force: true });
75 }41 }
76 });42 });
77 43
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
78 it("flattens oauth2 auth and explodes headers into child rows", () => {97 it("flattens oauth2 auth and explodes headers into child rows", () => {
79 const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));98 const dbPath = makeDbPath();
80 const dbPath = join(dir, "test.sqlite");99 const db = new Database(dbPath);
81 try {100 db.exec(PRE_0007_SQL);
82 const db = new Database(dbPath);101 stampPriorMigrationsApplied(db);
83 db.exec(PRE_0007_SQL);102
84 stampPriorMigrationsApplied(db);103 db.prepare(
85 104 "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
86 db.prepare(105 ).run(
87 "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",106 "default-scope",
88 ).run(107 "remote-oauth",
89 "default-scope",108 "Remote OAuth",
90 "remote-oauth",109 JSON.stringify({
91 "Remote OAuth",110 transport: "remote",
92 JSON.stringify({111 endpoint: "https://oauth.example/mcp",
93 transport: "remote",112 headers: {
94 endpoint: "https://oauth.example/mcp",113 "X-Trace": "static",
95 headers: {114 "X-Token": { secretId: "extra-tok" },
96 "X-Trace": "static",115 },
97 "X-Token": { secretId: "extra-tok" },116 queryParams: {
98 },117 org: { secretId: "org-id-secret" },
99 queryParams: {118 },
100 org: { secretId: "org-id-secret" },119 auth: {
101 },120 kind: "oauth2",
102 auth: {121 connectionId: "conn-1",
103 kind: "oauth2",122 clientIdSecretId: "client-id-sec",
104 connectionId: "conn-1",123 clientSecretSecretId: "client-secret-sec",
105 clientIdSecretId: "client-id-sec",124 },
106 clientSecretSecretId: "client-secret-sec",125 }),
107 },126 Date.now(),
108 }),127 );
109 Date.now(),128
110 );129 db.close();
111 130 const drizzleDb = drizzle(new Database(dbPath));
112 db.close();131 migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
113 const drizzleDb = drizzle(new Database(dbPath));132
114 migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });133 const after = new Database(dbPath, { readonly: true });
115 134 const row = after
116 const after = new Database(dbPath, { readonly: true });135 .prepare(
117 const row = after136 "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id FROM mcp_source WHERE id = ?",
118 .prepare(137 )
119 "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id FROM mcp_source WHERE id = ?",138 .get("remote-oauth") as Record<string, string | null>;
120 )139 expect(row.auth_kind).toBe("oauth2");
121 .get("remote-oauth") as Record<string, string | null>;140 expect(row.auth_connection_id).toBe("conn-1");
122 expect(row.auth_kind).toBe("oauth2");141 expect(row.auth_client_id_secret_id).toBe("client-id-sec");
123 expect(row.auth_connection_id).toBe("conn-1");142 expect(row.auth_client_secret_secret_id).toBe("client-secret-sec");
124 expect(row.auth_client_id_secret_id).toBe("client-id-sec");143
125 expect(row.auth_client_secret_secret_id).toBe("client-secret-sec");144 const headers = after
126 145 .prepare(
127 const headers = after146 "SELECT name, kind, text_value, secret_id FROM mcp_source_header WHERE source_id = ? ORDER BY name",
128 .prepare(147 )
129 "SELECT name, kind, text_value, secret_id FROM mcp_source_header WHERE source_id = ? ORDER BY name",148 .all("remote-oauth") as ReadonlyArray<Record<string, string | null>>;
130 )149 expect(headers).toHaveLength(2);
131 .all("remote-oauth") as ReadonlyArray<Record<string, string | null>>;150 const byName = new Map(headers.map((h) => [h.name, h]));
132 expect(headers).toHaveLength(2);151 expect(byName.get("X-Trace")).toMatchObject({
133 const byName = new Map(headers.map((h) => [h.name, h]));152 kind: "text",
134 expect(byName.get("X-Trace")).toMatchObject({153 text_value: "static",
135 kind: "text",154 });
136 text_value: "static",155 expect(byName.get("X-Token")).toMatchObject({
137 });156 kind: "secret",
138 expect(byName.get("X-Token")).toMatchObject({157 secret_id: "extra-tok",
139 kind: "secret",158 });
140 secret_id: "extra-tok",159
141 });160 const params = after
142 161 .prepare("SELECT name, secret_id FROM mcp_source_query_param WHERE source_id = ?")
143 const params = after162 .all("remote-oauth") as ReadonlyArray<Record<string, string>>;
144 .prepare(163 expect(params).toHaveLength(1);
145 "SELECT name, secret_id FROM mcp_source_query_param WHERE source_id = ?",164 expect(params[0]).toMatchObject({ name: "org", secret_id: "org-id-secret" });
146 )165
147 .all("remote-oauth") as ReadonlyArray<Record<string, string>>;166 after.close();
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 }
155 });167 });
156 168
157 it("leaves stdio sources alone (no auth, no headers, no queryParams)", () => {169 it("leaves stdio sources alone (no auth, no headers, no queryParams)", () => {
158 const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));170 const dbPath = makeDbPath();
159 const dbPath = join(dir, "test.sqlite");171 const db = new Database(dbPath);
160 try {172 db.exec(PRE_0007_SQL);
161 const db = new Database(dbPath);173 stampPriorMigrationsApplied(db);
162 db.exec(PRE_0007_SQL);174
163 stampPriorMigrationsApplied(db);175 db.prepare(
164 176 "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
165 db.prepare(177 ).run(
166 "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",178 "default-scope",
167 ).run(179 "stdio-only",
168 "default-scope",180 "Stdio",
169 "stdio-only",181 JSON.stringify({
170 "Stdio",182 transport: "stdio",
171 JSON.stringify({183 command: "/usr/bin/server",
172 transport: "stdio",184 args: ["--flag"],
173 command: "/usr/bin/server",185 }),
174 args: ["--flag"],186 Date.now(),
175 }),187 );
176 Date.now(),188
177 );189 db.close();
178 190 const drizzleDb = drizzle(new Database(dbPath));
179 db.close();191 migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
180 const drizzleDb = drizzle(new Database(dbPath));192
181 migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });193 const after = new Database(dbPath, { readonly: true });
182 194 const row = after
183 const after = new Database(dbPath, { readonly: true });195 .prepare("SELECT auth_kind, auth_secret_id, config FROM mcp_source WHERE id = ?")
184 const row = after196 .get("stdio-only") as {
185 .prepare(197 auth_kind: string;
186 "SELECT auth_kind, auth_secret_id, config FROM mcp_source WHERE id = ?",198 auth_secret_id: string | null;
187 )199 config: string;
188 .get("stdio-only") as {200 };
189 auth_kind: string;201 expect(row.auth_kind).toBe("none");
190 auth_secret_id: string | null;202 expect(row.auth_secret_id).toBeNull();
191 config: string;203 const config = Schema.decodeUnknownSync(ConfigJson)(row.config);
192 };204 expect(config.transport).toBe("stdio");
193 expect(row.auth_kind).toBe("none");205 expect(config.command).toBe("/usr/bin/server");
194 expect(row.auth_secret_id).toBeNull();206 after.close();
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 }
202 });207 });
203});208});