Unified · Split
| @@ -4,200 +4,205 @@ · 3 unmodified lines | |||
| 4 | // queryParams), runs the migration runner, asserts the auth columns | 4 | // 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 | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 8 | import { Database } from "bun:sqlite"; | 8 | import { Database } from "bun:sqlite"; |
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | 9 | import { mkdtempSync, rmSync } from "node:fs"; |
| 10 | import { join } from "node:path"; | 10 | import { join } from "node:path"; |
| 11 | import { tmpdir } from "node:os"; | 11 | import { tmpdir } from "node:os"; |
| 12 | import { drizzle } from "drizzle-orm/bun-sqlite"; | 12 | import { drizzle } from "drizzle-orm/bun-sqlite"; |
| 13 | import { migrate } from "drizzle-orm/bun-sqlite/migrator"; | 13 | import { migrate } from "drizzle-orm/bun-sqlite/migrator"; |
| 14 | import { Schema } from "effect"; | ||
| 14 | 15 | ||
| 15 | import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; | 16 | import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; |
| 16 | 17 | ||
| 17 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); | 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 | describe("0007_normalize_plugin_secret_refs (mcp)", () => { | 37 | describe("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 = after | 136 | "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 = after | 146 | "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 = after | 162 | .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 = after | 196 | .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 | }); |