History

Parse local migration test rows with Schema

Rhys Sullivan committed 6 months ago

Commit daa17631 parent b039b6e2 files · +407 −364Browse files

Unified · Split

2 files
  1. apps/local/src/server/migrate-google-discovery-bindings.test.ts +179 −172
  2. apps/local/src/server/migrate-graphql-bindings.test.ts +228 −192
apps/local/src/server/migrate-google-discovery-bindings.test.ts+179−172
@@ -4,8 +4,9 @@ · 3 unmodified lines
44 // containing auth/credentials), runs the migration, asserts the new
55 // columns and child tables are populated.
66
7−import { describe, expect, it } from "@effect/vitest";
7+import { afterEach, describe, expect, it } from "@effect/vitest";
88 import { Database } from "bun:sqlite";
9+import { Schema } from "effect";
910 import { mkdtempSync, rmSync } from "node:fs";
1011 import { join } from "node:path";
1112 import { tmpdir } from "node:os";
@@ -16,189 +17,195 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr · 4 unmodified lines
1617
1718 const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
1819
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));
5225
53− db.close();
54− const drizzleDb = drizzle(new Database(dbPath));
55− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
26+const tempDirs = new Set<string>();
5627
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) {
7537 rmSync(dir, { recursive: true, force: true });
7638 }
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();
7794 });
7895
7996 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 " },
109120 },
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();
149161 });
150162
151163 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
184203 .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 = ?",
186205 )
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();
203210 });
204211 });
apps/local/src/server/migrate-graphql-bindings.test.ts+228−192
@@ -4,8 +4,9 @@ · 3 unmodified lines
44 // run the migration, assert that the JSON unpacks into the new
55 // normalized columns / child tables and that the JSON columns are gone.
66
7−import { describe, expect, it } from "@effect/vitest";
7+import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
88 import { Database } from "bun:sqlite";
9+import { Schema } from "effect";
910 import { mkdtempSync, rmSync } from "node:fs";
1011 import { join } from "node:path";
1112 import { tmpdir } from "node:os";
@@ -16,231 +17,266 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr · 4 unmodified lines
1617
1718 const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
1819
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+
1974 describe("0007_normalize_plugin_secret_refs (graphql)", () => {
2075 it("flattens auth json into auth_kind/auth_connection_id columns", () => {
21− const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
2276 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
4599 .prepare(
46100 "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?",
47101 )
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
53109 .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();
62116 });
63117
64118 it("explodes header/query_param json into child rows", () => {
65− const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
66119 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
103156 .prepare(
104157 "SELECT name, kind, text_value, secret_id, secret_prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name",
105158 )
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
134183 .prepare(
135184 "SELECT kind, secret_id FROM graphql_source_query_param WHERE source_id = ?",
136185 )
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" });
139189
140− after.close();
141− } finally {
142− rmSync(dir, { recursive: true, force: true });
143− }
190+ after.close();
144191 });
145192
146193 it("handles graphql_source rows with null json (empty config)", () => {
147− const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
148194 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);
153198
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();
158203
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 });
161206
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
164210 .prepare(
165211 "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?",
166212 )
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();
183227 });
184228
185229 it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => {
186− const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
187230 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
217260 .prepare(
218261 "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name",
219262 )
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();
245281 });
246282 });