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:
Marc-André Bombeck committed 2026-10-21 12:36:14 +02:00
1 parent 41cf584a65
commit 6037ebd3d6
16 files changed
+431

No files matched your search

@@ -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);
});
}
});