Unified · Split
| @@ -4,200 +4,205 @@ · 3 unmodified lines | ||
| 4 | 4 | // queryParams), runs the migration runner, asserts the auth columns |
| 5 | 5 | // are populated and the child tables hold the secret-backed entries. |
| 6 | 6 | |
| 7 | −import { describe, expect, it } from "@effect/vitest"; | |
| 7 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 8 | 8 | import { Database } from "bun:sqlite"; |
| 9 | 9 | import { mkdtempSync, rmSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { drizzle } from "drizzle-orm/bun-sqlite"; |
| 13 | 13 | import { migrate } from "drizzle-orm/bun-sqlite/migrator"; |
| 14 | +import { Schema } from "effect"; | |
| 14 | 15 | |
| 15 | 16 | import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; |
| 16 | 17 | |
| 17 | 18 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); |
| 18 | 19 | |
| 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 | + | |
| 19 | 37 | 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)) { | |
| 74 | 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 | 97 | 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(); | |
| 155 | 167 | }); |
| 156 | 168 | |
| 157 | 169 | 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(); | |
| 202 | 207 | }); |
| 203 | 208 | }); |