371 lines · 11.3 KB · TypeScript
RawDownload
// End-to-end test for the graphql portion of// GraphQL credential migrations: seed a DB at the pre-migration shape// with json-blob headers/query_params/auth, run all migrations, and// assert the final slot model plus shared credential_binding rows. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";import { Database } from "bun:sqlite";import { Schema } from "effect";import { mkdtempSync, rmSync } from "node:fs";import { join } from "node:path";import { tmpdir } from "node:os";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 NullableString = Schema.NullOr(Schema.String); const GraphqlAuthRow = Schema.Struct({  auth_kind: Schema.String,  auth_connection_slot: NullableString,}); const TableInfoRow = Schema.Struct({  name: Schema.String,}); const GraphqlHeaderRow = Schema.Struct({  name: Schema.String,  kind: Schema.String,  text_value: NullableString,  slot_key: NullableString,  prefix: NullableString,}); const GraphqlQueryParamRow = Schema.Struct({  kind: Schema.String,  slot_key: Schema.String,}); const BindingRow = Schema.Struct({  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: NullableString,  connection_id: NullableString,}); const CountRow = Schema.Struct({  n: Schema.Number,}); const GraphqlHeaderIdRow = Schema.Struct({  id: Schema.String,  source_id: Schema.String,  name: Schema.String,  text_value: Schema.String,}); const decodeAuthRow = Schema.decodeUnknownSync(GraphqlAuthRow);const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow));const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderRow));const decodeQueryParamRow = Schema.decodeUnknownSync(GraphqlQueryParamRow);const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow));const decodeCountRow = Schema.decodeUnknownSync(CountRow);const decodeHeaderIdRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderIdRow)); let dir: string; beforeEach(() => {  dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));}); afterEach(() => {  rmSync(dir, { recursive: true, force: true });}); describe("graphql credential migrations", () => {  it("moves auth json connection refs into a connection slot binding", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     db.prepare(      "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)",    ).run(      "default-scope",      "github",      "GitHub",      "https://api.github.com/graphql",      JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }),    );     db.close();     const drizzleDb = drizzle(new Database(dbPath));    migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });     const after = new Database(dbPath, { readonly: true });    const row = decodeAuthRow(      after        .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?")        .get("github"),    );    expect(row.auth_kind).toBe("oauth2");    expect(row.auth_connection_slot).toBe("auth:oauth2:connection");    const bindings = decodeBindingRows(      after        .prepare(          "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key",        )        .all("graphql"),    );    expect(bindings).toEqual([      {        scope_id: "default-scope",        plugin_id: "graphql",        source_id: "github",        source_scope_id: "default-scope",        slot_key: "auth:oauth2:connection",        kind: "connection",        secret_id: null,        connection_id: "conn-1",      },    ]);    // Old json column is gone.    const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('graphql_source')").all());    expect(cols.some((c) => c.name === "auth")).toBe(false);    expect(cols.some((c) => c.name === "headers")).toBe(false);    expect(cols.some((c) => c.name === "query_params")).toBe(false);    expect(cols.some((c) => c.name === "auth_connection_id")).toBe(false);    after.close();  });   it("explodes header/query_param json into slots and credential bindings", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     const headers = {      // Literal text header.      "X-Static": "literal-value",      // Secret-backed header without prefix.      Authorization: { secretId: "sec-token" },      // Secret-backed with prefix.      "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " },    };    const queryParams = {      api_key: { secretId: "sec-key" },    };     db.prepare(      "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)",    ).run(      "default-scope",      "example",      "Example",      "https://example.com/graphql",      JSON.stringify(headers),      JSON.stringify(queryParams),      JSON.stringify({ kind: "none" }),    );     db.close();     const drizzleDb = drizzle(new Database(dbPath));    migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });     const after = new Database(dbPath, { readonly: true });    const headerRows = decodeHeaderRows(      after        .prepare(          "SELECT name, kind, text_value, slot_key, prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name",        )        .all("example"),    );    expect(headerRows).toHaveLength(3);     const byName = new Map(headerRows.map((r) => [r.name, r]));    expect(byName.get("X-Static")).toMatchObject({      kind: "text",      text_value: "literal-value",      slot_key: null,    });    expect(byName.get("Authorization")).toMatchObject({      kind: "binding",      text_value: null,      slot_key: "header:authorization",      prefix: null,    });    expect(byName.get("X-Bearer")).toMatchObject({      kind: "binding",      slot_key: "header:x-bearer",      prefix: "Bearer ",    });     const paramRow = decodeQueryParamRow(      after        .prepare("SELECT kind, slot_key FROM graphql_source_query_param WHERE source_id = ?")        .get("example"),    );    expect(paramRow).toMatchObject({ kind: "binding", slot_key: "query_param:api-key" });     const bindings = decodeBindingRows(      after        .prepare(          "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key",        )        .all("graphql"),    );    expect(bindings.map((binding) => [binding.slot_key, binding.secret_id])).toEqual([      ["header:authorization", "sec-token"],      ["header:x-bearer", "sec-bearer"],      ["query_param:api-key", "sec-key"],    ]);     after.close();  });   it("fails instead of silently collapsing colliding legacy query parameter slots", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     db.prepare(      "INSERT INTO graphql_source (scope_id, id, name, endpoint, query_params, auth) VALUES (?, ?, ?, ?, ?, ?)",    ).run(      "default-scope",      "collision",      "Collision",      "https://example.com/graphql",      JSON.stringify({        api_key: { secretId: "sec-underscore" },        "api-key": { secretId: "sec-dash" },      }),      JSON.stringify({ kind: "none" }),    );     db.close();     const sqlite = new Database(dbPath);    const drizzleDb = drizzle(sqlite);    expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow();    sqlite.close();  });   it("fails instead of silently collapsing colliding legacy header slots", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     db.prepare(      "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, auth) VALUES (?, ?, ?, ?, ?, ?)",    ).run(      "default-scope",      "collision",      "Collision",      "https://example.com/graphql",      JSON.stringify({        x_token: { secretId: "sec-underscore" },        "x-token": { secretId: "sec-dash" },      }),      JSON.stringify({ kind: "none" }),    );     db.close();     const sqlite = new Database(dbPath);    const drizzleDb = drizzle(sqlite);    expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow();    sqlite.close();  });   it("handles graphql_source rows with null json (empty config)", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     db.prepare("INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)").run(      "default-scope",      "bare",      "Bare",      "https://bare.example/graphql",    );    db.close();     const drizzleDb = drizzle(new Database(dbPath));    migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });     const after = new Database(dbPath, { readonly: true });    const row = decodeAuthRow(      after        .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?")        .get("bare"),    );    expect(row.auth_kind).toBe("none");    expect(row.auth_connection_slot).toBeNull();     const headerCount = decodeCountRow(      after        .prepare("SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?")        .get("bare"),    ).n;    expect(headerCount).toBe(0);    after.close();  });   it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => {    const dbPath = join(dir, "test.sqlite");    const db = new Database(dbPath);    db.exec(PRE_0007_SQL);    stampPriorMigrationsApplied(db);     const insert = db.prepare(      "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)",    );    insert.run(      "default-scope",      "a:b",      "First",      "https://first.example/graphql",      JSON.stringify({ c: "first" }),    );    insert.run(      "default-scope",      "a",      "Second",      "https://second.example/graphql",      JSON.stringify({ "b:c": "second" }),    );    db.close();     const drizzleDb = drizzle(new Database(dbPath));    migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });     const after = new Database(dbPath, { readonly: true });    const rows = decodeHeaderIdRows(      after        .prepare(          "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name",        )        .all(),    );    expect(rows).toHaveLength(2);    expect(rows).toEqual([      {        id: '["a","b:c"]',        source_id: "a",        name: "b:c",        text_value: "second",      },      {        id: '["a:b","c"]',        source_id: "a:b",        name: "c",        text_value: "first",      },    ]);    after.close();  });});