| @@ -4,8 +4,9 @@ · 3 unmodified lines |
| 4 | // run the migration, assert that the JSON unpacks into the new | 4 | // run the migration, assert that the JSON unpacks into the new |
| 5 | // normalized columns / child tables and that the JSON columns are gone. | 5 | // normalized columns / child tables and that the JSON columns are gone. |
| 6 | | 6 | |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; |
| 8 | import { Database } from "bun:sqlite"; | 8 | import { Database } from "bun:sqlite"; |
| | 9 | import { Schema } from "effect"; |
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | 10 | import { mkdtempSync, rmSync } from "node:fs"; |
| 10 | import { join } from "node:path"; | 11 | import { join } from "node:path"; |
| 11 | import { tmpdir } from "node:os"; | 12 | import { tmpdir } from "node:os"; |
| @@ -16,231 +17,266 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr · 4 unmodified lines |
| 16 | | 17 | |
| 17 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); | 18 | const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); |
| 18 | | 19 | |
| | 20 | const NullableString = Schema.NullOr(Schema.String); |
| | 21 | |
| | 22 | const GraphqlAuthRow = Schema.Struct({ |
| | 23 | auth_kind: Schema.String, |
| | 24 | auth_connection_id: NullableString, |
| | 25 | }); |
| | 26 | |
| | 27 | const TableInfoRow = Schema.Struct({ |
| | 28 | name: Schema.String, |
| | 29 | }); |
| | 30 | |
| | 31 | const GraphqlHeaderRow = Schema.Struct({ |
| | 32 | name: Schema.String, |
| | 33 | kind: Schema.String, |
| | 34 | text_value: NullableString, |
| | 35 | secret_id: NullableString, |
| | 36 | secret_prefix: NullableString, |
| | 37 | }); |
| | 38 | |
| | 39 | const GraphqlQueryParamRow = Schema.Struct({ |
| | 40 | kind: Schema.String, |
| | 41 | secret_id: Schema.String, |
| | 42 | }); |
| | 43 | |
| | 44 | const CountRow = Schema.Struct({ |
| | 45 | n: Schema.Number, |
| | 46 | }); |
| | 47 | |
| | 48 | const GraphqlHeaderIdRow = Schema.Struct({ |
| | 49 | id: Schema.String, |
| | 50 | source_id: Schema.String, |
| | 51 | name: Schema.String, |
| | 52 | text_value: Schema.String, |
| | 53 | }); |
| | 54 | |
| | 55 | const decodeAuthRow = Schema.decodeUnknownSync(GraphqlAuthRow); |
| | 56 | const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); |
| | 57 | const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderRow)); |
| | 58 | const decodeQueryParamRow = Schema.decodeUnknownSync(GraphqlQueryParamRow); |
| | 59 | const decodeCountRow = Schema.decodeUnknownSync(CountRow); |
| | 60 | const decodeHeaderIdRows = Schema.decodeUnknownSync( |
| | 61 | Schema.Array(GraphqlHeaderIdRow), |
| | 62 | ); |
| | 63 | |
| | 64 | let dir: string; |
| | 65 | |
| | 66 | beforeEach(() => { |
| | 67 | dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); |
| | 68 | }); |
| | 69 | |
| | 70 | afterEach(() => { |
| | 71 | rmSync(dir, { recursive: true, force: true }); |
| | 72 | }); |
| | 73 | |
| 19 | describe("0007_normalize_plugin_secret_refs (graphql)", () => { | 74 | describe("0007_normalize_plugin_secret_refs (graphql)", () => { |
| 20 | it("flattens auth json into auth_kind/auth_connection_id columns", () => { | 75 | it("flattens auth json into auth_kind/auth_connection_id columns", () => { |
| 21 | const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); | | |
| 22 | const dbPath = join(dir, "test.sqlite"); | 76 | const dbPath = join(dir, "test.sqlite"); |
| 23 | try { | 77 | const db = new Database(dbPath); |
| 24 | const db = new Database(dbPath); | 78 | db.exec(PRE_0007_SQL); |
| 25 | db.exec(PRE_0007_SQL); | 79 | stampPriorMigrationsApplied(db); |
| 26 | stampPriorMigrationsApplied(db); | 80 | |
| 27 | | 81 | db.prepare( |
| 28 | db.prepare( | 82 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", |
| 29 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", | 83 | ).run( |
| 30 | ).run( | 84 | "default-scope", |
| 31 | "default-scope", | 85 | "github", |
| 32 | "github", | 86 | "GitHub", |
| 33 | "GitHub", | 87 | "https://api.github.com/graphql", |
| 34 | "https://api.github.com/graphql", | 88 | JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), |
| 35 | JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), | 89 | ); |
| 36 | ); | 90 | |
| 37 | | 91 | db.close(); |
| 38 | db.close(); | 92 | |
| 39 | | 93 | const drizzleDb = drizzle(new Database(dbPath)); |
| 40 | const drizzleDb = drizzle(new Database(dbPath)); | 94 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); |
| 41 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 95 | |
| 42 | | 96 | const after = new Database(dbPath, { readonly: true }); |
| 43 | const after = new Database(dbPath, { readonly: true }); | 97 | const row = decodeAuthRow( |
| 44 | const row = after | 98 | after |
| 45 | .prepare( | 99 | .prepare( |
| 46 | "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", | 100 | "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", |
| 47 | ) | 101 | ) |
| 48 | .get("github") as { auth_kind: string; auth_connection_id: string }; | 102 | .get("github"), |
| 49 | expect(row.auth_kind).toBe("oauth2"); | 103 | ); |
| 50 | expect(row.auth_connection_id).toBe("conn-1"); | 104 | expect(row.auth_kind).toBe("oauth2"); |
| 51 | // Old json column is gone. | 105 | expect(row.auth_connection_id).toBe("conn-1"); |
| 52 | const cols = after | 106 | // Old json column is gone. |
| | 107 | const cols = decodeTableInfoRows( |
| | 108 | after |
| 53 | .prepare("PRAGMA table_info('graphql_source')") | 109 | .prepare("PRAGMA table_info('graphql_source')") |
| 54 | .all() as ReadonlyArray<{ name: string }>; | 110 | .all(), |
| 55 | expect(cols.some((c) => c.name === "auth")).toBe(false); | 111 | ); |
| 56 | expect(cols.some((c) => c.name === "headers")).toBe(false); | 112 | expect(cols.some((c) => c.name === "auth")).toBe(false); |
| 57 | expect(cols.some((c) => c.name === "query_params")).toBe(false); | 113 | expect(cols.some((c) => c.name === "headers")).toBe(false); |
| 58 | after.close(); | 114 | expect(cols.some((c) => c.name === "query_params")).toBe(false); |
| 59 | } finally { | 115 | after.close(); |
| 60 | rmSync(dir, { recursive: true, force: true }); | | |
| 61 | } | | |
| 62 | }); | 116 | }); |
| 63 | | 117 | |
| 64 | it("explodes header/query_param json into child rows", () => { | 118 | it("explodes header/query_param json into child rows", () => { |
| 65 | const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); | | |
| 66 | const dbPath = join(dir, "test.sqlite"); | 119 | const dbPath = join(dir, "test.sqlite"); |
| 67 | try { | 120 | const db = new Database(dbPath); |
| 68 | const db = new Database(dbPath); | 121 | db.exec(PRE_0007_SQL); |
| 69 | db.exec(PRE_0007_SQL); | 122 | stampPriorMigrationsApplied(db); |
| 70 | stampPriorMigrationsApplied(db); | 123 | |
| 71 | | 124 | const headers = { |
| 72 | const headers = { | 125 | // Literal text header. |
| 73 | // Literal text header. | 126 | "X-Static": "literal-value", |
| 74 | "X-Static": "literal-value", | 127 | // Secret-backed header without prefix. |
| 75 | // Secret-backed header without prefix. | 128 | Authorization: { secretId: "sec-token" }, |
| 76 | Authorization: { secretId: "sec-token" }, | 129 | // Secret-backed with prefix. |
| 77 | // Secret-backed with prefix. | 130 | "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " }, |
| 78 | "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " }, | 131 | }; |
| 79 | }; | 132 | const queryParams = { |
| 80 | const queryParams = { | 133 | api_key: { secretId: "sec-key" }, |
| 81 | api_key: { secretId: "sec-key" }, | 134 | }; |
| 82 | }; | 135 | |
| 83 | | 136 | db.prepare( |
| 84 | db.prepare( | 137 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 85 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", | 138 | ).run( |
| 86 | ).run( | 139 | "default-scope", |
| 87 | "default-scope", | 140 | "example", |
| 88 | "example", | 141 | "Example", |
| 89 | "Example", | 142 | "https://example.com/graphql", |
| 90 | "https://example.com/graphql", | 143 | JSON.stringify(headers), |
| 91 | JSON.stringify(headers), | 144 | JSON.stringify(queryParams), |
| 92 | JSON.stringify(queryParams), | 145 | JSON.stringify({ kind: "none" }), |
| 93 | JSON.stringify({ kind: "none" }), | 146 | ); |
| 94 | ); | 147 | |
| 95 | | 148 | db.close(); |
| 96 | db.close(); | 149 | |
| 97 | | 150 | const drizzleDb = drizzle(new Database(dbPath)); |
| 98 | const drizzleDb = drizzle(new Database(dbPath)); | 151 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); |
| 99 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 152 | |
| 100 | | 153 | const after = new Database(dbPath, { readonly: true }); |
| 101 | const after = new Database(dbPath, { readonly: true }); | 154 | const headerRows = decodeHeaderRows( |
| 102 | const headerRows = after | 155 | after |
| 103 | .prepare( | 156 | .prepare( |
| 104 | "SELECT name, kind, text_value, secret_id, secret_prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name", | 157 | "SELECT name, kind, text_value, secret_id, secret_prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name", |
| 105 | ) | 158 | ) |
| 106 | .all("example") as ReadonlyArray<{ | 159 | .all("example"), |
| 107 | name: string; | 160 | ); |
| 108 | kind: string; | 161 | expect(headerRows).toHaveLength(3); |
| 109 | text_value: string | null; | 162 | |
| 110 | secret_id: string | null; | 163 | const byName = new Map(headerRows.map((r) => [r.name, r])); |
| 111 | secret_prefix: string | null; | 164 | expect(byName.get("X-Static")).toMatchObject({ |
| 112 | }>; | 165 | kind: "text", |
| 113 | expect(headerRows).toHaveLength(3); | 166 | text_value: "literal-value", |
| 114 | | 167 | secret_id: null, |
| 115 | const byName = new Map(headerRows.map((r) => [r.name, r])); | 168 | }); |
| 116 | expect(byName.get("X-Static")).toMatchObject({ | 169 | expect(byName.get("Authorization")).toMatchObject({ |
| 117 | kind: "text", | 170 | kind: "secret", |
| 118 | text_value: "literal-value", | 171 | text_value: null, |
| 119 | secret_id: null, | 172 | secret_id: "sec-token", |
| 120 | }); | 173 | secret_prefix: null, |
| 121 | expect(byName.get("Authorization")).toMatchObject({ | 174 | }); |
| 122 | kind: "secret", | 175 | expect(byName.get("X-Bearer")).toMatchObject({ |
| 123 | text_value: null, | 176 | kind: "secret", |
| 124 | secret_id: "sec-token", | 177 | secret_id: "sec-bearer", |
| 125 | secret_prefix: null, | 178 | secret_prefix: "Bearer ", |
| 126 | }); | 179 | }); |
| 127 | expect(byName.get("X-Bearer")).toMatchObject({ | 180 | |
| 128 | kind: "secret", | 181 | const paramRow = decodeQueryParamRow( |
| 129 | secret_id: "sec-bearer", | 182 | after |
| 130 | secret_prefix: "Bearer ", | | |
| 131 | }); | | |
| 132 | | | |
| 133 | const paramRow = after | | |
| 134 | .prepare( | 183 | .prepare( |
| 135 | "SELECT kind, secret_id FROM graphql_source_query_param WHERE source_id = ?", | 184 | "SELECT kind, secret_id FROM graphql_source_query_param WHERE source_id = ?", |
| 136 | ) | 185 | ) |
| 137 | .get("example") as { kind: string; secret_id: string }; | 186 | .get("example"), |
| 138 | expect(paramRow).toMatchObject({ kind: "secret", secret_id: "sec-key" }); | 187 | ); |
| | 188 | expect(paramRow).toMatchObject({ kind: "secret", secret_id: "sec-key" }); |
| 139 | | 189 | |
| 140 | after.close(); | 190 | after.close(); |
| 141 | } finally { | | |
| 142 | rmSync(dir, { recursive: true, force: true }); | | |
| 143 | } | | |
| 144 | }); | 191 | }); |
| 145 | | 192 | |
| 146 | it("handles graphql_source rows with null json (empty config)", () => { | 193 | it("handles graphql_source rows with null json (empty config)", () => { |
| 147 | const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); | | |
| 148 | const dbPath = join(dir, "test.sqlite"); | 194 | const dbPath = join(dir, "test.sqlite"); |
| 149 | try { | 195 | const db = new Database(dbPath); |
| 150 | const db = new Database(dbPath); | 196 | db.exec(PRE_0007_SQL); |
| 151 | db.exec(PRE_0007_SQL); | 197 | stampPriorMigrationsApplied(db); |
| 152 | stampPriorMigrationsApplied(db); | | |
| 153 | | 198 | |
| 154 | db.prepare( | 199 | db.prepare( |
| 155 | "INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)", | 200 | "INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)", |
| 156 | ).run("default-scope", "bare", "Bare", "https://bare.example/graphql"); | 201 | ).run("default-scope", "bare", "Bare", "https://bare.example/graphql"); |
| 157 | db.close(); | 202 | db.close(); |
| 158 | | 203 | |
| 159 | const drizzleDb = drizzle(new Database(dbPath)); | 204 | const drizzleDb = drizzle(new Database(dbPath)); |
| 160 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 205 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); |
| 161 | | 206 | |
| 162 | const after = new Database(dbPath, { readonly: true }); | 207 | const after = new Database(dbPath, { readonly: true }); |
| 163 | const row = after | 208 | const row = decodeAuthRow( |
| | 209 | after |
| 164 | .prepare( | 210 | .prepare( |
| 165 | "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", | 211 | "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?", |
| 166 | ) | 212 | ) |
| 167 | .get("bare") as { auth_kind: string; auth_connection_id: string | null }; | 213 | .get("bare"), |
| 168 | expect(row.auth_kind).toBe("none"); | 214 | ); |
| 169 | expect(row.auth_connection_id).toBeNull(); | 215 | expect(row.auth_kind).toBe("none"); |
| 170 | | 216 | expect(row.auth_connection_id).toBeNull(); |
| 171 | const headerCount = ( | 217 | |
| 172 | after | 218 | const headerCount = decodeCountRow( |
| 173 | .prepare( | 219 | after |
| 174 | "SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?", | 220 | .prepare( |
| 175 | ) | 221 | "SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?", |
| 176 | .get("bare") as { n: number } | 222 | ) |
| 177 | ).n; | 223 | .get("bare"), |
| 178 | expect(headerCount).toBe(0); | 224 | ).n; |
| 179 | after.close(); | 225 | expect(headerCount).toBe(0); |
| 180 | } finally { | 226 | after.close(); |
| 181 | rmSync(dir, { recursive: true, force: true }); | | |
| 182 | } | | |
| 183 | }); | 227 | }); |
| 184 | | 228 | |
| 185 | it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => { | 229 | it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => { |
| 186 | const dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); | | |
| 187 | const dbPath = join(dir, "test.sqlite"); | 230 | const dbPath = join(dir, "test.sqlite"); |
| 188 | try { | 231 | const db = new Database(dbPath); |
| 189 | const db = new Database(dbPath); | 232 | db.exec(PRE_0007_SQL); |
| 190 | db.exec(PRE_0007_SQL); | 233 | stampPriorMigrationsApplied(db); |
| 191 | stampPriorMigrationsApplied(db); | 234 | |
| 192 | | 235 | const insert = db.prepare( |
| 193 | const insert = db.prepare( | 236 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)", |
| 194 | "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)", | 237 | ); |
| 195 | ); | 238 | insert.run( |
| 196 | insert.run( | 239 | "default-scope", |
| 197 | "default-scope", | 240 | "a:b", |
| 198 | "a:b", | 241 | "First", |
| 199 | "First", | 242 | "https://first.example/graphql", |
| 200 | "https://first.example/graphql", | 243 | JSON.stringify({ c: "first" }), |
| 201 | JSON.stringify({ c: "first" }), | 244 | ); |
| 202 | ); | 245 | insert.run( |
| 203 | insert.run( | 246 | "default-scope", |
| 204 | "default-scope", | 247 | "a", |
| 205 | "a", | 248 | "Second", |
| 206 | "Second", | 249 | "https://second.example/graphql", |
| 207 | "https://second.example/graphql", | 250 | JSON.stringify({ "b:c": "second" }), |
| 208 | JSON.stringify({ "b:c": "second" }), | 251 | ); |
| 209 | ); | 252 | db.close(); |
| 210 | db.close(); | 253 | |
| 211 | | 254 | const drizzleDb = drizzle(new Database(dbPath)); |
| 212 | const drizzleDb = drizzle(new Database(dbPath)); | 255 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); |
| 213 | migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); | 256 | |
| 214 | | 257 | const after = new Database(dbPath, { readonly: true }); |
| 215 | const after = new Database(dbPath, { readonly: true }); | 258 | const rows = decodeHeaderIdRows( |
| 216 | const rows = after | 259 | after |
| 217 | .prepare( | 260 | .prepare( |
| 218 | "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name", | 261 | "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name", |
| 219 | ) | 262 | ) |
| 220 | .all() as ReadonlyArray<{ | 263 | .all(), |
| 221 | id: string; | 264 | ); |
| 222 | source_id: string; | 265 | expect(rows).toHaveLength(2); |
| 223 | name: string; | 266 | expect(rows).toEqual([ |
| 224 | text_value: string; | 267 | { |
| 225 | }>; | 268 | id: '["a","b:c"]', |
| 226 | expect(rows).toHaveLength(2); | 269 | source_id: "a", |
| 227 | expect(rows).toEqual([ | 270 | name: "b:c", |
| 228 | { | 271 | text_value: "second", |
| 229 | id: '["a","b:c"]', | 272 | }, |
| 230 | source_id: "a", | 273 | { |
| 231 | name: "b:c", | 274 | id: '["a:b","c"]', |
| 232 | text_value: "second", | 275 | source_id: "a:b", |
| 233 | }, | 276 | name: "c", |
| 234 | { | 277 | text_value: "first", |
| 235 | id: '["a:b","c"]', | 278 | }, |
| 236 | source_id: "a:b", | 279 | ]); |
| 237 | name: "c", | 280 | after.close(); |
| 238 | text_value: "first", | | |
| 239 | }, | | |
| 240 | ]); | | |
| 241 | after.close(); | | |
| 242 | } finally { | | |
| 243 | rmSync(dir, { recursive: true, force: true }); | | |
| 244 | } | | |
| 245 | }); | 281 | }); |
| 246 | }); | 282 | }); |