import { describe, expect, it } from "@effect/vitest";
import { Effect, Fiber } from "effect";
import { collectTables } from "./executor";
import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
import { runSqliteOAuthClientGcMigration } from "./sqlite-oauth-client-gc-migration";
// Integration coverage for the local libSQL GC + backfill migration against a
// real SQLite schema (issue #1120, Part C). Complements the pure decision-matrix
// tests in oauth-gc.test.ts by exercising the actual SQL: seeded rows in, the
// migration runs, surviving rows asserted.
const TENANT = "t1";
const OWNER = "org";
const SUBJECT = "s1";
const withDb = (body: (db: SqliteTestFumaDb) => Promise): Promise =>
Effect.runPromise(
Effect.acquireUseRelease(
Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
(db) => Effect.promise(() => body(db)),
(db) => Effect.promise(() => db.close()),
),
);
const insertOAuthClient = (
db: SqliteTestFumaDb,
row: {
readonly slug: string;
readonly tokenUrl: string;
readonly grant?: string;
readonly resource?: string | null;
readonly originKind?: string | null;
readonly originIssuer?: string | null;
},
): Promise =>
db.client.execute({
sql: `INSERT INTO oauth_client
(row_id, tenant, owner, subject, slug, authorization_url, token_url, "grant",
client_id, resource, origin_kind, origin_issuer, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
`oc_${row.slug}`,
TENANT,
OWNER,
SUBJECT,
row.slug,
"https://as.example/authorize",
row.tokenUrl,
row.grant ?? "authorization_code",
`client-${row.slug}`,
row.resource ?? null,
row.originKind ?? null,
row.originIssuer ?? null,
Date.now(),
],
});
const insertConnection = (
db: SqliteTestFumaDb,
connectionName: string,
clientSlug: string,
): Promise =>
db.client.execute({
sql: `INSERT INTO connection
(row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
oauth_client, oauth_client_owner, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
`cn_${connectionName}`,
TENANT,
OWNER,
SUBJECT,
"acme",
connectionName,
"oauth",
"memory",
"{}",
clientSlug,
OWNER,
Date.now(),
Date.now(),
],
});
const slugs = async (db: SqliteTestFumaDb): Promise => {
const result = await db.client.execute("SELECT slug FROM oauth_client ORDER BY slug");
return result.rows.map((r) => String(r.slug));
};
const issuerOf = async (db: SqliteTestFumaDb, slug: string): Promise => {
const result = await db.client.execute({
sql: "SELECT origin_issuer FROM oauth_client WHERE slug = ?",
args: [slug],
});
const value = result.rows[0]?.origin_issuer;
return value == null ? null : String(value);
};
const originKindOf = async (db: SqliteTestFumaDb, slug: string): Promise => {
const result = await db.client.execute({
sql: "SELECT origin_kind FROM oauth_client WHERE slug = ?",
args: [slug],
});
const value = result.rows[0]?.origin_kind;
return value == null ? null : String(value);
};
describe("runSqliteOAuthClientGcMigration", () => {
it.effect("deletes orphaned DCR rows, keeps manual + referenced rows, backfills survivors", () =>
Effect.gen(function* () {
const outcome = yield* Effect.promise(() =>
withDb(async (db) => {
// Orphaned explicit DCR → delete.
await insertOAuthClient(db, {
slug: "dcr-cloudflare-com",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: "dynamic_client_registration",
originIssuer: "https://cloudflare.com",
});
// Orphaned legacy heuristic DCR (null origin) → delete.
await insertOAuthClient(db, {
slug: "cloudflare-mcp-2",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: null,
});
// Referenced legacy DCR (has a connection) → keep + backfill issuer.
await insertOAuthClient(db, {
slug: "cloudflare-mcp",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: null,
originIssuer: null,
});
await insertConnection(db, "cf-main", "cloudflare-mcp");
// Orphaned manual app → keep (never GC a hand-registered app).
await insertOAuthClient(db, {
slug: "my-github-app",
tokenUrl: "https://github.com/login/oauth/access_token",
resource: null,
originKind: "manual",
});
const applied = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client));
const surviving = await slugs(db);
const backfilled = await issuerOf(db, "cloudflare-mcp");
const survivorKind = await originKindOf(db, "cloudflare-mcp");
const manualKind = await originKindOf(db, "my-github-app");
const manualIssuer = await issuerOf(db, "my-github-app");
return {
applied,
surviving,
backfilled,
survivorKind,
manualKind,
manualIssuer,
};
}),
);
expect(outcome.applied).toEqual({
deleted: 2,
backfilled: 1,
stampedDcr: 1,
stampedManual: 0,
});
// Both orphaned DCR rows gone; the referenced DCR row and the manual app
// survive.
expect(outcome.surviving).toEqual(["cloudflare-mcp", "my-github-app"]);
// The surviving legacy DCR row got its issuer backfilled from token_url's
// registrable origin, so the per-AS reuse lookup can now key on it, and it
// is stamped as an explicit DCR row.
expect(outcome.backfilled).toBe("https://cloudflare.com");
expect(outcome.survivorKind).toBe("dynamic_client_registration");
// The manual app was already explicitly stamped, so its stamp is untouched
// and it never gets an origin_issuer.
expect(outcome.manualKind).toBe("manual");
expect(outcome.manualIssuer).toBeNull();
}),
);
it.effect("stamps a legacy null-origin manual survivor as manual with no issuer", () =>
Effect.gen(function* () {
const outcome = yield* Effect.promise(() =>
withDb(async (db) => {
// Legacy manual row: null origin_kind, auth-code, but NO resource, so
// the classifier keeps it manual. It must survive, get an explicit
// `manual` stamp, and NOT receive an origin_issuer.
await insertOAuthClient(db, {
slug: "legacy-manual",
tokenUrl: "https://github.com/login/oauth/access_token",
resource: null,
originKind: null,
originIssuer: null,
});
const applied = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client));
const surviving = await slugs(db);
const kind = await originKindOf(db, "legacy-manual");
const issuer = await issuerOf(db, "legacy-manual");
return { applied, surviving, kind, issuer };
}),
);
expect(outcome.applied).toEqual({
deleted: 0,
backfilled: 0,
stampedDcr: 0,
stampedManual: 1,
});
expect(outcome.surviving).toEqual(["legacy-manual"]);
expect(outcome.kind).toBe("manual");
expect(outcome.issuer).toBeNull();
}),
);
it.effect("is idempotent: a second run deletes nothing more and re-backfills nothing", () =>
Effect.gen(function* () {
const outcome = yield* Effect.promise(() =>
withDb(async (db) => {
await insertOAuthClient(db, {
slug: "cloudflare-mcp-2",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: null,
});
await insertOAuthClient(db, {
slug: "cloudflare-mcp",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: null,
originIssuer: null,
});
await insertConnection(db, "cf-main", "cloudflare-mcp");
const first = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client));
const afterFirst = await slugs(db);
const second = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client));
const afterSecond = await slugs(db);
return { first, afterFirst, second, afterSecond };
}),
);
expect(outcome.first).toEqual({
deleted: 1,
backfilled: 1,
stampedDcr: 1,
stampedManual: 0,
});
// Second pass is a no-op: the orphan is already gone, the survivor's issuer
// is set, and its origin_kind is stamped (so it is no longer a null-origin
// candidate for any of delete / backfill / stamp).
expect(outcome.second).toEqual({
deleted: 0,
backfilled: 0,
stampedDcr: 0,
stampedManual: 0,
});
expect(outcome.afterSecond).toEqual(outcome.afterFirst);
expect(outcome.afterSecond).toEqual(["cloudflare-mcp"]);
}),
);
it.effect("never deletes a manual app even when it is orphaned and MCP-shaped", () =>
Effect.gen(function* () {
const surviving = yield* Effect.promise(() =>
withDb(async (db) => {
// Explicit manual origin_kind must shield even an MCP-shaped slug from
// the heuristic.
await insertOAuthClient(db, {
slug: "cloudflare-mcp",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: "manual",
});
await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client));
return slugs(db);
}),
);
expect(surviving).toEqual(["cloudflare-mcp"]);
}),
);
it.effect("no-op on an empty oauth_client table", () =>
Effect.gen(function* () {
const applied = yield* Effect.promise(() =>
withDb((db) => Effect.runPromise(runSqliteOAuthClientGcMigration(db.client))),
);
expect(applied).toEqual({ deleted: 0, backfilled: 0, stampedDcr: 0, stampedManual: 0 });
}),
);
it.effect("rolls back the transaction when the migration fiber is interrupted mid-run", () =>
Effect.gen(function* () {
const surviving = yield* Effect.promise(() =>
withDb(async (db) => {
// Orphaned DCR row: without rollback-on-interrupt, an interruption
// landing after its DELETE (but before COMMIT) can still leave the
// transaction committed by a later statement, or the DELETE alone
// holding an open transaction — either way the row's fate becomes
// implementation-dependent instead of "the whole run never happened."
await insertOAuthClient(db, {
slug: "dcr-cloudflare-com",
tokenUrl: "https://oauth.cloudflare.com/token",
resource: "https://cloudflare.example/mcp",
originKind: "dynamic_client_registration",
originIssuer: "https://cloudflare.com",
});
// Interrupt the migration's fiber right after its DELETE statement
// executes (i.e. mid-transaction, well before COMMIT), by wrapping
// the client so the DELETE's returned promise resolves only after
// the outer fiber has been asked to interrupt.
const realExecute = db.client.execute.bind(db.client);
let releaseInterrupt: (() => void) | null = null;
const interruptSignal = new Promise((resolve) => {
releaseInterrupt = resolve;
});
const wrappedClient: typeof db.client = new Proxy(db.client, {
get(target, prop, receiver) {
if (prop !== "execute") return Reflect.get(target, prop, receiver);
return async (stmt: unknown) => {
const sql = typeof stmt === "string" ? stmt : (stmt as { sql: string }).sql;
const result = await realExecute(stmt as never);
if (typeof sql === "string" && sql.startsWith("DELETE FROM oauth_client")) {
// Let the test's interrupt fire, then hand control back so the
// fiber observes the interruption as its very next step.
releaseInterrupt?.();
await new Promise((r) => setTimeout(r, 20));
}
return result;
};
},
});
const fiber = Effect.runFork(runSqliteOAuthClientGcMigration(wrappedClient));
await interruptSignal;
await Effect.runPromise(Fiber.interrupt(fiber));
return slugs(db);
}),
);
// Rolled back: the orphaned row is exactly as it was before the run, not
// deleted (proving ROLLBACK ran) and not left half-mutated.
expect(surviving).toEqual(["dcr-cloudflare-com"]);
}),
);
});