// End-to-end test for the openapi portion of// openapi credential migrations. Seeds the pre-0007 shape// shape (json blobs on openapi_source.headers/query_params,// openapi_source.invocation_config.specFetchCredentials.*, and// openapi_source_binding.value), runs the migration runner, asserts// child rows and shared credential bindings match the old data. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";import { Database } from "bun:sqlite";import { mkdtempSync, rmSync } from "node:fs";import { join } from "node:path";import { tmpdir } from "node:os";import { Schema } from "effect";import { drizzle } from "drizzle-orm/bun-sqlite";import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); const BindingRow = Schema.Struct({ id: Schema.String, scope_id: Schema.String, plugin_id: Schema.String, source_id: Schema.String, source_scope_id: Schema.String, slot_key: Schema.String, kind: Schema.String, secret_id: Schema.NullOr(Schema.String), connection_id: Schema.NullOr(Schema.String), text_value: Schema.NullOr(Schema.String),}); const PluginStorageRow = Schema.Struct({ data: Schema.String,}); const CountRow = Schema.Struct({ n: Schema.Number,}); const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow));const decodeCountRow = Schema.decodeUnknownSync(CountRow);const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow);const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("0007_normalize_plugin_secret_refs (openapi)", () => { let dir: string; let dbPath: string; let openDatabases: Set<Database>; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); dbPath = join(dir, "test.sqlite"); openDatabases = new Set(); }); afterEach(() => { for (const db of openDatabases) { db.close(); } openDatabases.clear(); rmSync(dir, { recursive: true, force: true }); }); const openDatabase = (...args: ConstructorParameters<typeof Database>) => { const db = new Database(...args); openDatabases.add(db); return db; }; const closeDatabase = (db: Database) => { db.close(); openDatabases.delete(db); }; it("moves openapi_source_binding rows into shared credential_binding", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); // Seed three bindings, one per kind. const insert = db.prepare( "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ); const now = Date.now(); insert.run( "b1", "src", "default-scope", "default-scope", "header:authorization", JSON.stringify({ kind: "secret", secretId: "tok-secret" }), now, now, ); insert.run( "b2", "src", "default-scope", "default-scope", "oauth2:default:connection", JSON.stringify({ kind: "connection", connectionId: "conn-1" }), now, now, ); insert.run( "b3", "src", "default-scope", "default-scope", "header:x-static", JSON.stringify({ kind: "text", text: "literal" }), now, now, ); // Need the parent openapi_source row so the source_id FK ergonomics // are satisfied for any cascading delete logic, though the binding // table has no DB-level FK, code paths assume the parent exists. db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", ).run("default-scope", "src", "Source", "{}", "{}"); closeDatabase(db); const drizzleSqlite = openDatabase(dbPath); const drizzleDb = drizzle(drizzleSqlite); migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); const rows = decodeBindingRows( after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding ORDER BY id", ) .all(), ); expect(rows).toHaveLength(3); expect(rows[0]).toMatchObject({ id: '["openapi","default-scope","src","header:authorization"]', scope_id: "default-scope", plugin_id: "openapi", source_id: "src", source_scope_id: "default-scope", slot_key: "header:authorization", kind: "secret", secret_id: "tok-secret", connection_id: null, text_value: null, }); expect(rows[1]).toMatchObject({ id: '["openapi","default-scope","src","header:x-static"]', scope_id: "default-scope", plugin_id: "openapi", source_id: "src", source_scope_id: "default-scope", slot_key: "header:x-static", kind: "text", secret_id: null, connection_id: null, text_value: "literal", }); expect(rows[2]).toMatchObject({ id: '["openapi","default-scope","src","oauth2:default:connection"]', scope_id: "default-scope", plugin_id: "openapi", source_id: "src", source_scope_id: "default-scope", slot_key: "oauth2:default:connection", kind: "connection", secret_id: null, connection_id: "conn-1", text_value: null, }); const oldTableCount = decodeCountRow( after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_binding'", ) .get(), ); expect(oldTableCount.n).toBe(0); }); it("explodes query_params and specFetchCredentials json into child slot rows", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); const queryParams = { api_key: { secretId: "qp-secret" }, flag: "true", }; const invocationConfig = { specFetchCredentials: { headers: { Authorization: { secretId: "fetch-tok", prefix: "Bearer " }, }, queryParams: { token: { secretId: "fetch-qp" } }, }, }; db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", ).run( "default-scope", "src", "Source", "{}", JSON.stringify(queryParams), JSON.stringify(invocationConfig), ); closeDatabase(db); const drizzleSqlite = openDatabase(dbPath); const drizzleDb = drizzle(drizzleSqlite); migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); const sourceData = decodePluginStorageData( decodePluginStorageRow( after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) .get("openapi", "source", "src"), ).data, ) as { readonly config: { readonly queryParams?: Record<string, unknown>; readonly specFetchCredentials?: { readonly headers?: Record<string, unknown>; readonly queryParams?: Record<string, unknown>; }; }; }; expect(sourceData.config.queryParams).toMatchObject({ api_key: { kind: "binding", slot: "query_param:api-key" }, flag: "true", }); expect(sourceData.config.specFetchCredentials?.headers?.Authorization).toMatchObject({ kind: "binding", slot: "spec_fetch_header:authorization", prefix: "Bearer ", }); expect(sourceData.config.specFetchCredentials?.queryParams?.token).toMatchObject({ kind: "binding", slot: "spec_fetch_query_param:token", }); const oldQueryParamTableCount = decodeCountRow( after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'", ) .get(), ); expect(oldQueryParamTableCount.n).toBe(0); const bindings = decodeBindingRows( after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", ) .all("src"), ); expect(bindings.map((row) => [row.slot_key, row.kind, row.secret_id])).toEqual([ ["query_param:api-key", "secret", "qp-secret"], ["spec_fetch_header:authorization", "secret", "fetch-tok"], ["spec_fetch_query_param:token", "secret", "fetch-qp"], ]); const oldSourceTableCount = decodeCountRow( after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", ) .get(), ); expect(oldSourceTableCount.n).toBe(0); }); it("fails instead of silently collapsing colliding legacy query parameter slots", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", ).run( "default-scope", "collision", "Collision", "{}", JSON.stringify({ api_key: { secretId: "sec-underscore" }, "api-key": { secretId: "sec-dash" }, }), "{}", ); closeDatabase(db); const sqlite = openDatabase(dbPath); const drizzleDb = drizzle(sqlite); expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); closeDatabase(sqlite); }); it("fails on punctuation collisions that runtime canonicalization would collapse", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", ).run( "default-scope", "punctuation-collision", "Punctuation Collision", "{}", JSON.stringify({ "X@Token": { secretId: "sec-at" }, "X-Token": { secretId: "sec-dash" }, }), "{}", ); closeDatabase(db); const sqlite = openDatabase(dbPath); const drizzleDb = drizzle(sqlite); expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); closeDatabase(sqlite); }); it("rewrites old OpenAPI header and OAuth JSON into slot config plus core bindings", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); const headers = { Authorization: { secretId: "header-token", prefix: "Bearer " }, "X-Static": "literal", "X-Already": { kind: "binding", slot: "header:x-already" }, }; const oauth2 = { kind: "oauth2", connectionId: "conn-1", securitySchemeName: "oauth2", flow: "authorizationCode", tokenUrl: "https://auth.example.com/token", authorizationUrl: "https://auth.example.com/authorize", issuerUrl: "https://auth.example.com", clientIdSecretId: "client-id", clientSecretSecretId: "client-secret", scopes: ["read"], }; db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, headers, oauth2, invocation_config) VALUES (?, ?, ?, ?, ?, ?, ?)", ).run( "default-scope", "src", "Source", "{}", JSON.stringify(headers), JSON.stringify(oauth2), JSON.stringify({}), ); closeDatabase(db); const drizzleSqlite = openDatabase(dbPath); const drizzleDb = drizzle(drizzleSqlite); migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); const source = decodePluginStorageData( decodePluginStorageRow( after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) .get("openapi", "source", "src"), ).data, ) as { readonly config: { readonly headers?: Record<string, unknown>; readonly oauth2?: Record<string, unknown>; }; }; expect(source.config.headers).toMatchObject({ Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, "X-Static": "literal", "X-Already": { kind: "binding", slot: "header:x-already" }, }); const oldHeaderTableCount = decodeCountRow( after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'", ) .get(), ); expect(oldHeaderTableCount.n).toBe(0); const migratedOAuth2 = source.config.oauth2 ?? {}; expect(migratedOAuth2).toMatchObject({ kind: "oauth2", securitySchemeName: "oauth2", clientIdSlot: "oauth2:oauth2:client-id", clientSecretSlot: "oauth2:oauth2:client-secret", connectionSlot: "oauth2:oauth2:connection", }); expect(migratedOAuth2).not.toHaveProperty("connectionId"); expect(migratedOAuth2).not.toHaveProperty("clientIdSecretId"); const bindings = decodeBindingRows( after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", ) .all("src"), ); expect( bindings.map((row) => [row.slot_key, row.kind, row.secret_id, row.connection_id]), ).toEqual([ ["header:authorization", "secret", "header-token", null], ["oauth2:oauth2:client-id", "secret", "client-id", null], ["oauth2:oauth2:client-secret", "secret", "client-secret", null], ["oauth2:oauth2:connection", "connection", null, "conn-1"], ]); const oldSourceTableCount = decodeCountRow( after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", ) .get(), ); expect(oldSourceTableCount.n).toBe(0); }); it("survives empty / missing json on bindings and sources", () => { const db = openDatabase(dbPath); db.exec(PRE_0007_SQL); stampPriorMigrationsApplied(db); // Source with empty invocation_config and no query_params. db.prepare( "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); closeDatabase(db); const drizzleSqlite = openDatabase(dbPath); const drizzleDb = drizzle(drizzleSqlite); migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); const source = decodePluginStorageData( decodePluginStorageRow( after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) .get("openapi", "source", "bare"), ).data, ) as { readonly config: { readonly queryParams?: unknown } }; expect(source.config.queryParams).toBeUndefined(); });});
// End-to-end test for the openapi portion of
// openapi credential migrations. Seeds the pre-0007 shape
// shape (json blobs on openapi_source.headers/query_params,
// openapi_source.invocation_config.specFetchCredentials.*, and
// openapi_source_binding.value), runs the migration runner, asserts
// child rows and shared credential bindings match the old data.
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { Database } from "bun:sqlite";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Schema } from "effect";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
const BindingRow = Schema.Struct({
id: Schema.String,
scope_id: Schema.String,
plugin_id: Schema.String,
source_id: Schema.String,
source_scope_id: Schema.String,
slot_key: Schema.String,
kind: Schema.String,
secret_id: Schema.NullOr(Schema.String),
connection_id: Schema.NullOr(Schema.String),
text_value: Schema.NullOr(Schema.String),
});
const PluginStorageRow = Schema.Struct({
data: Schema.String,
});
const CountRow = Schema.Struct({
n: Schema.Number,
});
const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow));
const decodeCountRow = Schema.decodeUnknownSync(CountRow);
const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow);
const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
describe("0007_normalize_plugin_secret_refs (openapi)", () => {
let dir: string;
let dbPath: string;
let openDatabases: Set<Database>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
dbPath = join(dir, "test.sqlite");
openDatabases = new Set();
});
afterEach(() => {
for (const db of openDatabases) {
db.close();
}
openDatabases.clear();
rmSync(dir, { recursive: true, force: true });
});
const openDatabase = (...args: ConstructorParameters<typeof Database>) => {
const db = new Database(...args);
openDatabases.add(db);
return db;
};
const closeDatabase = (db: Database) => {
db.close();
openDatabases.delete(db);
};
it("moves openapi_source_binding rows into shared credential_binding", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
// Seed three bindings, one per kind.
const insert = db.prepare(
"INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const now = Date.now();
insert.run(
"b1",
"src",
"default-scope",
"default-scope",
"header:authorization",
JSON.stringify({ kind: "secret", secretId: "tok-secret" }),
now,
now,
);
insert.run(
"b2",
"src",
"default-scope",
"default-scope",
"oauth2:default:connection",
JSON.stringify({ kind: "connection", connectionId: "conn-1" }),
now,
now,
);
insert.run(
"b3",
"src",
"default-scope",
"default-scope",
"header:x-static",
JSON.stringify({ kind: "text", text: "literal" }),
now,
now,
);
// Need the parent openapi_source row so the source_id FK ergonomics
// are satisfied for any cascading delete logic, though the binding
// table has no DB-level FK, code paths assume the parent exists.
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)",
).run("default-scope", "src", "Source", "{}", "{}");
closeDatabase(db);
const drizzleSqlite = openDatabase(dbPath);
const drizzleDb = drizzle(drizzleSqlite);
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
closeDatabase(drizzleSqlite);
const after = openDatabase(dbPath, { readonly: true });
const rows = decodeBindingRows(
after
.prepare(
"SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding ORDER BY id",
)
.all(),
);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({
id: '["openapi","default-scope","src","header:authorization"]',
scope_id: "default-scope",
plugin_id: "openapi",
source_id: "src",
source_scope_id: "default-scope",
slot_key: "header:authorization",
kind: "secret",
secret_id: "tok-secret",
connection_id: null,
text_value: null,
});
expect(rows[1]).toMatchObject({
id: '["openapi","default-scope","src","header:x-static"]',
scope_id: "default-scope",
plugin_id: "openapi",
source_id: "src",
source_scope_id: "default-scope",
slot_key: "header:x-static",
kind: "text",
secret_id: null,
connection_id: null,
text_value: "literal",
});
expect(rows[2]).toMatchObject({
id: '["openapi","default-scope","src","oauth2:default:connection"]',
scope_id: "default-scope",
plugin_id: "openapi",
source_id: "src",
source_scope_id: "default-scope",
slot_key: "oauth2:default:connection",
kind: "connection",
secret_id: null,
connection_id: "conn-1",
text_value: null,
});
const oldTableCount = decodeCountRow(
after
.prepare(
"SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_binding'",
)
.get(),
);
expect(oldTableCount.n).toBe(0);
});
it("explodes query_params and specFetchCredentials json into child slot rows", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
const queryParams = {
api_key: { secretId: "qp-secret" },
flag: "true",
};
const invocationConfig = {
specFetchCredentials: {
headers: {
Authorization: { secretId: "fetch-tok", prefix: "Bearer " },
},
queryParams: { token: { secretId: "fetch-qp" } },
},
};
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)",
).run(
"default-scope",
"src",
"Source",
"{}",
JSON.stringify(queryParams),
JSON.stringify(invocationConfig),
);
closeDatabase(db);
const drizzleSqlite = openDatabase(dbPath);
const drizzleDb = drizzle(drizzleSqlite);
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
closeDatabase(drizzleSqlite);
const after = openDatabase(dbPath, { readonly: true });
const sourceData = decodePluginStorageData(
decodePluginStorageRow(
after
.prepare(
"SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?",
)
.get("openapi", "source", "src"),
).data,
) as {
readonly config: {
readonly queryParams?: Record<string, unknown>;
readonly specFetchCredentials?: {
readonly headers?: Record<string, unknown>;
readonly queryParams?: Record<string, unknown>;
};
};
};
expect(sourceData.config.queryParams).toMatchObject({
api_key: { kind: "binding", slot: "query_param:api-key" },
flag: "true",
});
expect(sourceData.config.specFetchCredentials?.headers?.Authorization).toMatchObject({
kind: "binding",
slot: "spec_fetch_header:authorization",
prefix: "Bearer ",
});
expect(sourceData.config.specFetchCredentials?.queryParams?.token).toMatchObject({
kind: "binding",
slot: "spec_fetch_query_param:token",
});
const oldQueryParamTableCount = decodeCountRow(
after
.prepare(
"SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'",
)
.get(),
);
expect(oldQueryParamTableCount.n).toBe(0);
const bindings = decodeBindingRows(
after
.prepare(
"SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key",
)
.all("src"),
);
expect(bindings.map((row) => [row.slot_key, row.kind, row.secret_id])).toEqual([
["query_param:api-key", "secret", "qp-secret"],
["spec_fetch_header:authorization", "secret", "fetch-tok"],
["spec_fetch_query_param:token", "secret", "fetch-qp"],
]);
const oldSourceTableCount = decodeCountRow(
after
.prepare(
"SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'",
)
.get(),
);
expect(oldSourceTableCount.n).toBe(0);
});
it("fails instead of silently collapsing colliding legacy query parameter slots", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)",
).run(
"default-scope",
"collision",
"Collision",
"{}",
JSON.stringify({
api_key: { secretId: "sec-underscore" },
"api-key": { secretId: "sec-dash" },
}),
"{}",
);
closeDatabase(db);
const sqlite = openDatabase(dbPath);
const drizzleDb = drizzle(sqlite);
expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow();
closeDatabase(sqlite);
});
it("fails on punctuation collisions that runtime canonicalization would collapse", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)",
).run(
"default-scope",
"punctuation-collision",
"Punctuation Collision",
"{}",
JSON.stringify({
"X@Token": { secretId: "sec-at" },
"X-Token": { secretId: "sec-dash" },
}),
"{}",
);
closeDatabase(db);
const sqlite = openDatabase(dbPath);
const drizzleDb = drizzle(sqlite);
expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow();
closeDatabase(sqlite);
});
it("rewrites old OpenAPI header and OAuth JSON into slot config plus core bindings", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
const headers = {
Authorization: { secretId: "header-token", prefix: "Bearer " },
"X-Static": "literal",
"X-Already": { kind: "binding", slot: "header:x-already" },
};
const oauth2 = {
kind: "oauth2",
connectionId: "conn-1",
securitySchemeName: "oauth2",
flow: "authorizationCode",
tokenUrl: "https://auth.example.com/token",
authorizationUrl: "https://auth.example.com/authorize",
issuerUrl: "https://auth.example.com",
clientIdSecretId: "client-id",
clientSecretSecretId: "client-secret",
scopes: ["read"],
};
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, headers, oauth2, invocation_config) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(
"default-scope",
"src",
"Source",
"{}",
JSON.stringify(headers),
JSON.stringify(oauth2),
JSON.stringify({}),
);
closeDatabase(db);
const drizzleSqlite = openDatabase(dbPath);
const drizzleDb = drizzle(drizzleSqlite);
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
closeDatabase(drizzleSqlite);
const after = openDatabase(dbPath, { readonly: true });
const source = decodePluginStorageData(
decodePluginStorageRow(
after
.prepare(
"SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?",
)
.get("openapi", "source", "src"),
).data,
) as {
readonly config: {
readonly headers?: Record<string, unknown>;
readonly oauth2?: Record<string, unknown>;
};
};
expect(source.config.headers).toMatchObject({
Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " },
"X-Static": "literal",
"X-Already": { kind: "binding", slot: "header:x-already" },
});
const oldHeaderTableCount = decodeCountRow(
after
.prepare(
"SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'",
)
.get(),
);
expect(oldHeaderTableCount.n).toBe(0);
const migratedOAuth2 = source.config.oauth2 ?? {};
expect(migratedOAuth2).toMatchObject({
kind: "oauth2",
securitySchemeName: "oauth2",
clientIdSlot: "oauth2:oauth2:client-id",
clientSecretSlot: "oauth2:oauth2:client-secret",
connectionSlot: "oauth2:oauth2:connection",
});
expect(migratedOAuth2).not.toHaveProperty("connectionId");
expect(migratedOAuth2).not.toHaveProperty("clientIdSecretId");
const bindings = decodeBindingRows(
after
.prepare(
"SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key",
)
.all("src"),
);
expect(
bindings.map((row) => [row.slot_key, row.kind, row.secret_id, row.connection_id]),
).toEqual([
["header:authorization", "secret", "header-token", null],
["oauth2:oauth2:client-id", "secret", "client-id", null],
["oauth2:oauth2:client-secret", "secret", "client-secret", null],
["oauth2:oauth2:connection", "connection", null, "conn-1"],
]);
const oldSourceTableCount = decodeCountRow(
after
.prepare(
"SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'",
)
.get(),
);
expect(oldSourceTableCount.n).toBe(0);
});
it("survives empty / missing json on bindings and sources", () => {
const db = openDatabase(dbPath);
db.exec(PRE_0007_SQL);
stampPriorMigrationsApplied(db);
// Source with empty invocation_config and no query_params.
db.prepare(
"INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)",
).run("default-scope", "bare", "Bare", "{}", JSON.stringify({}));
closeDatabase(db);
const drizzleSqlite = openDatabase(dbPath);
const drizzleDb = drizzle(drizzleSqlite);
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
closeDatabase(drizzleSqlite);
const after = openDatabase(dbPath, { readonly: true });
const source = decodePluginStorageData(
decodePluginStorageRow(
after
.prepare(
"SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?",
)
.get("openapi", "source", "bare"),
).data,
) as { readonly config: { readonly queryParams?: unknown } };
expect(source.config.queryParams).toBeUndefined();
});
});