import { expect, spyOn, test } from "bun:test"; import { join } from "node:path"; import { forEachDialect } from "@sttracker/db/testing"; import { decryptJson } from "../src/crypto.ts"; import { createConnection, deleteConnection, listConnections, makePluginContext, syncHandler, updateConnection } from "../src/connections.ts"; import { loadPlugins } from "../src/registry.ts"; import { makeUser } from "./sync/helpers.ts"; import { newId, nowIso } from "@sttracker/db/ids"; import type { Connection } from "../src/sync/run.ts"; const SECRET = "test-secret"; const warn = spyOn(console, "warn").mockImplementation(() => {}); const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]); warn.mockRestore(); const echo = reg.get("echo")!; forEachDialect("connections", (getDb) => { const raw = (id: string) => getDb().k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); const asConn = async (id: string): Promise => { const r = await raw(id); return { id, userId: r.user_id, pluginId: r.plugin_id, name: r.name, config: JSON.parse(r.config), credentialsEnc: r.credentials_enc, scheduleMinutes: null, cursor: r.cursor, status: r.status, lastRunAt: null, createdAt: r.created_at }; }; test("secrets live only in the encrypted blob and never in list", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "hi", token: "s3cret" } }, echo.manifest); const r = await raw(id); expect(JSON.parse(r.config)).toEqual({ label: "hi" }); expect(r.credentials_enc).not.toContain("s3cret"); expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "s3cret" }); const [v] = await listConnections(db, u); expect(JSON.stringify(v)).not.toContain("s3cret"); expect(v).toMatchObject({ id, config: { label: "hi" }, hasCredentials: true }); const ctx = await makePluginContext(db, SECRET, await asConn(id), "r", echo.manifest); expect(ctx.config).toEqual({ label: "hi", token: "s3cret" }); expect(await ctx.credentials.get()).toEqual({ token: "s3cret" }); }); test("update keeps omitted secrets; delete pauses and clears credentials", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { token: "t1" } }, echo.manifest); await updateConnection(db, SECRET, u, id, { config: { label: "new" }, scheduleMinutes: 30 }, echo.manifest); let r = await raw(id); expect(JSON.parse(r.config)).toEqual({ label: "new" }); expect(r.schedule_minutes).toBe(30); expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "t1" }); await updateConnection(db, SECRET, u, id, { config: { token: "t2" } }, echo.manifest); expect(await decryptJson(SECRET, (await raw(id)).credentials_enc!)).toEqual({ token: "t2" }); const other = await makeUser(db, "bob"); await expect(deleteConnection(db, other, id)).rejects.toThrow(); await deleteConnection(db, u, id); r = await raw(id); expect(r.status).toBe("paused"); expect(r.credentials_enc).toBeNull(); expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false); }); test("cursor persists on set; http uses manifest rate limit", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); const ctx = await makePluginContext(db, SECRET, await asConn(id), "r", echo.manifest); await ctx.cursor.set("c1"); expect((await raw(id)).cursor).toBe("c1"); expect(await ctx.cursor.get()).toBe("c1"); }); test("credentials.save persists encrypted before pull continues", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "explode", token: "t" } }, echo.manifest); const handler = syncHandler({ db, secret: SECRET, registry: reg }); await expect(handler({ connectionId: id })).rejects.toThrow(); const r = await raw(id); expect(await decryptJson(SECRET, r.credentials_enc!)).toEqual({ token: "t", refresh: "new-refresh" }); expect(r.status).toBe("error"); }); test("sync handler: full first, changes after a recent full, full when payload says so or full is stale", async () => { const db = getDb(); const u = await makeUser(db); const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: { label: "ok" } }, echo.manifest); const handler = syncHandler({ db, secret: SECRET, registry: reg }); const modes = async () => (await db.k.selectFrom("source_runs").select(["mode", "status"]).where("connection_id", "=", id).orderBy("started_at").orderBy("id").execute()).map((r) => `${r.mode}:${r.status}`); await handler({ connectionId: id }); await handler({ connectionId: id }); await handler({ connectionId: id, mode: "full" }); expect(await modes()).toEqual(["full:ok", "changes:ok", "full:ok"]); await db.k.updateTable("source_runs").set({ started_at: new Date(Date.now() - 8 * 86400_000).toISOString() }).where("connection_id", "=", id).execute(); await handler({ connectionId: id }); expect((await modes()).at(-1)).toBe("full:ok"); await db.k.insertInto("source_runs").values({ id: newId(), connection_id: id, mode: "full", started_at: nowIso(), finished_at: null, status: "failed", counts: "{}", log: "", error: "x" }).execute(); await handler({ connectionId: id }); expect((await modes()).at(-1)).toBe("changes:ok"); }); });