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