mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(settings): let a saved AI API key be removed
Every personal provider form showed a stored key and offered no way to take it back, since saving with an empty key field keeps the stored one. The OpenAI, Anthropic, local model and gateway forms now carry a confirmed "Remove API key" while a key is stored. It sends the existing PATCH /api/user/ai-provider with that one key as null, which clears the column and leaves the provider choice, model and the other keys alone.
This commit is contained in:
1 parent
41cf584a65
commit
6037ebd3d6
16 files changed
+431
No files matched your search
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Du folgst dem Standard des Servers ({level}).",
|
||||
"useDefault": "Standard des Servers verwenden ({level})",
|
||||
"saveError": "Die Stufe konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "API-Schlüssel entfernen",
|
||||
"title": "Gespeicherten API-Schlüssel entfernen?",
|
||||
"body": "HealthLog löscht den gespeicherten Schlüssel und kann ihn nicht wiederherstellen. Gib ihn erneut ein, um ihn weiter zu nutzen.",
|
||||
"confirm": "API-Schlüssel entfernen",
|
||||
"done": "API-Schlüssel entfernt.",
|
||||
"failed": "Der API-Schlüssel konnte nicht entfernt werden."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "You follow the server default ({level}).",
|
||||
"useDefault": "Use the server default ({level})",
|
||||
"saveError": "The level could not be saved. Please try again."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "Remove API key",
|
||||
"title": "Remove the saved API key?",
|
||||
"body": "HealthLog deletes the stored key and cannot recover it. Enter it again to keep using it.",
|
||||
"confirm": "Remove API key",
|
||||
"done": "API key removed.",
|
||||
"failed": "Could not remove the API key."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Sigues el valor predeterminado del servidor ({level}).",
|
||||
"useDefault": "Usar el valor predeterminado del servidor ({level})",
|
||||
"saveError": "No se pudo guardar el nivel. Inténtalo de nuevo."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "Quitar clave de API",
|
||||
"title": "¿Quitar la clave de API guardada?",
|
||||
"body": "HealthLog borra la clave guardada y no puede recuperarla. Vuelve a introducirla para seguir usándola.",
|
||||
"confirm": "Quitar clave de API",
|
||||
"done": "Clave de API quitada.",
|
||||
"failed": "No se pudo quitar la clave de API."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).",
|
||||
"useDefault": "Utiliser le réglage par défaut du serveur ({level})",
|
||||
"saveError": "Le niveau n'a pas pu être enregistré. Réessaie."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "Supprimer la clé API",
|
||||
"title": "Supprimer la clé API enregistrée ?",
|
||||
"body": "HealthLog efface la clé enregistrée et ne peut pas la récupérer. Saisissez-la de nouveau pour continuer à l’utiliser.",
|
||||
"confirm": "Supprimer la clé API",
|
||||
"done": "Clé API supprimée.",
|
||||
"failed": "Impossible de supprimer la clé API."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Segui l'impostazione predefinita del server ({level}).",
|
||||
"useDefault": "Usa l'impostazione predefinita del server ({level})",
|
||||
"saveError": "Non è stato possibile salvare il livello. Riprova."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "Rimuovi chiave API",
|
||||
"title": "Rimuovere la chiave API salvata?",
|
||||
"body": "HealthLog elimina la chiave salvata e non può recuperarla. Inseriscila di nuovo per continuare a usarla.",
|
||||
"confirm": "Rimuovi chiave API",
|
||||
"done": "Chiave API rimossa.",
|
||||
"failed": "Impossibile rimuovere la chiave API."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "서버 기본값({level})을 따르고 있어요.",
|
||||
"useDefault": "서버 기본값 사용({level})",
|
||||
"saveError": "단계를 저장하지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "API 키 삭제",
|
||||
"title": "저장된 API 키를 삭제할까요?",
|
||||
"body": "HealthLog가 저장된 키를 삭제하며 되돌릴 수 없어요. 계속 쓰려면 키를 다시 입력하세요.",
|
||||
"confirm": "API 키 삭제",
|
||||
"done": "API 키를 삭제했어요.",
|
||||
"failed": "API 키를 삭제하지 못했어요."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Je volgt de standaardinstelling van de server ({level}).",
|
||||
"useDefault": "Standaardinstelling van de server gebruiken ({level})",
|
||||
"saveError": "Het niveau kon niet worden opgeslagen. Probeer het opnieuw."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "API-sleutel verwijderen",
|
||||
"title": "Opgeslagen API-sleutel verwijderen?",
|
||||
"body": "HealthLog wist de opgeslagen sleutel en kan hem niet herstellen. Voer hem opnieuw in om hem te blijven gebruiken.",
|
||||
"confirm": "API-sleutel verwijderen",
|
||||
"done": "API-sleutel verwijderd.",
|
||||
"failed": "De API-sleutel kon niet worden verwijderd."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6440,6 +6440,14 @@
|
||||
"followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).",
|
||||
"useDefault": "Użyj ustawienia domyślnego serwera ({level})",
|
||||
"saveError": "Nie udało się zapisać poziomu. Spróbuj ponownie."
|
||||
},
|
||||
"removeKey": {
|
||||
"label": "Usuń klucz API",
|
||||
"title": "Usunąć zapisany klucz API?",
|
||||
"body": "HealthLog usuwa zapisany klucz i nie może go odzyskać. Wpisz go ponownie, aby dalej z niego korzystać.",
|
||||
"confirm": "Usuń klucz API",
|
||||
"done": "Usunięto klucz API.",
|
||||
"failed": "Nie udało się usunąć klucza API."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -137,6 +137,21 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/ai/remove-saved-key-button.tsx",
|
||||
destroys:
|
||||
"one stored personal AI provider key (OpenAI, Anthropic, the local model or the gateway); the provider choice and model stay",
|
||||
triggers: [
|
||||
"components/settings/ai/openai-provider-form.tsx",
|
||||
"components/settings/ai/anthropic-provider-form.tsx",
|
||||
"components/settings/ai/local-provider-form.tsx",
|
||||
"components/settings/ai/compat-provider-form.tsx",
|
||||
],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
undetected:
|
||||
"No detector sees it. The body comes from `removeSavedKeyBody` with a computed key (`{ [field]: null }`), which the D3 literal pattern does not match, and `/api/user/ai-provider` names no act. Registered by hand.",
|
||||
},
|
||||
{
|
||||
file: "components/settings/api-section.tsx",
|
||||
destroys: "an API token — revoked, and the secret is never shown again",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* A saved API key can be removed from every provider form that holds one.
|
||||
*
|
||||
* The forms showed "saved" beside the key field and nothing to take the key
|
||||
* back: an empty field on Save keeps the stored key by design, so a key once
|
||||
* entered stayed. The remove control renders on each of the four personal-key
|
||||
* forms exactly when that form's key is stored, and the body it sends clears
|
||||
* that one key and nothing beside it. The route's side (the column is cleared,
|
||||
* the other keys and the provider choice survive) is pinned against Postgres
|
||||
* in `tests/integration/ai-provider-key-removal.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
import { AnthropicProviderForm } from "../anthropic-provider-form";
|
||||
import { CompatProviderForm } from "../compat-provider-form";
|
||||
import { LocalProviderForm } from "../local-provider-form";
|
||||
import { OpenAIProviderForm } from "../openai-provider-form";
|
||||
import {
|
||||
removeSavedKeyBody,
|
||||
type SavedKeyField,
|
||||
} from "../remove-saved-key-button";
|
||||
import type { UserAIProvider } from "../shared";
|
||||
|
||||
function provider(overrides: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
return {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: null,
|
||||
compatModel: null,
|
||||
hasCompatKey: false,
|
||||
responseTimeoutSeconds: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type Form = ComponentType<{ userProvider: UserAIProvider | null | undefined }>;
|
||||
|
||||
const FORMS: {
|
||||
name: string;
|
||||
Form: Form;
|
||||
field: SavedKeyField;
|
||||
saved: Partial<UserAIProvider>;
|
||||
}[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
Form: OpenAIProviderForm,
|
||||
field: "openaiKey",
|
||||
saved: { hasOpenaiKey: true, openaiKeyPreview: "...abcd" },
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
Form: AnthropicProviderForm,
|
||||
field: "anthropicKey",
|
||||
saved: { hasAnthropicKey: true, anthropicKeyPreview: "...abcd" },
|
||||
},
|
||||
{
|
||||
name: "Local",
|
||||
Form: LocalProviderForm,
|
||||
field: "localKey",
|
||||
saved: { hasLocalKey: true },
|
||||
},
|
||||
{
|
||||
name: "OpenAI-compatible gateway",
|
||||
Form: CompatProviderForm,
|
||||
field: "compatKey",
|
||||
saved: { hasCompatKey: true },
|
||||
},
|
||||
];
|
||||
|
||||
function render(Form: Form, userProvider: UserAIProvider) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<Form userProvider={userProvider} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("remove a saved API key", () => {
|
||||
for (const { name, Form, field, saved } of FORMS) {
|
||||
describe(name, () => {
|
||||
const slot = `data-slot="settings-ai-remove-key-${field}"`;
|
||||
|
||||
it("offers the remove control while a key is stored", () => {
|
||||
const html = render(Form, provider(saved));
|
||||
expect(html).toContain(slot);
|
||||
expect(html).toContain("Remove API key");
|
||||
});
|
||||
|
||||
it("offers nothing to remove when no key is stored", () => {
|
||||
expect(render(Form, provider())).not.toContain(slot);
|
||||
});
|
||||
|
||||
it("sends a body that clears that key and nothing else", () => {
|
||||
const body = removeSavedKeyBody(field);
|
||||
expect(body).toEqual({ [field]: null });
|
||||
// The route reads the body through this schema; a field it did not
|
||||
// know would be dropped and the request answered "No valid fields".
|
||||
const parsed = aiProviderPatchSchema.safeParse(body);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data).toEqual({ [field]: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -169,6 +171,9 @@ export function AnthropicProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasAnthropicKey && (
|
||||
<RemoveSavedKeyButton field="anthropicKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -20,6 +20,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
uiToLegacyProviderEnum,
|
||||
@@ -152,6 +154,9 @@ export function CompatProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasCompatKey && (
|
||||
<RemoveSavedKeyButton field="compatKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -188,6 +190,7 @@ export function LocalProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasLocalKey && <RemoveSavedKeyButton field="localKey" />}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -21,6 +21,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
OPENAI_MODEL_PRESETS,
|
||||
@@ -207,6 +209,9 @@ export function OpenAIProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasOpenaiKey && (
|
||||
<RemoveSavedKeyButton field="openaiKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Removes the stored API key of one personal AI provider.
|
||||
*
|
||||
* Every provider form showed a saved key and offered no way to take it back:
|
||||
* saving with the key field empty keeps the stored key, by design, so a key
|
||||
* once entered stayed until the account was deleted. `PATCH
|
||||
* /api/user/ai-provider` has always cleared a key sent as `null`; this is the
|
||||
* control for it, shared by the four forms that hold a personal key so each
|
||||
* asks the same question and reports the same way.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The PATCH field that holds each provider's personal key. */
|
||||
export type SavedKeyField =
|
||||
"openaiKey" | "anthropicKey" | "localKey" | "compatKey";
|
||||
|
||||
/**
|
||||
* The body that clears exactly one key and touches nothing else: not the
|
||||
* provider choice, the model or the base URL, which stay as they were.
|
||||
*/
|
||||
export function removeSavedKeyBody(
|
||||
field: SavedKeyField,
|
||||
): Partial<Record<SavedKeyField, null>> {
|
||||
return { [field]: null };
|
||||
}
|
||||
|
||||
export function RemoveSavedKeyButton({ field }: { field: SavedKeyField }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPatch("/api/user/ai-provider", removeSavedKeyBody(field)),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
toastWrittenOutcome("success", t("settings.ai.removeKey.done"));
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.ai.removeKey.failed"), { cause: failure }),
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmButton
|
||||
slot={`settings-ai-remove-key-${field}`}
|
||||
destructive
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.removeKey.label")}
|
||||
title={t("settings.ai.removeKey.title")}
|
||||
body={t("settings.ai.removeKey.body")}
|
||||
confirmLabel={t("settings.ai.removeKey.confirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Removing one saved AI key, against a real account row.
|
||||
*
|
||||
* The settings forms send `{ <provider>Key: null }` to `PATCH
|
||||
* /api/user/ai-provider` to take a stored key back. This pins the route's
|
||||
* half of that against Postgres: the one ciphertext column is cleared, the
|
||||
* other keys, the provider choice and the model are left as they were, and
|
||||
* the read the form seeds from reports the key gone.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seedAccountWithKeys(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "ai-key-removal",
|
||||
email: "ai-key-removal@example.test",
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: "gpt-4o-mini",
|
||||
aiOpenaiKeyEncrypted: encrypt("sk-test-openai-0001"),
|
||||
aiAnthropicKeyEncrypted: encrypt("sk-ant-test-0002"),
|
||||
aiLocalKeyEncrypted: encrypt("local-test-0003"),
|
||||
aiCompatKeyEncrypted: encrypt("compat-test-0004"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function patch(body: unknown): Promise<Response> {
|
||||
const { PATCH } = await import("@/app/api/user/ai-provider/route");
|
||||
return PATCH(
|
||||
new NextRequest("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function readProvider(): Promise<Record<string, unknown>> {
|
||||
const { GET } = await import("@/app/api/user/ai-provider/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: Record<string, unknown> }).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider — removing one saved key", () => {
|
||||
it("clears that key's column and leaves every other setting", async () => {
|
||||
const userId = await seedAccountWithKeys();
|
||||
|
||||
const res = await patch({ openaiKey: null });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
expect(row.aiOpenaiKeyEncrypted).toBeNull();
|
||||
expect(row.aiAnthropicKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiLocalKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiCompatKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiProvider).toBe("OPENAI");
|
||||
expect(row.aiModel).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("the read the form seeds from reports the key gone", async () => {
|
||||
await seedAccountWithKeys();
|
||||
expect(await readProvider()).toMatchObject({
|
||||
hasOpenaiKey: true,
|
||||
hasAnthropicKey: true,
|
||||
});
|
||||
|
||||
await patch({ anthropicKey: null });
|
||||
|
||||
expect(await readProvider()).toMatchObject({
|
||||
hasOpenaiKey: true,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: true,
|
||||
hasCompatKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
for (const field of ["localKey", "compatKey"] as const) {
|
||||
it(`clears ${field} on its own`, async () => {
|
||||
await seedAccountWithKeys();
|
||||
await patch({ [field]: null });
|
||||
const data = await readProvider();
|
||||
expect(data[field === "localKey" ? "hasLocalKey" : "hasCompatKey"]).toBe(
|
||||
false,
|
||||
);
|
||||
expect(data.hasOpenaiKey).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user