History

Parse OpenAPI migration test rows with Schema

Rhys Sullivan committed 6 months ago

Commit ffc13741 parent b039b6e1 file · +244 −202Browse files

Unified · Split

apps/local/src/server/migrate-openapi-bindings.test.ts+244−202
@@ -5,11 +5,12 @@ · 4 unmodified lines
55 // openapi_source_binding.value), runs the migration runner, asserts
66 // the new flat columns + child tables match.
77
8−import { describe, expect, it } from "@effect/vitest";
8+import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
99 import { Database } from "bun:sqlite";
1010 import { mkdtempSync, rmSync } from "node:fs";
1111 import { join } from "node:path";
1212 import { tmpdir } from "node:os";
13+import { Schema } from "effect";
1314 import { drizzle } from "drizzle-orm/bun-sqlite";
1415 import { migrate } from "drizzle-orm/bun-sqlite/migrator";
1516
@@ -17,235 +18,276 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr · 1 unmodified line
1718
1819 const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
1920
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+
2056 describe("0007_normalize_plugin_secret_refs (openapi)", () => {
21− it("flattens openapi_source_binding.value into kind/secret_id/connection_id/text_value", () => {
22− const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
23− const dbPath = join(dir, "test.sqlite");
24− try {
25− const db = new Database(dbPath);
26− db.exec(PRE_0007_SQL);
27− stampPriorMigrationsApplied(db);
28−
29− // Seed three bindings, one per kind.
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", "{}", "{}");
57+ let dir: string;
58+ let dbPath: string;
59+ let openDatabases: Set<Database>;
60+
61+ beforeEach(() => {
62+ dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
63+ dbPath = join(dir, "test.sqlite");
64+ openDatabases = new Set();
65+ });
7166
67+ afterEach(() => {
68+ for (const db of openDatabases) {
7269 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+ };
7385
74− const drizzleDb = drizzle(new Database(dbPath));
75− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
86+ it("flattens openapi_source_binding.value into kind/secret_id/connection_id/text_value", () => {
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);
76140
77− const after = new Database(dbPath, { readonly: true });
78− const rows = after
141+ const after = openDatabase(dbPath, { readonly: true });
142+ const rows = Schema.decodeUnknownSync(Schema.Array(BindingRow))(
143+ after
79144 .prepare(
80145 "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id",
81146 )
82− .all() as ReadonlyArray<{
83− id: string;
84− kind: string;
85− secret_id: string | null;
86− connection_id: string | null;
87− text_value: string | null;
88− }>;
89− expect(rows).toHaveLength(3);
90− expect(rows[0]).toMatchObject({
91− id: "b1",
92− kind: "secret",
93− secret_id: "tok-secret",
94− connection_id: null,
95− text_value: null,
96− });
97− expect(rows[1]).toMatchObject({
98− id: "b2",
99− kind: "connection",
100− secret_id: null,
101− connection_id: "conn-1",
102− text_value: null,
103− });
104− expect(rows[2]).toMatchObject({
105− id: "b3",
106− kind: "text",
107− secret_id: null,
108− connection_id: null,
109− text_value: "literal",
110− });
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− }
147+ .all(),
148+ );
149+ expect(rows).toHaveLength(3);
150+ expect(rows[0]).toMatchObject({
151+ id: "b1",
152+ kind: "secret",
153+ secret_id: "tok-secret",
154+ connection_id: null,
155+ text_value: null,
156+ });
157+ expect(rows[1]).toMatchObject({
158+ id: "b2",
159+ kind: "connection",
160+ secret_id: null,
161+ connection_id: "conn-1",
162+ text_value: null,
163+ });
164+ expect(rows[2]).toMatchObject({
165+ id: "b3",
166+ kind: "text",
167+ secret_id: null,
168+ connection_id: null,
169+ text_value: "literal",
170+ });
171+ // value json column dropped.
172+ const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))(
173+ after.prepare("PRAGMA table_info('openapi_source_binding')").all(),
174+ );
175+ expect(cols.some((c) => c.name === "value")).toBe(false);
120176 });
121177
122178 it("explodes query_params and specFetchCredentials json into child rows", () => {
123− const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
124− const dbPath = join(dir, "test.sqlite");
125− try {
126− const db = new Database(dbPath);
127− db.exec(PRE_0007_SQL);
128− stampPriorMigrationsApplied(db);
129−
130− const queryParams = {
131− api_key: { secretId: "qp-secret" },
132− flag: "true",
133− };
134− const invocationConfig = {
135− specFetchCredentials: {
136− headers: {
137− Authorization: { secretId: "fetch-tok", prefix: "Bearer " },
138− },
139− queryParams: { token: { secretId: "fetch-qp" } },
179+ const db = openDatabase(dbPath);
180+ db.exec(PRE_0007_SQL);
181+ stampPriorMigrationsApplied(db);
182+
183+ const queryParams = {
184+ api_key: { secretId: "qp-secret" },
185+ flag: "true",
186+ };
187+ const invocationConfig = {
188+ specFetchCredentials: {
189+ headers: {
190+ Authorization: { secretId: "fetch-tok", prefix: "Bearer " },
140191 },
141− };
142−
143− db.prepare(
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− );
192+ queryParams: { token: { secretId: "fetch-qp" } },
193+ },
194+ };
153195
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+ );
155206
156− const drizzleDb = drizzle(new Database(dbPath));
157− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
207+ closeDatabase(db);
158208
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);
160213
161− const qpRows = after
214+ const after = openDatabase(dbPath, { readonly: true });
215+
216+ const qpRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow))(
217+ after
162218 .prepare(
163219 "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name",
164220 )
165− .all("src") as ReadonlyArray<{
166− name: string;
167− kind: string;
168− text_value: string | null;
169− secret_id: string | null;
170− }>;
171− expect(qpRows).toHaveLength(2);
172− const byName = new Map(qpRows.map((r) => [r.name, r]));
173− expect(byName.get("api_key")).toMatchObject({
174− kind: "secret",
175− secret_id: "qp-secret",
176− });
177− expect(byName.get("flag")).toMatchObject({
178− kind: "text",
179− text_value: "true",
180− });
181−
182− const fetchHeaders = after
221+ .all("src"),
222+ );
223+ expect(qpRows).toHaveLength(2);
224+ const byName = new Map(qpRows.map((r) => [r.name, r]));
225+ expect(byName.get("api_key")).toMatchObject({
226+ kind: "secret",
227+ secret_id: "qp-secret",
228+ });
229+ expect(byName.get("flag")).toMatchObject({
230+ kind: "text",
231+ text_value: "true",
232+ });
233+
234+ const fetchHeaders = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow))(
235+ after
183236 .prepare(
184237 "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?",
185238 )
186− .all("src") as ReadonlyArray<{
187− name: string;
188− kind: string;
189− secret_id: string | null;
190− secret_prefix: string | null;
191− }>;
192− expect(fetchHeaders).toHaveLength(1);
193− expect(fetchHeaders[0]).toMatchObject({
194− name: "Authorization",
195− kind: "secret",
196− secret_id: "fetch-tok",
197− secret_prefix: "Bearer ",
198− });
199−
200− const fetchQp = after
239+ .all("src"),
240+ );
241+ expect(fetchHeaders).toHaveLength(1);
242+ expect(fetchHeaders[0]).toMatchObject({
243+ name: "Authorization",
244+ kind: "secret",
245+ secret_id: "fetch-tok",
246+ secret_prefix: "Bearer ",
247+ });
248+
249+ const fetchQp = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow))(
250+ after
201251 .prepare(
202252 "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?",
203253 )
204− .all("src") as ReadonlyArray<{ name: string; secret_id: string }>;
205− expect(fetchQp).toHaveLength(1);
206− expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" });
207−
208− // Old json columns dropped.
209− const cols = after
210− .prepare("PRAGMA table_info('openapi_source')")
211− .all() as ReadonlyArray<{ name: string }>;
212− expect(cols.some((c) => c.name === "query_params")).toBe(false);
213− expect(cols.some((c) => c.name === "invocation_config")).toBe(false);
214− after.close();
215− } finally {
216− rmSync(dir, { recursive: true, force: true });
217− }
254+ .all("src"),
255+ );
256+ expect(fetchQp).toHaveLength(1);
257+ expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" });
258+
259+ // Old json columns dropped.
260+ const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))(
261+ after.prepare("PRAGMA table_info('openapi_source')").all(),
262+ );
263+ expect(cols.some((c) => c.name === "query_params")).toBe(false);
264+ expect(cols.some((c) => c.name === "invocation_config")).toBe(false);
218265 });
219266
220267 it("survives empty / missing json on bindings and sources", () => {
221− const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
222− const dbPath = join(dir, "test.sqlite");
223− try {
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({}));
268+ const db = openDatabase(dbPath);
269+ db.exec(PRE_0007_SQL);
270+ stampPriorMigrationsApplied(db);
232271
233− db.close();
234− const drizzleDb = drizzle(new Database(dbPath));
235− migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
236−
237− const after = new Database(dbPath, { readonly: true });
238− const qpCount = (
239− after
240− .prepare(
241− "SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?",
242− )
243− .get("bare") as { n: number }
244− ).n;
245− expect(qpCount).toBe(0);
246− after.close();
247− } finally {
248− rmSync(dir, { recursive: true, force: true });
249− }
272+ // Source with empty invocation_config and no query_params.
273+ db.prepare(
274+ "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)",
275+ ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({}));
276+
277+ closeDatabase(db);
278+ const drizzleSqlite = openDatabase(dbPath);
279+ const drizzleDb = drizzle(drizzleSqlite);
280+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
281+ closeDatabase(drizzleSqlite);
282+
283+ const after = openDatabase(dbPath, { readonly: true });
284+ const qpCount = Schema.decodeUnknownSync(CountRow)(
285+ after
286+ .prepare(
287+ "SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?",
288+ )
289+ .get("bare"),
290+ ).n;
291+ expect(qpCount).toBe(0);
250292 });
251293 });