mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 5d1ae19771 into 3f7b9667c6
This commit is contained in:
commit
c4720021c5
5 changed files with 147 additions and 6 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -29,5 +29,9 @@ jobs:
|
|||
- name: Run TypeScript type checking
|
||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
||||
|
||||
- name: Run web unit tests
|
||||
working-directory: apps/web
|
||||
run: bun test
|
||||
|
||||
- name: Run Biome CI (format & lint on changed files)
|
||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { useViewMode } from "@/lib/view-mode-context"
|
|||
import type { ViewParamValue } from "@/lib/search-params"
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"
|
||||
import { addDocumentParam, docParam } from "@/lib/search-params"
|
||||
import { revokePluginKey } from "@/components/integrations/plugin-key-revocation"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -2971,9 +2972,11 @@ export function IntegrationsView({
|
|||
|
||||
const handleRevokePluginKey = async (keyId: string) => {
|
||||
try {
|
||||
await authClient.apiKey.delete({ keyId })
|
||||
toast.success("Plugin disconnected")
|
||||
refetchKeys()
|
||||
await revokePluginKey({
|
||||
deleteKey: () => authClient.apiKey.delete({ keyId }),
|
||||
onSuccess: () => toast.success("Plugin disconnected"),
|
||||
refetch: () => void refetchKeys(),
|
||||
})
|
||||
} catch {
|
||||
toast.error("Failed to disconnect plugin")
|
||||
}
|
||||
|
|
|
|||
107
apps/web/components/integrations/plugin-key-revocation.test.ts
Normal file
107
apps/web/components/integrations/plugin-key-revocation.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it, mock } from "bun:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { revokePluginKey } from "./plugin-key-revocation"
|
||||
|
||||
describe("revokePluginKey", () => {
|
||||
it("does not report success or refetch when Better Auth resolves an error", async () => {
|
||||
const onSuccess = mock(() => {})
|
||||
const refetch = mock(() => {})
|
||||
|
||||
await expect(
|
||||
revokePluginKey({
|
||||
deleteKey: async () => ({
|
||||
data: null,
|
||||
error: { message: "API key not found" },
|
||||
}),
|
||||
onSuccess,
|
||||
refetch,
|
||||
}),
|
||||
).rejects.toThrow("API key not found")
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not report success or refetch when deletion rejects", async () => {
|
||||
const onSuccess = mock(() => {})
|
||||
const refetch = mock(() => {})
|
||||
const deleteError = new Error("Network unavailable")
|
||||
|
||||
await expect(
|
||||
revokePluginKey({
|
||||
deleteKey: async () => {
|
||||
throw deleteError
|
||||
},
|
||||
onSuccess,
|
||||
refetch,
|
||||
}),
|
||||
).rejects.toBe(deleteError)
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("uses a fallback message when Better Auth omits one", async () => {
|
||||
const onSuccess = mock(() => {})
|
||||
const refetch = mock(() => {})
|
||||
|
||||
await expect(
|
||||
revokePluginKey({
|
||||
deleteKey: async () => ({ data: null, error: {} }),
|
||||
onSuccess,
|
||||
refetch,
|
||||
}),
|
||||
).rejects.toThrow("Failed to disconnect plugin")
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("reports success and refetches after Better Auth resolves success", async () => {
|
||||
const onSuccess = mock(() => {})
|
||||
const refetch = mock(() => {})
|
||||
|
||||
await revokePluginKey({
|
||||
deleteKey: async () => ({
|
||||
data: { success: true },
|
||||
error: null,
|
||||
}),
|
||||
onSuccess,
|
||||
refetch,
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("plugin revoke handler wiring", () => {
|
||||
const handlers = [
|
||||
{
|
||||
name: "integrations view",
|
||||
file: new URL("../integrations-view.tsx", import.meta.url),
|
||||
handler: "handleRevokePluginKey",
|
||||
},
|
||||
{
|
||||
name: "plugin detail",
|
||||
file: new URL("./plugins-detail.tsx", import.meta.url),
|
||||
handler: "handleRevoke",
|
||||
},
|
||||
]
|
||||
|
||||
for (const { name, file, handler } of handlers) {
|
||||
it(`${name} delegates revocation through the guarded helper`, async () => {
|
||||
const source = await readFile(file, "utf8")
|
||||
const handlerStart = source.indexOf(`const ${handler} = async`)
|
||||
const handlerSource = source.slice(handlerStart, handlerStart + 500)
|
||||
|
||||
expect(handlerStart).toBeGreaterThanOrEqual(0)
|
||||
expect(handlerSource).toContain("await revokePluginKey({")
|
||||
expect(handlerSource).toMatch(
|
||||
/deleteKey:\s*\(\)\s*=>\s*authClient\.apiKey\.delete\(\{\s*keyId\s*\}\)/,
|
||||
)
|
||||
expect(
|
||||
handlerSource.match(/authClient\.apiKey\.delete/g) ?? [],
|
||||
).toHaveLength(1)
|
||||
expect(handlerSource).toContain("onSuccess:")
|
||||
expect(handlerSource).toContain("refetch:")
|
||||
})
|
||||
}
|
||||
})
|
||||
24
apps/web/components/integrations/plugin-key-revocation.ts
Normal file
24
apps/web/components/integrations/plugin-key-revocation.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
type PluginKeyDeleteResult = {
|
||||
data: unknown
|
||||
error: { message?: string } | null
|
||||
}
|
||||
|
||||
type RevokePluginKeyOptions = {
|
||||
deleteKey: () => Promise<PluginKeyDeleteResult>
|
||||
onSuccess: () => void
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
export async function revokePluginKey({
|
||||
deleteKey,
|
||||
onSuccess,
|
||||
refetch,
|
||||
}: RevokePluginKeyOptions): Promise<void> {
|
||||
const result = await deleteKey()
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message ?? "Failed to disconnect plugin")
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
refetch()
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
||||
import { revokePluginKey } from "./plugin-key-revocation"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
interface ConnectedPlugin {
|
||||
|
|
@ -560,9 +561,11 @@ export function PluginsDetail() {
|
|||
|
||||
const handleRevoke = async (keyId: string) => {
|
||||
try {
|
||||
await authClient.apiKey.delete({ keyId })
|
||||
toast.success("Plugin disconnected")
|
||||
refetchKeys()
|
||||
await revokePluginKey({
|
||||
deleteKey: () => authClient.apiKey.delete({ keyId }),
|
||||
onSuccess: () => toast.success("Plugin disconnected"),
|
||||
refetch: () => void refetchKeys(),
|
||||
})
|
||||
} catch {
|
||||
toast.error("Failed to disconnect plugin")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue