Files
HealthLog/tests/integration/ai-provider-key-removal.test.ts
T
Marc-André Bombeck 65c296d430 fix(ai): audit setting and removing a stored provider key
Removing a provider key, like setting one, left no audit row, unlike
every other credential change. Each key slot a PATCH touches now writes
user.ai-provider.key.set or .remove with the slot and never the value.
2026-10-21 15:07:06 +02:00

157 lines
5.0 KiB
TypeScript

/**
* 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");
// The removal is audited, naming the slot and no value.
const audits = await getPrismaClient().auditLog.findMany({
where: { userId, action: { startsWith: "user.ai-provider.key." } },
select: { action: true, details: true },
});
expect(audits).toEqual([
{
action: "user.ai-provider.key.remove",
details: JSON.stringify({ slot: "openai" }),
},
]);
});
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);
});
}
});