Unified · Split
| @@ -5,11 +5,12 @@ · 4 unmodified lines | |||
| 5 | // openapi_source_binding.value), runs the migration runner, asserts | 5 | // openapi_source_binding.value), runs the migration runner, asserts |
| 6 | // the new flat columns + child tables match. | 6 | // the new flat columns + child tables match. |
| 7 | 7 | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; |
| 9 | import { Database } from "bun:sqlite"; | 9 | import { Database } from "bun:sqlite"; |
| 10 | import { mkdtempSync, rmSync } from "node:fs"; | 10 | import { mkdtempSync, rmSync } from "node:fs"; |
| 11 | import { join } from "node:path"; | 11 | import { join } from "node:path"; |
| 12 | import { tmpdir } from "node:os"; | 12 | import { tmpdir } from "node:os"; |
| 13 | import { Schema } from "effect"; | ||
| 13 | import { drizzle } from "drizzle-orm/bun-sqlite"; | 14 | import { drizzle } from "drizzle-orm/bun-sqlite"; |
| 14 | import { migrate } from "drizzle-orm/bun-sqlite/migrator"; | 15 | import { migrate } from "drizzle-orm/bun-sqlite/migrator"; |
| 15 | 16 | ||
| @@ -17,235 +18,276 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr · 1 unmodified line | |||
| 17 | 18 | ||
| 18 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); | 19 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); |
| 19 | 20 | ||
| 21 | const BindingRow = Schema.Struct({ | ||
| 22 | id: Schema.String, | ||
| 23 | kind: Schema.String, | ||
| 24 | secret_id: Schema.NullOr(Schema.String), | ||
| 25 | connection_id: Schema.NullOr(Schema.String), | ||
| 26 | text_value: Schema.NullOr(Schema.String), | ||
| 27 | }); | ||
| 28 | |||
| 29 | const QueryParamRow = Schema.Struct({ | ||
| 30 | name: Schema.String, | ||
| 31 | kind: Schema.String, | ||
| 32 | text_value: Schema.NullOr(Schema.String), | ||
| 33 | secret_id: Schema.NullOr(Schema.String), | ||
| 34 | }); | ||
| 35 | |||
| 36 | const FetchHeaderRow = Schema.Struct({ | ||
| 37 | name: Schema.String, | ||
| 38 | kind: Schema.String, | ||
| 39 | secret_id: Schema.NullOr(Schema.String), | ||
| 40 | secret_prefix: Schema.NullOr(Schema.String), | ||
| 41 | }); | ||
| 42 | |||
| 43 | const FetchQueryParamRow = Schema.Struct({ | ||
| 44 | name: Schema.String, | ||
| 45 | secret_id: Schema.String, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const TableInfoRow = Schema.Struct({ | ||
| 49 | name: Schema.String, | ||
| 50 | }); | ||
| 51 | |||
| 52 | const CountRow = Schema.Struct({ | ||
| 53 | n: Schema.Number, | ||
| 54 | }); | ||
| 55 | |||
| 20 | describe("0007_normalize_plugin_secret_refs (openapi)", () => { | 56 | describe("0007_normalize_plugin_secret_refs (openapi)", () => { |
| 21 | it("flattens openapi_source_binding.value into kind/secret_id/connection_id/text_value", () => { | 57 | let dir: string; |
| 22 | const dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); | 58 | let dbPath: string; |
| 23 | const dbPath = join(dir, "test.sqlite"); | 59 | let openDatabases: Set<Database>; |
| 24 | try { | 60 | |
| 25 | const db = new Database(dbPath); | 61 | beforeEach(() => { |
| 26 | db.exec(PRE_0007_SQL); | 62 | dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); |
| 27 | stampPriorMigrationsApplied(db); | 63 | dbPath = join(dir, "test.sqlite"); |
| 28 | 64 | openDatabases = new Set(); | |
| 29 | // Seed three bindings, one per kind. | 65 | }); |
| 30 | const insert = db.prepare( | ||
| 31 | "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | ||
| 32 | ); | ||
| 33 | const now = Date.now(); | ||
| 34 | insert.run( | ||
| 35 | "b1", | ||
| 36 | "src", | ||
| 37 | "default-scope", | ||
| 38 | "default-scope", | ||
| 39 | "header:authorization", | ||
| 40 | JSON.stringify({ kind: "secret", secretId: "tok-secret" }), | ||
| 41 | now, | ||
| 42 | now, | ||
| 43 | ); | ||
| 44 | insert.run( | ||
| 45 | "b2", | ||
| 46 | "src", | ||
| 47 | "default-scope", | ||
| 48 | "default-scope", | ||
| 49 | "oauth2:default:connection", | ||
| 50 | JSON.stringify({ kind: "connection", connectionId: "conn-1" }), | ||
| 51 | now, | ||
| 52 | now, | ||
| 53 | ); | ||
| 54 | insert.run( | ||
| 55 | "b3", | ||
| 56 | "src", | ||
| 57 | "default-scope", | ||
| 58 | "default-scope", | ||
| 59 | "header:x-static", | ||
| 60 | JSON.stringify({ kind: "text", text: "literal" }), | ||
| 61 | now, | ||
| 62 | now, | ||
| 63 | ); | ||
| 64 | |||
| 65 | // Need the parent openapi_source row so the source_id FK ergonomics | ||
| 66 | // are satisfied for any cascading delete logic — though the binding | ||
| 67 | // table has no DB-level FK, code paths assume the parent exists. | ||
| 68 | db.prepare( | ||
| 69 | "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", | ||
| 70 | ).run("default-scope", "src", "Source", "{}", "{}"); | ||
| 71 | 66 | ||
| 67 | afterEach(() => { | ||
| 68 | for (const db of openDatabases) { | ||
| 72 | db.close(); | 69 | db.close(); |
| 70 | } | ||
| 71 | openDatabases.clear(); | ||
| 72 | rmSync(dir, { recursive: true, force: true }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | const openDatabase = (...args: ConstructorParameters<typeof Database>) => { | ||
| 76 | const db = new Database(...args); | ||
| 77 | openDatabases.add(db); | ||
| 78 | return db; | ||
| 79 | }; | ||
| 80 | |||
| 81 | const closeDatabase = (db: Database) => { | ||
| 82 | db.close(); | ||
| 83 | openDatabases.delete(db); | ||
| 84 | }; | ||
| 73 | 85 | ||
| 74 | const drizzleDb = drizzle(new Database(dbPath)); | 86 | it("flattens openapi_source_binding.value into kind/secret_id/connection_id/text_value", () => { |
| 75 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 87 | const db = openDatabase(dbPath); |
| 88 | db.exec(PRE_0007_SQL); | ||
| 89 | stampPriorMigrationsApplied(db); | ||
| 90 | |||
| 91 | // Seed three bindings, one per kind. | ||
| 92 | const insert = db.prepare( | ||
| 93 | "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | ||
| 94 | ); | ||
| 95 | const now = Date.now(); | ||
| 96 | insert.run( | ||
| 97 | "b1", | ||
| 98 | "src", | ||
| 99 | "default-scope", | ||
| 100 | "default-scope", | ||
| 101 | "header:authorization", | ||
| 102 | JSON.stringify({ kind: "secret", secretId: "tok-secret" }), | ||
| 103 | now, | ||
| 104 | now, | ||
| 105 | ); | ||
| 106 | insert.run( | ||
| 107 | "b2", | ||
| 108 | "src", | ||
| 109 | "default-scope", | ||
| 110 | "default-scope", | ||
| 111 | "oauth2:default:connection", | ||
| 112 | JSON.stringify({ kind: "connection", connectionId: "conn-1" }), | ||
| 113 | now, | ||
| 114 | now, | ||
| 115 | ); | ||
| 116 | insert.run( | ||
| 117 | "b3", | ||
| 118 | "src", | ||
| 119 | "default-scope", | ||
| 120 | "default-scope", | ||
| 121 | "header:x-static", | ||
| 122 | JSON.stringify({ kind: "text", text: "literal" }), | ||
| 123 | now, | ||
| 124 | now, | ||
| 125 | ); | ||
| 126 | |||
| 127 | // Need the parent openapi_source row so the source_id FK ergonomics | ||
| 128 | // are satisfied for any cascading delete logic, though the binding | ||
| 129 | // table has no DB-level FK, code paths assume the parent exists. | ||
| 130 | db.prepare( | ||
| 131 | "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", | ||
| 132 | ).run("default-scope", "src", "Source", "{}", "{}"); | ||
| 133 | |||
| 134 | closeDatabase(db); | ||
| 135 | |||
| 136 | const drizzleSqlite = openDatabase(dbPath); | ||
| 137 | const drizzleDb = drizzle(drizzleSqlite); | ||
| 138 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | ||
| 139 | closeDatabase(drizzleSqlite); | ||
| 76 | 140 | ||
| 77 | const after = new Database(dbPath, { readonly: true }); | 141 | const after = openDatabase(dbPath, { readonly: true }); |
| 78 | const rows = after | 142 | const rows = Schema.decodeUnknownSync(Schema.Array(BindingRow))( |
| 143 | after | ||
| 79 | .prepare( | 144 | .prepare( |
| 80 | "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id", | 145 | "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id", |
| 81 | ) | 146 | ) |
| 82 | .all() as ReadonlyArray<{ | 147 | .all(), |
| 83 | id: string; | 148 | ); |
| 84 | kind: string; | 149 | expect(rows).toHaveLength(3); |
| 85 | secret_id: string | null; | 150 | expect(rows[0]).toMatchObject({ |
| 86 | connection_id: string | null; | 151 | id: "b1", |
| 87 | text_value: string | null; | 152 | kind: "secret", |
| 88 | }>; | 153 | secret_id: "tok-secret", |
| 89 | expect(rows).toHaveLength(3); | 154 | connection_id: null, |
| 90 | expect(rows[0]).toMatchObject({ | 155 | text_value: null, |
| 91 | id: "b1", | 156 | }); |
| 92 | kind: "secret", | 157 | expect(rows[1]).toMatchObject({ |
| 93 | secret_id: "tok-secret", | 158 | id: "b2", |
| 94 | connection_id: null, | 159 | kind: "connection", |
| 95 | text_value: null, | 160 | secret_id: null, |
| 96 | }); | 161 | connection_id: "conn-1", |
| 97 | expect(rows[1]).toMatchObject({ | 162 | text_value: null, |
| 98 | id: "b2", | 163 | }); |
| 99 | kind: "connection", | 164 | expect(rows[2]).toMatchObject({ |
| 100 | secret_id: null, | 165 | id: "b3", |
| 101 | connection_id: "conn-1", | 166 | kind: "text", |
| 102 | text_value: null, | 167 | secret_id: null, |
| 103 | }); | 168 | connection_id: null, |
| 104 | expect(rows[2]).toMatchObject({ | 169 | text_value: "literal", |
| 105 | id: "b3", | 170 | }); |
| 106 | kind: "text", | 171 | // value json column dropped. |
| 107 | secret_id: null, | 172 | const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))( |
| 108 | connection_id: null, | 173 | after.prepare("PRAGMA table_info('openapi_source_binding')").all(), |
| 109 | text_value: "literal", | 174 | ); |
| 110 | }); | 175 | expect(cols.some((c) => c.name === "value")).toBe(false); |
| 111 | // value json column dropped. | ||
| 112 | const cols = after | ||
| 113 | .prepare("PRAGMA table_info('openapi_source_binding')") | ||
| 114 | .all() as ReadonlyArray<{ name: string }>; | ||
| 115 | expect(cols.some((c) => c.name === "value")).toBe(false); | ||
| 116 | after.close(); | ||
| 117 | } finally { | ||
| 118 | rmSync(dir, { recursive: true, force: true }); | ||
| 119 | } | ||
| 120 | }); | 176 | }); |
| 121 | 177 | ||
| 122 | it("explodes query_params and specFetchCredentials json into child rows", () => { | 178 | it("explodes query_params and specFetchCredentials json into child rows", () => { |
| 123 | const dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); | 179 | const db = openDatabase(dbPath); |
| 124 | const dbPath = join(dir, "test.sqlite"); | 180 | db.exec(PRE_0007_SQL); |
| 125 | try { | 181 | stampPriorMigrationsApplied(db); |
| 126 | const db = new Database(dbPath); | 182 | |
| 127 | db.exec(PRE_0007_SQL); | 183 | const queryParams = { |
| 128 | stampPriorMigrationsApplied(db); | 184 | api_key: { secretId: "qp-secret" }, |
| 129 | 185 | flag: "true", | |
| 130 | const queryParams = { | 186 | }; |
| 131 | api_key: { secretId: "qp-secret" }, | 187 | const invocationConfig = { |
| 132 | flag: "true", | 188 | specFetchCredentials: { |
| 133 | }; | 189 | headers: { |
| 134 | const invocationConfig = { | 190 | Authorization: { secretId: "fetch-tok", prefix: "Bearer " }, |
| 135 | specFetchCredentials: { | ||
| 136 | headers: { | ||
| 137 | Authorization: { secretId: "fetch-tok", prefix: "Bearer " }, | ||
| 138 | }, | ||
| 139 | queryParams: { token: { secretId: "fetch-qp" } }, | ||
| 140 | }, | 191 | }, |
| 141 | }; | 192 | queryParams: { token: { secretId: "fetch-qp" } }, |
| 142 | 193 | }, | |
| 143 | db.prepare( | 194 | }; |
| 144 | "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", | ||
| 145 | ).run( | ||
| 146 | "default-scope", | ||
| 147 | "src", | ||
| 148 | "Source", | ||
| 149 | "{}", | ||
| 150 | JSON.stringify(queryParams), | ||
| 151 | JSON.stringify(invocationConfig), | ||
| 152 | ); | ||
| 153 | 195 | ||
| 154 | db.close(); | 196 | db.prepare( |
| 197 | "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", | ||
| 198 | ).run( | ||
| 199 | "default-scope", | ||
| 200 | "src", | ||
| 201 | "Source", | ||
| 202 | "{}", | ||
| 203 | JSON.stringify(queryParams), | ||
| 204 | JSON.stringify(invocationConfig), | ||
| 205 | ); | ||
| 155 | 206 | ||
| 156 | const drizzleDb = drizzle(new Database(dbPath)); | 207 | closeDatabase(db); |
| 157 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | ||
| 158 | 208 | ||
| 159 | const after = new Database(dbPath, { readonly: true }); | 209 | const drizzleSqlite = openDatabase(dbPath); |
| 210 | const drizzleDb = drizzle(drizzleSqlite); | ||
| 211 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | ||
| 212 | closeDatabase(drizzleSqlite); | ||
| 160 | 213 | ||
| 161 | const qpRows = after | 214 | const after = openDatabase(dbPath, { readonly: true }); |
| 215 | |||
| 216 | const qpRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow))( | ||
| 217 | after | ||
| 162 | .prepare( | 218 | .prepare( |
| 163 | "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name", | 219 | "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name", |
| 164 | ) | 220 | ) |
| 165 | .all("src") as ReadonlyArray<{ | 221 | .all("src"), |
| 166 | name: string; | 222 | ); |
| 167 | kind: string; | 223 | expect(qpRows).toHaveLength(2); |
| 168 | text_value: string | null; | 224 | const byName = new Map(qpRows.map((r) => [r.name, r])); |
| 169 | secret_id: string | null; | 225 | expect(byName.get("api_key")).toMatchObject({ |
| 170 | }>; | 226 | kind: "secret", |
| 171 | expect(qpRows).toHaveLength(2); | 227 | secret_id: "qp-secret", |
| 172 | const byName = new Map(qpRows.map((r) => [r.name, r])); | 228 | }); |
| 173 | expect(byName.get("api_key")).toMatchObject({ | 229 | expect(byName.get("flag")).toMatchObject({ |
| 174 | kind: "secret", | 230 | kind: "text", |
| 175 | secret_id: "qp-secret", | 231 | text_value: "true", |
| 176 | }); | 232 | }); |
| 177 | expect(byName.get("flag")).toMatchObject({ | 233 | |
| 178 | kind: "text", | 234 | const fetchHeaders = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow))( |
| 179 | text_value: "true", | 235 | after |
| 180 | }); | ||
| 181 | |||
| 182 | const fetchHeaders = after | ||
| 183 | .prepare( | 236 | .prepare( |
| 184 | "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?", | 237 | "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?", |
| 185 | ) | 238 | ) |
| 186 | .all("src") as ReadonlyArray<{ | 239 | .all("src"), |
| 187 | name: string; | 240 | ); |
| 188 | kind: string; | 241 | expect(fetchHeaders).toHaveLength(1); |
| 189 | secret_id: string | null; | 242 | expect(fetchHeaders[0]).toMatchObject({ |
| 190 | secret_prefix: string | null; | 243 | name: "Authorization", |
| 191 | }>; | 244 | kind: "secret", |
| 192 | expect(fetchHeaders).toHaveLength(1); | 245 | secret_id: "fetch-tok", |
| 193 | expect(fetchHeaders[0]).toMatchObject({ | 246 | secret_prefix: "Bearer ", |
| 194 | name: "Authorization", | 247 | }); |
| 195 | kind: "secret", | 248 | |
| 196 | secret_id: "fetch-tok", | 249 | const fetchQp = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow))( |
| 197 | secret_prefix: "Bearer ", | 250 | after |
| 198 | }); | ||
| 199 | |||
| 200 | const fetchQp = after | ||
| 201 | .prepare( | 251 | .prepare( |
| 202 | "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?", | 252 | "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?", |
| 203 | ) | 253 | ) |
| 204 | .all("src") as ReadonlyArray<{ name: string; secret_id: string }>; | 254 | .all("src"), |
| 205 | expect(fetchQp).toHaveLength(1); | 255 | ); |
| 206 | expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" }); | 256 | expect(fetchQp).toHaveLength(1); |
| 207 | 257 | expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" }); | |
| 208 | // Old json columns dropped. | 258 | |
| 209 | const cols = after | 259 | // Old json columns dropped. |
| 210 | .prepare("PRAGMA table_info('openapi_source')") | 260 | const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))( |
| 211 | .all() as ReadonlyArray<{ name: string }>; | 261 | after.prepare("PRAGMA table_info('openapi_source')").all(), |
| 212 | expect(cols.some((c) => c.name === "query_params")).toBe(false); | 262 | ); |
| 213 | expect(cols.some((c) => c.name === "invocation_config")).toBe(false); | 263 | expect(cols.some((c) => c.name === "query_params")).toBe(false); |
| 214 | after.close(); | 264 | expect(cols.some((c) => c.name === "invocation_config")).toBe(false); |
| 215 | } finally { | ||
| 216 | rmSync(dir, { recursive: true, force: true }); | ||
| 217 | } | ||
| 218 | }); | 265 | }); |
| 219 | 266 | ||
| 220 | it("survives empty / missing json on bindings and sources", () => { | 267 | it("survives empty / missing json on bindings and sources", () => { |
| 221 | const dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); | 268 | const db = openDatabase(dbPath); |
| 222 | const dbPath = join(dir, "test.sqlite"); | 269 | db.exec(PRE_0007_SQL); |
| 223 | try { | 270 | stampPriorMigrationsApplied(db); |
| 224 | const db = new Database(dbPath); | ||
| 225 | db.exec(PRE_0007_SQL); | ||
| 226 | stampPriorMigrationsApplied(db); | ||
| 227 | |||
| 228 | // Source with empty invocation_config and no query_params. | ||
| 229 | db.prepare( | ||
| 230 | "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", | ||
| 231 | ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); | ||
| 232 | 271 | ||
| 233 | db.close(); | 272 | // Source with empty invocation_config and no query_params. |
| 234 | const drizzleDb = drizzle(new Database(dbPath)); | 273 | db.prepare( |
| 235 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 274 | "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", |
| 236 | 275 | ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); | |
| 237 | const after = new Database(dbPath, { readonly: true }); | 276 | |
| 238 | const qpCount = ( | 277 | closeDatabase(db); |
| 239 | after | 278 | const drizzleSqlite = openDatabase(dbPath); |
| 240 | .prepare( | 279 | const drizzleDb = drizzle(drizzleSqlite); |
| 241 | "SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?", | 280 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); |
| 242 | ) | 281 | closeDatabase(drizzleSqlite); |
| 243 | .get("bare") as { n: number } | 282 | |
| 244 | ).n; | 283 | const after = openDatabase(dbPath, { readonly: true }); |
| 245 | expect(qpCount).toBe(0); | 284 | const qpCount = Schema.decodeUnknownSync(CountRow)( |
| 246 | after.close(); | 285 | after |
| 247 | } finally { | 286 | .prepare( |
| 248 | rmSync(dir, { recursive: true, force: true }); | 287 | "SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?", |
| 249 | } | 288 | ) |
| 289 | .get("bare"), | ||
| 290 | ).n; | ||
| 291 | expect(qpCount).toBe(0); | ||
| 250 | }); | 292 | }); |
| 251 | }); | 293 | }); |