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